From 4eb6f399db4fde261e40c8992a37fe31801b8afe Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Mon, 10 Aug 2026 22:43:35 -0700 Subject: [PATCH 01/17] Multicam scaffolding: feature flag, supports_multicam capability, sync metadata helpers Inert groundwork for the multicam director feature (PR0 of the plan): - FeatureFlags.ENABLE_MULTICAM=false master switch; cameras advertise the new supports_multicam capability tied to the flag, so the same release that flips it on starts advertising. - FlatBufferSchemas.fbs: supports_multicam appended to CameraCapabilities (same evolution pattern as supports_focus_point), regenerated with flatc, round-tripped in RemoteCmdSerializationTests including the legacy-peer absent-field default. - CaptureSyncMetadata: the per-clip alignment record (shared director-clock anchor + captureId/sessionId + offset quality) with filename prefix, QuickTime metadata items, and deterministic JSON; unused until synced capture ships. No behavior change; full suite green. Co-Authored-By: Claude Fable 5 --- RemoteCam/CaptureEngine.swift | 3 + RemoteCam/CaptureSyncMetadata.swift | 88 +++++++++++++++++++ RemoteCam/FeatureFlags.swift | 6 ++ RemoteCam/FlatBufferSchemas.fbs | 5 ++ RemoteCam/FlatBufferSchemas_generated.swift | 11 ++- RemoteCam/RemoteCmdFlatBuffers.swift | 4 +- RemoteCam/RemoteCmds.swift | 6 ++ RemoteCamTests/CaptureSyncMetadataTests.swift | 73 +++++++++++++++ .../RemoteCmdSerializationTests.swift | 16 ++++ RemoteShutter.xcodeproj/project.pbxproj | 8 ++ 10 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 RemoteCam/CaptureSyncMetadata.swift create mode 100644 RemoteCamTests/CaptureSyncMetadataTests.swift diff --git a/RemoteCam/CaptureEngine.swift b/RemoteCam/CaptureEngine.swift index 405eb268..76019483 100644 --- a/RemoteCam/CaptureEngine.swift +++ b/RemoteCam/CaptureEngine.swift @@ -938,6 +938,9 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { // This build understands SetCameraPreviewMode; advertise the current // persisted mode so the monitor reflects it from the first exchange. supportsPreviewMode: true, + // Tied to the flag so cameras start advertising multicam the same + // release the director UI ships. + supportsMulticam: FeatureFlags.ENABLE_MULTICAM, previewMode: CameraPreviewModeStore().load(), error: nil ) diff --git a/RemoteCam/CaptureSyncMetadata.swift b/RemoteCam/CaptureSyncMetadata.swift new file mode 100644 index 00000000..376e34a5 --- /dev/null +++ b/RemoteCam/CaptureSyncMetadata.swift @@ -0,0 +1,88 @@ +// +// CaptureSyncMetadata.swift +// RemoteShutter +// +// Created by Dario Lencina on 2026. +// Copyright © 2026 Security Union. All rights reserved. +// + +import AVFoundation +import Foundation + +/// Alignment metadata attached to every clip and photo captured in a multicam +/// director session. Each camera saves full-res media locally; these fields +/// are what let any editor (CapCut, FCP, Resolve) line the angles up without +/// the files ever leaving the phones. +/// +/// The `anchorMillis` timestamp is the scheduled fire time on the *director's* +/// clock, so it is identical across all N cameras' clips for one capture — +/// that shared value is the alignment key. `clockOffsetMillis`/ +/// `roundTripMillis` record how good this camera's clock estimate was when it +/// fired (a quality hint, not part of alignment). +struct CaptureSyncMetadata: Codable, Equatable { + /// One per multicam rig session (director generates it). + let sessionID: String + /// One per shutter press / record start, shared by every camera in the rig. + let captureID: String + /// Stable 1-based index of this camera in the rig, for humans and filenames. + let cameraIndex: Int + /// Scheduled fire time in ms on the director's clock — the alignment key. + let anchorMillis: UInt64 + /// This camera's estimated clock offset vs the director when it fired (ms). + let clockOffsetMillis: Int64 + /// RTT of the offset estimate (ms); smaller = tighter sync. + let roundTripMillis: Int64 + + /// QuickTime metadata keys, reverse-DNS in the `mdta` keyspace. + enum QuickTimeKey { + static let anchor = "com.remoteshutter.syncAnchorMs" + static let capture = "com.remoteshutter.captureId" + static let session = "com.remoteshutter.sessionId" + static let offset = "com.remoteshutter.clockOffsetMs" + } + + /// `RS___cam` — groups one capture's files across cameras + /// when they land in a shared folder or an editor's media bin. Uses the + /// first UUID group so names stay readable. + var filenamePrefix: String { + "RS_\(Self.shortID(sessionID))_\(Self.shortID(captureID))_cam\(cameraIndex)" + } + + /// Items for `AVAssetWriter.metadata` so the values travel inside the + /// .mov itself and survive export/AirDrop. + func quickTimeMetadataItems() -> [AVMetadataItem] { + [ + Self.item(key: QuickTimeKey.anchor, value: NSNumber(value: anchorMillis)), + Self.item(key: QuickTimeKey.capture, value: captureID as NSString), + Self.item(key: QuickTimeKey.session, value: sessionID as NSString), + Self.item(key: QuickTimeKey.offset, value: NSNumber(value: clockOffsetMillis)), + ] + } + + /// JSON blob for the photo path (EXIF UserComment) and the Documents + /// sidecar. Sorted keys so output is deterministic and testable. + func jsonString() -> String? { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(self) else { return nil } + return String(data: data, encoding: .utf8) + } + + static func fromJSONString(_ string: String) -> CaptureSyncMetadata? { + guard let data = string.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(CaptureSyncMetadata.self, from: data) + } + + private static func shortID(_ uuidString: String) -> String { + String(uuidString.prefix(8)).lowercased() + } + + private static func item(key: String, value: NSCopying & NSObjectProtocol) -> AVMetadataItem { + let item = AVMutableMetadataItem() + item.identifier = AVMetadataItem.identifier(forKey: key, keySpace: .quickTimeMetadata) + item.keySpace = .quickTimeMetadata + item.key = key as NSString + item.value = value + return item + } +} diff --git a/RemoteCam/FeatureFlags.swift b/RemoteCam/FeatureFlags.swift index b9541695..de704651 100644 --- a/RemoteCam/FeatureFlags.swift +++ b/RemoteCam/FeatureFlags.swift @@ -31,6 +31,12 @@ struct FeatureFlags { /// one-time buy, and the entitlement code stays in place for when it flips on. static let ENABLE_PRO_SUBSCRIPTION = false + /// Multicam director mode: one monitor controlling several cameras with + /// synced capture. Off until the feature ships (target 9.1.0); while off, + /// cameras advertise `supports_multicam=false` and the scanner keeps its + /// single-camera flow. + static let ENABLE_MULTICAM = false + /// Show the local camera-device picker on the camera screen. On for Mac /// Catalyst only (a Mac has N cameras — built-in, Continuity, USB); /// iPhone keeps its flip button. diff --git a/RemoteCam/FlatBufferSchemas.fbs b/RemoteCam/FlatBufferSchemas.fbs index 303bf1ea..99e6f4f6 100644 --- a/RemoteCam/FlatBufferSchemas.fbs +++ b/RemoteCam/FlatBufferSchemas.fbs @@ -257,6 +257,11 @@ table CameraCapabilities { // not send SetCameraPreviewMode to such a peer (old decoders read the // unknown action as its enum default). 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). + supports_multicam: bool; } // MARK: - Response Structure diff --git a/RemoteCam/FlatBufferSchemas_generated.swift b/RemoteCam/FlatBufferSchemas_generated.swift index 951f0efd..f2859ac2 100644 --- a/RemoteCam/FlatBufferSchemas_generated.swift +++ b/RemoteCam/FlatBufferSchemas_generated.swift @@ -1011,6 +1011,7 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { case activeDeviceId = 10 case supportsFocusPoint = 12 case supportsPreviewMode = 14 + case supportsMulticam = 16 var v: Int32 { Int32(self.rawValue) } var p: VOffset { self.rawValue } } @@ -1024,7 +1025,8 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { 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 static func startCameraCapabilities(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 6) } + 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 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) } @@ -1033,6 +1035,8 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { 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 endCameraCapabilities(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } public static func createCameraCapabilities( _ fbb: inout FlatBufferBuilder, @@ -1041,7 +1045,8 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { cameraDevicesVectorOffset cameraDevices: Offset = Offset(), activeDeviceIdOffset activeDeviceId: Offset = Offset(), supportsFocusPoint: Bool = false, - supportsPreviewMode: Bool = false + supportsPreviewMode: Bool = false, + supportsMulticam: Bool = false ) -> Offset { let __start = RemoteShutter_CameraCapabilities.startCameraCapabilities(&fbb) RemoteShutter_CameraCapabilities.add(frontCamera: frontCamera, &fbb) @@ -1050,6 +1055,7 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { RemoteShutter_CameraCapabilities.add(activeDeviceId: activeDeviceId, &fbb) RemoteShutter_CameraCapabilities.add(supportsFocusPoint: supportsFocusPoint, &fbb) RemoteShutter_CameraCapabilities.add(supportsPreviewMode: supportsPreviewMode, &fbb) + RemoteShutter_CameraCapabilities.add(supportsMulticam: supportsMulticam, &fbb) return RemoteShutter_CameraCapabilities.endCameraCapabilities(&fbb, start: __start) } @@ -1061,6 +1067,7 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { 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) _v.finish() } } diff --git a/RemoteCam/RemoteCmdFlatBuffers.swift b/RemoteCam/RemoteCmdFlatBuffers.swift index 206e718a..a7d06001 100644 --- a/RemoteCam/RemoteCmdFlatBuffers.swift +++ b/RemoteCam/RemoteCmdFlatBuffers.swift @@ -418,7 +418,8 @@ private func encodeCapabilitiesEnvelope( cameraDevicesVectorOffset: devicesVector, activeDeviceIdOffset: activeIDOffset, supportsFocusPoint: c.supportsFocusPoint, - supportsPreviewMode: c.supportsPreviewMode) + supportsPreviewMode: c.supportsPreviewMode, + supportsMulticam: c.supportsMulticam) let stateOffset = RemoteShutter_CameraState.createCameraState( &fbb, @@ -1303,6 +1304,7 @@ extension RemoteCmd { activeDeviceID: activeDeviceID, supportsFocusPoint: caps?.supportsFocusPoint ?? false, supportsPreviewMode: caps?.supportsPreviewMode ?? false, + supportsMulticam: caps?.supportsMulticam ?? false, previewMode: state.map { fromFBPreviewMode($0.previewMode) } ?? .on, error: error ) diff --git a/RemoteCam/RemoteCmds.swift b/RemoteCam/RemoteCmds.swift index 4071071e..c8773367 100644 --- a/RemoteCam/RemoteCmds.swift +++ b/RemoteCam/RemoteCmds.swift @@ -410,6 +410,10 @@ public class RemoteCmd: Message, @unchecked Sendable { /// `RemoteCmd.SetCameraPreviewMode`. The monitor's standby gate reads /// this so it never sends the command to a peer that would misread it. 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. + public let supportsMulticam: Bool /// The camera's current local-preview mode, so the monitor can reflect /// it from the first capabilities exchange. public let previewMode: CameraPreviewMode @@ -426,6 +430,7 @@ public class RemoteCmd: Message, @unchecked Sendable { activeDeviceID: String? = nil, supportsFocusPoint: Bool = false, supportsPreviewMode: Bool = false, + supportsMulticam: Bool = false, previewMode: CameraPreviewMode = .on, error: Error?) { self.frontCamera = frontCamera @@ -441,6 +446,7 @@ public class RemoteCmd: Message, @unchecked Sendable { self.activeDeviceID = activeDeviceID self.supportsFocusPoint = supportsFocusPoint self.supportsPreviewMode = supportsPreviewMode + self.supportsMulticam = supportsMulticam self.previewMode = previewMode self.error = error super.init(sender: nil) diff --git a/RemoteCamTests/CaptureSyncMetadataTests.swift b/RemoteCamTests/CaptureSyncMetadataTests.swift new file mode 100644 index 00000000..b47ae2aa --- /dev/null +++ b/RemoteCamTests/CaptureSyncMetadataTests.swift @@ -0,0 +1,73 @@ +// +// CaptureSyncMetadataTests.swift +// RemoteShutterTests +// +// Created by Dario Lencina on 2026. +// Copyright © 2026 Security Union. All rights reserved. +// + +import AVFoundation +import XCTest +@testable import RemoteShutter + +final class CaptureSyncMetadataTests: XCTestCase { + + private let sample = CaptureSyncMetadata( + sessionID: "6BB65B12-30A4-4A5C-9F41-000000000001", + captureID: "D0E1F2A3-1111-2222-3333-000000000002", + cameraIndex: 3, + anchorMillis: 1_754_800_000_123, + clockOffsetMillis: -42, + roundTripMillis: 11 + ) + + func testFilenamePrefixGroupsBySessionCaptureAndCamera() { + XCTAssertEqual(sample.filenamePrefix, "RS_6bb65b12_d0e1f2a3_cam3") + } + + func testJSONRoundTrip() { + guard let json = sample.jsonString() else { + return XCTFail("expected JSON encoding to succeed") + } + XCTAssertEqual(CaptureSyncMetadata.fromJSONString(json), sample) + } + + func testJSONIsDeterministic() { + XCTAssertEqual(sample.jsonString(), sample.jsonString()) + } + + func testQuickTimeItemsCarryAnchorAndIDs() { + let items = sample.quickTimeMetadataItems() + XCTAssertEqual(items.count, 4) + + func value(for key: String) -> Any? { + items.first { ($0.key as? String) == key }?.value + } + XCTAssertEqual( + (value(for: CaptureSyncMetadata.QuickTimeKey.anchor) as? NSNumber)?.uint64Value, + sample.anchorMillis) + XCTAssertEqual( + value(for: CaptureSyncMetadata.QuickTimeKey.capture) as? String, + sample.captureID) + XCTAssertEqual( + value(for: CaptureSyncMetadata.QuickTimeKey.session) as? String, + sample.sessionID) + XCTAssertEqual( + (value(for: CaptureSyncMetadata.QuickTimeKey.offset) as? NSNumber)?.int64Value, + sample.clockOffsetMillis) + for item in items { + XCTAssertEqual(item.keySpace, .quickTimeMetadata) + XCTAssertNotNil(item.identifier) + } + } + + /// The wire capability appended for multicam must default to false for + /// legacy peers (absent field) — PR0 ships it inert. + func testCapabilitiesDefaultToNoMulticam() { + let resp = RemoteCmd.CameraCapabilitiesResp( + frontCamera: nil, backCamera: nil, + currentCamera: .back, currentLens: .wideAngle, + currentZoom: 1.0, error: nil) + XCTAssertFalse(resp.supportsMulticam) + } +} diff --git a/RemoteCamTests/RemoteCmdSerializationTests.swift b/RemoteCamTests/RemoteCmdSerializationTests.swift index a045eccf..72153162 100644 --- a/RemoteCamTests/RemoteCmdSerializationTests.swift +++ b/RemoteCamTests/RemoteCmdSerializationTests.swift @@ -1182,4 +1182,20 @@ extension RemoteCmdSerializationTests { XCTAssertFalse(result.supportsPreviewMode) XCTAssertEqual(result.previewMode, .on) } + + /// The multicam capability survives the wire, and a peer that predates it + /// (absent field) decodes as not-multicam-capable. + func testCapabilitiesCarryMulticamSupport() { + let caps = RemoteCmd.CameraCapabilitiesResp( + frontCamera: nil, backCamera: nil, + currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, + 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) + XCTAssertFalse(roundTrip(legacy).supportsMulticam) + } } diff --git a/RemoteShutter.xcodeproj/project.pbxproj b/RemoteShutter.xcodeproj/project.pbxproj index 223ad010..6cbb744b 100644 --- a/RemoteShutter.xcodeproj/project.pbxproj +++ b/RemoteShutter.xcodeproj/project.pbxproj @@ -201,6 +201,8 @@ FADEC0DE0002000000000002 /* PeerLinkStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FADEC0DE0002000000000001 /* PeerLinkStatus.swift */; }; FC0CF5A101FE65A9800F0B238 /* FocusPointMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC0CF5A201FE65A9800F0B238 /* FocusPointMapping.swift */; }; CAFEBABE0120000000000001 /* MonitorChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0120000000000002 /* MonitorChrome.swift */; }; + CAFEBABE0130000000000001 /* CaptureSyncMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */; }; + CAFEBABE0131000000000002 /* CaptureSyncMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0131000000000001 /* CaptureSyncMetadataTests.swift */; }; FEEDFACE0000000000000001 /* StormoLoopbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDFACE0000000000000002 /* StormoLoopbackTests.swift */; }; /* End PBXBuildFile section */ @@ -442,6 +444,8 @@ FADEC0DE0002000000000001 /* PeerLinkStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerLinkStatus.swift; sourceTree = ""; }; FC0CF5A201FE65A9800F0B238 /* FocusPointMapping.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FocusPointMapping.swift; sourceTree = ""; }; CAFEBABE0120000000000002 /* MonitorChrome.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MonitorChrome.swift; sourceTree = ""; }; + CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CaptureSyncMetadata.swift; sourceTree = ""; }; + CAFEBABE0131000000000001 /* CaptureSyncMetadataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptureSyncMetadataTests.swift; sourceTree = ""; }; FCB0F6BA2086BF9BB4242D66 /* Pods_RemoteShutterWatch.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RemoteShutterWatch.framework; sourceTree = BUILT_PRODUCTS_DIR; }; FEEDFACE0000000000000002 /* StormoLoopbackTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StormoLoopbackTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -591,6 +595,7 @@ CAFEBABE0001000000000001 /* CropRectTests.swift */, CAFEBABE00F0000000000001 /* FocusPointMappingTests.swift */, CAFEBABE0121000000000001 /* MonitorChromeTests.swift */, + CAFEBABE0131000000000001 /* CaptureSyncMetadataTests.swift */, CAFEBABE0002000000000001 /* WatchCaptureCountdownTests.swift */, CAFEBABE0003000000000001 /* WatchSerializationTests.swift */, CAFEBABE0006000000000001 /* WatchPreviewStreamerTests.swift */, @@ -683,6 +688,7 @@ 0684A2D01BE65A9800F0B238 /* OrientationUtils.swift */, FC0CF5A201FE65A9800F0B238 /* FocusPointMapping.swift */, CAFEBABE0120000000000002 /* MonitorChrome.swift */, + CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */, 060B1E141BE7079800077BCC /* Helpers */, 06E965202535199400E5A8B3 /* MediaProcessors.swift */, 06E9652625351E3F00E5A8B3 /* SwiftConstants.swift */, @@ -1175,6 +1181,7 @@ 0684A2D11BE65A9800F0B238 /* OrientationUtils.swift in Sources */, FC0CF5A101FE65A9800F0B238 /* FocusPointMapping.swift in Sources */, CAFEBABE0120000000000001 /* MonitorChrome.swift in Sources */, + CAFEBABE0130000000000001 /* CaptureSyncMetadata.swift in Sources */, 06BB79B82E374D410094E085 /* FeatureFlags.swift in Sources */, 0692844F1BE5C0E600AF4678 /* MultipeerMessages.swift in Sources */, 069284531BE5C0E600AF4678 /* RemoteCamStateNames.swift in Sources */, @@ -1247,6 +1254,7 @@ CAFEBABE0001000000000002 /* CropRectTests.swift in Sources */, CAFEBABE00F0000000000002 /* FocusPointMappingTests.swift in Sources */, CAFEBABE0121000000000002 /* MonitorChromeTests.swift in Sources */, + CAFEBABE0131000000000002 /* CaptureSyncMetadataTests.swift in Sources */, CAFEBABE0002000000000002 /* WatchCaptureCountdownTests.swift in Sources */, CAFEBABE0003000000000002 /* WatchSerializationTests.swift in Sources */, CAFEBABE0006000000000002 /* WatchPreviewStreamerTests.swift in Sources */, From b3a3632d399717a11434803e4f458713dbe56933 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Mon, 10 Aug 2026 22:48:35 -0700 Subject: [PATCH 02/17] Seams for multicam: source peer on inbound messages, per-peer frame acks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two behavior-preserving cuts that the multicam director needs (PR1): - MultipeerServiceDelegate.didReceiveMessage now carries the source peer. The 1:1 SessionCoordinator ignores it (its single link makes the source unambiguous); a multicam controller will route responses by it. - sendMessage/sendOrGoToScanning accept an explicit peer list (nil = all connected, unchanged), and the monitor's frame ack now addresses only the camera whose frame was consumed — with several cameras, a broadcast ack would advance every camera's credit window on one camera's frame. Single-cam behavior is identical (one connected peer makes both forms the same). New test proves the ack targets only the sending peer with two peers connected; full suite green. Co-Authored-By: Claude Fable 5 --- RemoteCam/MultipeerService.swift | 7 +++-- RemoteCam/SessionCoordinator.swift | 31 ++++++++++++++++------ RemoteCamTests/LoopbackSessionTests.swift | 2 +- RemoteCamTests/RemoteCamSessionTests.swift | 27 +++++++++++++++++++ RemoteCamTests/StormoLoopbackTests.swift | 2 +- 5 files changed, 57 insertions(+), 12 deletions(-) diff --git a/RemoteCam/MultipeerService.swift b/RemoteCam/MultipeerService.swift index 98200ee8..dfaa7603 100644 --- a/RemoteCam/MultipeerService.swift +++ b/RemoteCam/MultipeerService.swift @@ -12,7 +12,10 @@ import Combine import UIKit protocol MultipeerServiceDelegate: AnyObject { - func didReceiveMessage(_ message: Message) + /// `peer` is the message's source. The 1:1 SessionCoordinator can ignore + /// it (there is only one peer a message can come from); a multicam + /// director needs it to route responses to the right camera. + func didReceiveMessage(_ message: Message, from peer: MCPeerID) func didReceiveFrameRequest(_ request: RemoteCmd.RequestFrame) func didReceiveFrame(_ frame: RemoteCmd.SendFrame, from peer: MCPeerID) func peerDidConnect(_ peer: MCPeerID) @@ -205,7 +208,7 @@ class MultipeerService: NSObject, MCSessionDelegate, case let frame as RemoteCmd.SendFrame: delegate?.didReceiveFrame(frame, from: peerID) default: - delegate?.didReceiveMessage(inboundMessage) + delegate?.didReceiveMessage(inboundMessage, from: peerID) } } diff --git a/RemoteCam/SessionCoordinator.swift b/RemoteCam/SessionCoordinator.swift index 58e8ff0f..e7bd66f3 100644 --- a/RemoteCam/SessionCoordinator.swift +++ b/RemoteCam/SessionCoordinator.swift @@ -302,23 +302,27 @@ public actor SessionCoordinator { } @discardableResult - func sendMessage(_ msg: Message, mode: MCSessionSendDataMode = .reliable) -> Bool { + func sendMessage(_ msg: Message, to peers: [MCPeerID]? = nil, + mode: MCSessionSendDataMode = .reliable) -> Bool { guard let multipeerService else { // Watch Remote mode never starts a multipeer session. return false } - return multipeerService.send(msg, to: connectedPeers, mode: mode) + return multipeerService.send(msg, to: peers ?? connectedPeers, mode: mode) } /// Send, or pop to scanning with a connection-error alert on failure — - /// the old `sendCommandOrGoToScanning`. - func sendOrGoToScanning(_ msg: Message, mode: MCSessionSendDataMode = .reliable) async { + /// the old `sendCommandOrGoToScanning`. `peers` nil = all connected + /// (identical for this 1:1 coordinator; the param exists so per-peer + /// sends like frame acks address only their source). + func sendOrGoToScanning(_ msg: Message, to peers: [MCPeerID]? = nil, + mode: MCSessionSendDataMode = .reliable) async { guard multipeerService != nil else { // Watch Remote mode: there is no peer and no scanning state to fall back to. debugLog("sendOrGoToScanning: no multipeer session, dropping \(type(of: msg))") return } - if !sendMessage(msg, mode: mode) { + if !sendMessage(msg, to: peers, mode: mode) { await popToScanning() let presenter = alertPresenter OperationQueue.main.addOperation { @@ -542,6 +546,14 @@ public actor SessionCoordinator { await sendOrGoToScanning(RemoteCmd.RequestFrame(sender: nil)) } + /// Ack the camera that sent this frame so only its credit window + /// advances. With one connected peer this is identical to the broadcast + /// form; with several cameras a broadcast ack would let every camera + /// send on one camera's consumed frame. + private func requestFrame(acking frame: RemoteCmd.OnFrame) async { + await sendOrGoToScanning(RemoteCmd.RequestFrame(sender: nil), to: [frame.peerId]) + } + /// Pop to scanning (stops at the lobby floor like the old machine) and /// restart discovery via `.scanning`'s entry behavior. func popToScanning() async { @@ -1590,7 +1602,7 @@ public actor SessionCoordinator { case let frame as RemoteCmd.OnFrame: noteMonitorFrame(frame) monitor?.show(frame: frame) - await requestFrame() + await requestFrame(acking: frame) case is UICmd.StreamStalled: await requestFrame() @@ -1893,7 +1905,7 @@ public actor SessionCoordinator { case let frame as RemoteCmd.OnFrame: noteMonitorFrame(frame) monitor?.show(frame: frame) - await requestFrame() + await requestFrame(acking: frame) case is UICmd.StreamStalled: await requestFrame() @@ -2426,7 +2438,10 @@ public actor SessionCoordinator { extension SessionCoordinator: MultipeerServiceDelegate { - public nonisolated func didReceiveMessage(_ message: Message) { + public nonisolated func didReceiveMessage(_ message: Message, from peer: MCPeerID) { + // `peer` is deliberately unused: this coordinator links exactly one + // peer, so every message's source is unambiguous. The multicam + // director's controller is the consumer that routes by it. tell(UICmd.PeerTrafficObserved()) tell(message) } diff --git a/RemoteCamTests/LoopbackSessionTests.swift b/RemoteCamTests/LoopbackSessionTests.swift index 87b176c7..5f97a598 100644 --- a/RemoteCamTests/LoopbackSessionTests.swift +++ b/RemoteCamTests/LoopbackSessionTests.swift @@ -82,7 +82,7 @@ class LoopbackMultipeerService: MultipeerServiceProtocol { case let frame as RemoteCmd.SendFrame: remoteDelegate.didReceiveFrame(frame, from: localPeerID) default: - remoteDelegate.didReceiveMessage(decoded) + remoteDelegate.didReceiveMessage(decoded, from: localPeerID) } return true } diff --git a/RemoteCamTests/RemoteCamSessionTests.swift b/RemoteCamTests/RemoteCamSessionTests.swift index e748d145..b3907563 100644 --- a/RemoteCamTests/RemoteCamSessionTests.swift +++ b/RemoteCamTests/RemoteCamSessionTests.swift @@ -260,6 +260,33 @@ class SessionCoordinatorTests: XCTestCase { XCTAssertEqual(frameRequests[0].mode, .reliable) } + /// Seam B: a frame's ack goes only to the camera that sent it. With two + /// peers connected, a broadcast ack would advance the credit window of a + /// camera whose frame was never consumed. + func testFrameAckTargetsOnlyTheSendingPeer() async { + let secondCamera = MCPeerID(displayName: "SecondCamera") + await enterMonitor(.Photo) + harness.fakeMP.connectedPeers.append(secondCamera) + harness.fakeMP.sendResult = true + + await harness.deliver(RemoteCmd.OnFrame( + data: Data([1, 2, 3]), sender: nil, peerId: secondCamera, + fps: 30, camPosition: .back, camOrientation: .portrait, + codec: .jpeg, sequenceNumber: 1)) + + let acks = sent(RemoteCmd.RequestFrame.self) + XCTAssertEqual(acks.count, 1) + XCTAssertEqual(acks[0].peers, [secondCamera], + "ack must address the frame's source, not all connected peers") + + harness.fakeMP.sentMessages.removeAll() + await harness.deliver(RemoteCmd.OnFrame( + data: Data([4, 5, 6]), sender: nil, peerId: harness.peer, + fps: 30, camPosition: .back, camOrientation: .portrait, + codec: .jpeg, sequenceNumber: 2)) + XCTAssertEqual(sent(RemoteCmd.RequestFrame.self).map(\.peers), [[harness.peer]]) + } + func testMonitorPhotoModeUnbecomeMonitorPopsToConnected() async { await enterMonitor(.Photo) await harness.deliver(UICmd.UnbecomeMonitor(sender: nil)) diff --git a/RemoteCamTests/StormoLoopbackTests.swift b/RemoteCamTests/StormoLoopbackTests.swift index 9a4a1f7f..4c4928ad 100644 --- a/RemoteCamTests/StormoLoopbackTests.swift +++ b/RemoteCamTests/StormoLoopbackTests.swift @@ -57,7 +57,7 @@ final class StormoLoopbackTests: XCTestCase { func peerDidConnect(_ peer: PeerID) { connected.fulfill() } - func didReceiveMessage(_ message: Message) { + func didReceiveMessage(_ message: Message, from peer: PeerID) { lock.lock(); _receivedMessages.append(message); lock.unlock() messageReceived.fulfill() } From 123fcd5c1ce619352015d45d9ef183a45fdea179 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Mon, 10 Aug 2026 22:55:05 -0700 Subject: [PATCH 03/17] Clock sync: ClockSyncPing/Pong wire messages and ClockOffsetEstimator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR2 of the multicam plan. The synced-shutter foundation: the director will schedule captures on each camera's own clock, which needs a measured per-camera offset. - Wire: ClockSyncPing (action 25, carries director clock at send) answered with a CameraStateResponse echoing t0 plus the camera clock at receipt. Appended-field schema evolution; round-trip tested. Only ever sent to peers advertising supports_multicam. - The camera answers pings in the nonisolated delegate callback, off the actor inbox, so queued state-machine work cannot smear the timestamp (same pacing precedent as frame acks). Pong is addressed to the pinging peer only. - ClockOffsetEstimator: pure NTP-style min-RTT-of-5 window; offset = cameraClock − (t0 + rtt/2); rejects stale pongs; reset() for background/reconnect invalidation. SyncClock supplies the monotonic ms clock both sides read. Inert in production until a director sends pings. Full suite green. Co-Authored-By: Claude Fable 5 --- RemoteCam/ClockOffsetEstimator.swift | 74 +++++++++++++++++ RemoteCam/FlatBufferSchemas.fbs | 10 ++- RemoteCam/FlatBufferSchemas_generated.swift | 29 +++++-- RemoteCam/RemoteCmdFlatBuffers.swift | 31 +++++++ RemoteCam/RemoteCmds.swift | 26 ++++++ RemoteCam/SessionCoordinator.swift | 18 ++++- .../ClockOffsetEstimatorTests.swift | 80 +++++++++++++++++++ RemoteCamTests/RemoteCamSessionTests.swift | 15 ++++ .../RemoteCmdSerializationTests.swift | 14 ++++ RemoteShutter.xcodeproj/project.pbxproj | 8 ++ 10 files changed, 296 insertions(+), 9 deletions(-) create mode 100644 RemoteCam/ClockOffsetEstimator.swift create mode 100644 RemoteCamTests/ClockOffsetEstimatorTests.swift diff --git a/RemoteCam/ClockOffsetEstimator.swift b/RemoteCam/ClockOffsetEstimator.swift new file mode 100644 index 00000000..2bc4c8e4 --- /dev/null +++ b/RemoteCam/ClockOffsetEstimator.swift @@ -0,0 +1,74 @@ +// +// ClockOffsetEstimator.swift +// RemoteShutter +// +// Created by Dario Lencina on 2026. +// Copyright © 2026 Security Union. All rights reserved. +// + +import Foundation + +/// The millisecond clock both ends of a clock-sync exchange read. Monotonic +/// while the app runs (never jumps with NTP/wall-clock changes), but it +/// pauses in deep sleep and restarts per boot — which is why offsets are +/// re-estimated on every foreground and never persisted. +enum SyncClock { + static func nowMillis() -> UInt64 { + DispatchTime.now().uptimeNanoseconds / 1_000_000 + } +} + +/// One ping/pong exchange reduced to an offset estimate. +struct ClockOffsetSample: Equatable { + /// camera clock − director clock, in ms. Adding this to a director + /// timestamp yields the same instant on the camera's clock. + let offsetMillis: Int64 + /// Round trip of the exchange; smaller = tighter estimate (the error + /// bound is the RTT asymmetry, at most rtt/2). + let roundTripMillis: Int64 +} + +/// NTP-style offset estimation over the session link: keep a short window of +/// exchanges and trust the minimum-RTT one — a queued or retransmitted +/// exchange has a large RTT and an unreliable midpoint, so it should never +/// outvote a clean one. +/// +/// Pure value type: callers supply every timestamp, so tests need no clocks. +struct ClockOffsetEstimator { + static let windowSize = 5 + + private(set) var samples: [ClockOffsetSample] = [] + + /// Record one exchange: director sent at `t0Millis`, camera stamped + /// `cameraClockMillis` at receipt, director received the pong at + /// `t3Millis` (all in each side's own `SyncClock`). Returns the sample, + /// or nil for a nonsensical exchange (pong before ping — a stale reply + /// from before a clock reset). + @discardableResult + mutating func recordExchange(t0Millis: UInt64, + cameraClockMillis: UInt64, + t3Millis: UInt64) -> ClockOffsetSample? { + guard t3Millis >= t0Millis else { return nil } + let rtt = Int64(t3Millis - t0Millis) + let directorMidpoint = Int64(bitPattern: t0Millis) + rtt / 2 + let sample = ClockOffsetSample( + offsetMillis: Int64(bitPattern: cameraClockMillis) - directorMidpoint, + roundTripMillis: rtt) + samples.append(sample) + if samples.count > Self.windowSize { + samples.removeFirst(samples.count - Self.windowSize) + } + return sample + } + + /// The trusted estimate: the minimum-RTT sample in the window. + var best: ClockOffsetSample? { + samples.min { $0.roundTripMillis < $1.roundTripMillis } + } + + /// Drop everything — called when the clocks may have moved under us + /// (either side backgrounded, or the peer reconnected). + mutating func reset() { + samples.removeAll() + } +} diff --git a/RemoteCam/FlatBufferSchemas.fbs b/RemoteCam/FlatBufferSchemas.fbs index 99e6f4f6..3dfee4b3 100644 --- a/RemoteCam/FlatBufferSchemas.fbs +++ b/RemoteCam/FlatBufferSchemas.fbs @@ -38,8 +38,12 @@ enum CommandAction : byte { RequestKeyframe = 21, // monitor -> camera: force a VP9 keyframe FocusAtPoint = 22, // monitor -> camera: focus/exposure point EndSession = 23, // either side: "I am leaving on purpose" - SetCameraPreviewMode = 24 // monitor -> camera: local preview on/standby; + SetCameraPreviewMode = 24, // monitor -> camera: local preview on/standby; // only sent to peers advertising supports_preview_mode + ClockSyncPing = 25 // director -> camera: clock-offset probe carrying the + // director's send time; the camera answers with a + // ClockSyncPing response echoing it plus its own clock. + // Only sent to peers advertising supports_multicam. } // Whether the camera device drives its own on-screen live preview. On is the @@ -158,6 +162,7 @@ table CommandParameters { focus_point_x: float; // payload for FocusAtPoint (normalized 0..1, focus_point_y: float; // upright-display space; origin top-left) camera_preview_mode: CameraPreviewModeEnum; // payload for SetCameraPreviewMode + clock_sync_t0_ms: uint64; // payload for ClockSyncPing: director clock at send } // MARK: - Command Structure @@ -277,6 +282,9 @@ table CameraStateResponse { 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 } // MARK: - Frame Data diff --git a/RemoteCam/FlatBufferSchemas_generated.swift b/RemoteCam/FlatBufferSchemas_generated.swift index f2859ac2..ef8adaa5 100644 --- a/RemoteCam/FlatBufferSchemas_generated.swift +++ b/RemoteCam/FlatBufferSchemas_generated.swift @@ -33,8 +33,9 @@ public enum RemoteShutter_CommandAction: Int8, Enum, Verifiable { case focusatpoint = 22 case endsession = 23 case setcamerapreviewmode = 24 + case clocksyncping = 25 - public static var max: RemoteShutter_CommandAction { return .setcamerapreviewmode } + public static var max: RemoteShutter_CommandAction { return .clocksyncping } public static var min: RemoteShutter_CommandAction { return .unknown } } @@ -331,6 +332,7 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { case focusPointX = 36 case focusPointY = 38 case cameraPreviewMode = 40 + case clockSyncT0Ms = 42 var v: Int32 { Int32(self.rawValue) } var p: VOffset { self.rawValue } } @@ -357,7 +359,8 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { public var focusPointX: Float32 { let o = _accessor.offset(VTOFFSET.focusPointX.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Float32.self, at: o) } public var focusPointY: Float32 { let o = _accessor.offset(VTOFFSET.focusPointY.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Float32.self, at: o) } public var cameraPreviewMode: RemoteShutter_CameraPreviewModeEnum { let o = _accessor.offset(VTOFFSET.cameraPreviewMode.v); return o == 0 ? .unknown : RemoteShutter_CameraPreviewModeEnum(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? .unknown } - public static func startCommandParameters(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 19) } + public var clockSyncT0Ms: UInt64 { let o = _accessor.offset(VTOFFSET.clockSyncT0Ms.v); return o == 0 ? 0 : _accessor.readBuffer(of: UInt64.self, at: o) } + public static func startCommandParameters(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 20) } 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) } @@ -378,6 +381,7 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { public static func add(focusPointX: Float32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: focusPointX, def: 0.0, at: VTOFFSET.focusPointX.p) } public static func add(focusPointY: Float32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: focusPointY, def: 0.0, at: VTOFFSET.focusPointY.p) } public static func add(cameraPreviewMode: RemoteShutter_CameraPreviewModeEnum, _ fbb: inout FlatBufferBuilder) { fbb.add(element: cameraPreviewMode.rawValue, def: 0, at: VTOFFSET.cameraPreviewMode.p) } + public static func add(clockSyncT0Ms: UInt64, _ fbb: inout FlatBufferBuilder) { fbb.add(element: clockSyncT0Ms, def: 0, at: VTOFFSET.clockSyncT0Ms.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, @@ -399,7 +403,8 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { deviceUniqueIdOffset deviceUniqueId: Offset = Offset(), focusPointX: Float32 = 0.0, focusPointY: Float32 = 0.0, - cameraPreviewMode: RemoteShutter_CameraPreviewModeEnum = .unknown + cameraPreviewMode: RemoteShutter_CameraPreviewModeEnum = .unknown, + clockSyncT0Ms: UInt64 = 0 ) -> Offset { let __start = RemoteShutter_CommandParameters.startCommandParameters(&fbb) RemoteShutter_CommandParameters.add(sendToRemote: sendToRemote, &fbb) @@ -421,6 +426,7 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { RemoteShutter_CommandParameters.add(focusPointX: focusPointX, &fbb) RemoteShutter_CommandParameters.add(focusPointY: focusPointY, &fbb) RemoteShutter_CommandParameters.add(cameraPreviewMode: cameraPreviewMode, &fbb) + RemoteShutter_CommandParameters.add(clockSyncT0Ms: clockSyncT0Ms, &fbb) return RemoteShutter_CommandParameters.endCommandParameters(&fbb, start: __start) } @@ -445,6 +451,7 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { try _v.visit(field: VTOFFSET.focusPointX.p, fieldName: "focusPointX", required: false, type: Float32.self) try _v.visit(field: VTOFFSET.focusPointY.p, fieldName: "focusPointY", required: false, type: Float32.self) try _v.visit(field: VTOFFSET.cameraPreviewMode.p, fieldName: "cameraPreviewMode", required: false, type: RemoteShutter_CameraPreviewModeEnum.self) + try _v.visit(field: VTOFFSET.clockSyncT0Ms.p, fieldName: "clockSyncT0Ms", required: false, type: UInt64.self) _v.finish() } } @@ -1094,6 +1101,8 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { case availableLenses = 18 case zoomRange = 20 case currentZoom = 22 + case clockSyncEchoT0Ms = 24 + case clockSyncCameraClockMs = 26 var v: Int32 { Int32(self.rawValue) } var p: VOffset { self.rawValue } } @@ -1114,7 +1123,9 @@ public struct RemoteShutter_CameraStateResponse: 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 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 static func startCameraStateResponse(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 10) } + 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 static func startCameraStateResponse(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 12) } public static func add(action: RemoteShutter_CommandAction, _ fbb: inout FlatBufferBuilder) { fbb.add(element: action.rawValue, def: 0, at: VTOFFSET.action.p) } public static func add(success: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: success, def: false, at: VTOFFSET.success.p) } @@ -1126,6 +1137,8 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { 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 endCameraStateResponse(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } public static func createCameraStateResponse( _ fbb: inout FlatBufferBuilder, @@ -1138,7 +1151,9 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { recordingStartTime: UInt64 = 0, availableLensesVectorOffset availableLenses: Offset = Offset(), zoomRangeOffset zoomRange: Offset = Offset(), - currentZoom: Double = 0.0 + currentZoom: Double = 0.0, + clockSyncEchoT0Ms: UInt64 = 0, + clockSyncCameraClockMs: UInt64 = 0 ) -> Offset { let __start = RemoteShutter_CameraStateResponse.startCameraStateResponse(&fbb) RemoteShutter_CameraStateResponse.add(action: action, &fbb) @@ -1151,6 +1166,8 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { 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) return RemoteShutter_CameraStateResponse.endCameraStateResponse(&fbb, start: __start) } @@ -1166,6 +1183,8 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { 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) _v.finish() } } diff --git a/RemoteCam/RemoteCmdFlatBuffers.swift b/RemoteCam/RemoteCmdFlatBuffers.swift index a7d06001..ed3636e0 100644 --- a/RemoteCam/RemoteCmdFlatBuffers.swift +++ b/RemoteCam/RemoteCmdFlatBuffers.swift @@ -29,6 +29,8 @@ func serializeToFlatBuffer(_ msg: Message) -> Data? { case let m as RemoteCmd.SendFrame: return m.toFlatBuffer() case let m as RemoteCmd.RequestFrame: return m.toFlatBuffer() case let m as RemoteCmd.RequestKeyframe: return m.toFlatBuffer() + case let m as RemoteCmd.ClockSyncPing: return m.toFlatBuffer() + case let m as RemoteCmd.ClockSyncPong: 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() @@ -741,6 +743,28 @@ extension RemoteCmd.RequestKeyframe { } } +extension RemoteCmd.ClockSyncPing { + func toFlatBuffer() -> Data { + var fbb = FlatBufferBuilder() + let params = RemoteShutter_CommandParameters.createCommandParameters( + &fbb, clockSyncT0Ms: t0Millis) + return buildCommand(&fbb, action: .clocksyncping, parameters: params) + } +} + +extension RemoteCmd.ClockSyncPong { + func toFlatBuffer() -> Data { + var fbb = FlatBufferBuilder() + let resp = RemoteShutter_CameraStateResponse.createCameraStateResponse( + &fbb, + action: .clocksyncping, + success: true, + clockSyncEchoT0Ms: echoT0Millis, + clockSyncCameraClockMs: cameraClockMillis) + return buildResponse(&fbb, action: .clocksyncping, response: resp) + } +} + extension RemoteCmd.ToggleFlash { func toFlatBuffer() -> Data { var fbb = FlatBufferBuilder() @@ -1101,6 +1125,9 @@ extension RemoteCmd { case .requestkeyframe: return RequestKeyframe(sender: nil) + case .clocksyncping: + return ClockSyncPing(t0Millis: params?.clockSyncT0Ms ?? 0) + case .toggleflash: return ToggleFlash() @@ -1161,6 +1188,10 @@ extension RemoteCmd { let nsError: Error? = errorStr.map { NSError(domain: "RemoteCmd", code: -1, userInfo: [NSLocalizedDescriptionKey: $0]) } switch resp.action { + case .clocksyncping: + return ClockSyncPong(echoT0Millis: resp.clockSyncEchoT0Ms, + cameraClockMillis: resp.clockSyncCameraClockMs) + case .startrecording: let startTime: Date? = resp.recordingStartTime > 0 ? Date(timeIntervalSince1970: Double(resp.recordingStartTime) / 1000.0) : nil return StartRecordingVideoAck(sender: nil, recordingStartTime: startTime, error: nsError) diff --git a/RemoteCam/RemoteCmds.swift b/RemoteCam/RemoteCmds.swift index c8773367..4ff87284 100644 --- a/RemoteCam/RemoteCmds.swift +++ b/RemoteCam/RemoteCmds.swift @@ -194,6 +194,32 @@ public class RemoteCmd: Message, @unchecked Sendable { } } + /// Director → camera: clock-offset probe. `t0Millis` is the director's + /// monotonic clock at send; the camera answers immediately with a + /// `ClockSyncPong` echoing it. Only sent to peers advertising + /// `supportsMulticam` (an old peer would decode it as Unknown and drop it). + public class ClockSyncPing: Message, @unchecked Sendable { + public let t0Millis: UInt64 + init(t0Millis: UInt64, sender: AnyObject? = nil) { + self.t0Millis = t0Millis + super.init(sender: sender) + } + } + + /// Camera → director: answer to `ClockSyncPing`. Echoes the director's + /// `t0Millis` (so the director can compute RTT against its own clock) and + /// carries the camera's monotonic clock at receipt — the pair the + /// director's `ClockOffsetEstimator` turns into an offset sample. + public class ClockSyncPong: Message, @unchecked Sendable { + public let echoT0Millis: UInt64 + public let cameraClockMillis: UInt64 + init(echoT0Millis: UInt64, cameraClockMillis: UInt64, sender: AnyObject? = nil) { + self.echoT0Millis = echoT0Millis + self.cameraClockMillis = cameraClockMillis + super.init(sender: sender) + } + } + public class OnFrame: Message, @unchecked Sendable { public let data: Data public let peerId: MCPeerID diff --git a/RemoteCam/SessionCoordinator.swift b/RemoteCam/SessionCoordinator.swift index e7bd66f3..1894b3bc 100644 --- a/RemoteCam/SessionCoordinator.swift +++ b/RemoteCam/SessionCoordinator.swift @@ -2439,9 +2439,21 @@ public actor SessionCoordinator { extension SessionCoordinator: MultipeerServiceDelegate { public nonisolated func didReceiveMessage(_ message: Message, from peer: MCPeerID) { - // `peer` is deliberately unused: this coordinator links exactly one - // peer, so every message's source is unambiguous. The multicam - // director's controller is the consumer that routes by it. + if let ping = message as? RemoteCmd.ClockSyncPing { + // Answered here, off the actor inbox: the sample's quality is the + // camera clock *at receipt*, and queuing behind state-machine work + // would smear it (same pacing argument as didReceiveFrameRequest). + // Stateless, so no isolation is needed. + _ = transportShared.value?.send( + RemoteCmd.ClockSyncPong(echoT0Millis: ping.t0Millis, + cameraClockMillis: SyncClock.nowMillis()), + to: [peer], mode: .reliable) + tell(UICmd.PeerTrafficObserved()) + return + } + // `peer` is otherwise deliberately unused: this coordinator links + // exactly one peer, so every message's source is unambiguous. The + // multicam director's controller is the consumer that routes by it. tell(UICmd.PeerTrafficObserved()) tell(message) } diff --git a/RemoteCamTests/ClockOffsetEstimatorTests.swift b/RemoteCamTests/ClockOffsetEstimatorTests.swift new file mode 100644 index 00000000..de19c9f3 --- /dev/null +++ b/RemoteCamTests/ClockOffsetEstimatorTests.swift @@ -0,0 +1,80 @@ +// +// ClockOffsetEstimatorTests.swift +// RemoteShutterTests +// +// Created by Dario Lencina on 2026. +// Copyright © 2026 Security Union. All rights reserved. +// + +import XCTest +@testable import RemoteShutter + +final class ClockOffsetEstimatorTests: XCTestCase { + + func testSymmetricExchangeRecoversExactOffset() { + // Camera runs 500ms ahead. Ping takes 10ms each way: sent at t0=1000, + // received at director-time 1010 = camera clock 1510, pong back at + // t3=1020. Symmetric latency → the rtt/2 midpoint is exact. + var estimator = ClockOffsetEstimator() + let sample = estimator.recordExchange( + t0Millis: 1000, cameraClockMillis: 1510, t3Millis: 1020) + XCTAssertEqual(sample?.offsetMillis, 500) + XCTAssertEqual(sample?.roundTripMillis, 20) + } + + func testAsymmetricLatencyErrorIsBoundedByHalfRoundTrip() { + // Worst case: all 20ms on the outbound leg. Receive happens at + // director-time 1020 (camera 1520) but the midpoint guess is 1010 — + // estimate 510, true offset 500. |error| = rtt/2. + var estimator = ClockOffsetEstimator() + let sample = estimator.recordExchange( + t0Millis: 1000, cameraClockMillis: 1520, t3Millis: 1020) + XCTAssertEqual(sample?.offsetMillis, 510) + } + + func testNegativeOffsetWhenCameraRunsBehind() { + var estimator = ClockOffsetEstimator() + let sample = estimator.recordExchange( + t0Millis: 10_000, cameraClockMillis: 8_010, t3Millis: 10_020) + XCTAssertEqual(sample?.offsetMillis, -2_000) + } + + func testBestPrefersMinimumRoundTrip() { + var estimator = ClockOffsetEstimator() + // A slow, queued exchange with a skewed midpoint... + estimator.recordExchange(t0Millis: 1000, cameraClockMillis: 1900, t3Millis: 1400) + // ...must not outvote a clean 8ms exchange. + estimator.recordExchange(t0Millis: 2000, cameraClockMillis: 2504, t3Millis: 2008) + XCTAssertEqual(estimator.best?.roundTripMillis, 8) + XCTAssertEqual(estimator.best?.offsetMillis, 500) + } + + func testWindowSlidesAndDropsOldest() { + var estimator = ClockOffsetEstimator() + // Fill the window with a fast-but-old sample first. + estimator.recordExchange(t0Millis: 0, cameraClockMillis: 501, t3Millis: 2) + for i in 0.. Date: Mon, 10 Aug 2026 23:20:31 -0700 Subject: [PATCH 04/17] Multicam PR3: MulticamController + scanner multi-select + focus/strip UI 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 --- RemoteCam/CameraLink.swift | 63 +++ RemoteCam/DeviceScannerView.swift | 27 + RemoteCam/DeviceScannerViewController.swift | 38 +- RemoteCam/DeviceScannerViewModel.swift | 3 + RemoteCam/MulticamController.swift | 495 +++++++++++++++++++ RemoteCam/MulticamView.swift | 139 ++++++ RemoteCam/MulticamViewController.swift | 118 +++++ RemoteCam/MulticamViewModel.swift | 83 ++++ RemoteCam/ScannerLobby.swift | 9 + RemoteCam/SessionCoordinator.swift | 71 +++ RemoteCam/UICmds.swift | 13 + RemoteCam/en.lproj/Localizable.strings | 3 + RemoteCamTests/MulticamControllerTests.swift | 190 +++++++ RemoteCamTests/MulticamViewModelTests.swift | 62 +++ RemoteShutter.xcodeproj/project.pbxproj | 28 ++ 15 files changed, 1341 insertions(+), 1 deletion(-) create mode 100644 RemoteCam/CameraLink.swift create mode 100644 RemoteCam/MulticamController.swift create mode 100644 RemoteCam/MulticamView.swift create mode 100644 RemoteCam/MulticamViewController.swift create mode 100644 RemoteCam/MulticamViewModel.swift create mode 100644 RemoteCamTests/MulticamControllerTests.swift create mode 100644 RemoteCamTests/MulticamViewModelTests.swift diff --git a/RemoteCam/CameraLink.swift b/RemoteCam/CameraLink.swift new file mode 100644 index 00000000..2d3128b7 --- /dev/null +++ b/RemoteCam/CameraLink.swift @@ -0,0 +1,63 @@ +// +// CameraLink.swift +// RemoteShutter +// +// Copyright © 2026 Security Union LLC. All rights reserved. +// + +import Foundation +import MPCCompat +import Stormo + +/// One camera in a multicam director session. The director holds one of these +/// per connected camera, keyed by `MCPeerID`; it is the multicam analog of the +/// single link + single `FrameStreamReceiver` that `SessionCoordinator` holds +/// for a 1:1 monitor. +/// +/// A reference type, not a struct: it owns a `FrameStreamReceiver` (a class +/// with a running decode timer) and the `MulticamController` actor mutates it +/// in place as frames, capabilities and clock samples arrive — a value type +/// would force a dictionary read-modify-write on every frame. +final class CameraLink { + + enum Status: Equatable { + /// The session is up and frames are expected. + case linked + /// The link dropped; the director is re-browsing to invite it back. + /// The tile stays on screen (last frame frozen) rather than vanishing. + case reconnecting + /// The peer is gone for good (removed by the user, or version-refused). + case failed + } + + let peerID: MCPeerID + let displayName: String + var status: Status = .linked + + /// The most recent capabilities the camera advertised, or nil until the + /// first exchange completes. `supportsMulticam` gates the multicam-only + /// wire messages (clock sync now; scheduled capture in later PRs). + var capabilities: RemoteCmd.CameraCapabilitiesResp? + var supportsMulticam: Bool { capabilities?.supportsMulticam ?? false } + + /// Rolling clock-offset estimate for this camera, fed by ClockSyncPong. + /// Stored here so a future synced capture can schedule on the camera's own + /// clock (PR4); PR3 only measures and surfaces it. + var clockEstimator = ClockOffsetEstimator() + var latestOffset: ClockOffsetSample? { clockEstimator.best } + + /// Monitor side: this lane has produced at least one VP9 frame, proving the + /// camera speaks VP9 — the gate for sending it `RequestKeyframe` (mirrors + /// `SessionCoordinator.monitorReceivedVP9Frame`, but per camera). + var sawVP9 = false + + /// This camera's own preview decoder + stall watchdog. Frames tagged with + /// this peer's id are fed here; its `onImage` drives exactly this lane's + /// tile, so a frame from another camera never touches it. + let receiver = FrameStreamReceiver() + + init(peerID: MCPeerID) { + self.peerID = peerID + self.displayName = peerID.displayName + } +} diff --git a/RemoteCam/DeviceScannerView.swift b/RemoteCam/DeviceScannerView.swift index c7c86c7a..570a20cb 100644 --- a/RemoteCam/DeviceScannerView.swift +++ b/RemoteCam/DeviceScannerView.swift @@ -14,6 +14,9 @@ struct DeviceScannerView: View { let onShareApp: () -> Void let onOpenSettings: () -> Void let onHelp: () -> Void + /// Multicam only: begin a director session with the cameras collected so + /// far. Nil in the single-camera build (flag off), where it never shows. + var onStartMulticam: (() -> Void)? = nil /// Peer-link state; the reconnect overlay is a function of it. @ObservedObject var peerLink: PeerLinkStatus = .shared @@ -34,10 +37,34 @@ struct DeviceScannerView: View { connectingOverlay } + if viewModel.multicamCollectedCount >= 1, let onStartMulticam { + startMulticamButton(onStartMulticam) + } + PeerLinkOverlay(status: peerLink) } } + /// Floating "Start (N)" for the multicam collecting flow. One camera starts + /// the classic monitor; two or more starts the director grid. + private func startMulticamButton(_ action: @escaping () -> Void) -> some View { + VStack { + Spacer() + Button(action: action) { + Text(String(format: NSLocalizedString("Start (%d)", comment: "start multicam with N cameras"), + viewModel.multicamCollectedCount)) + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(AppTheme.accent) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .padding(.horizontal, 24) + .padding(.bottom, 24) + } + } + // MARK: - Peer List private var peerList: some View { diff --git a/RemoteCam/DeviceScannerViewController.swift b/RemoteCam/DeviceScannerViewController.swift index 720e08bc..f5f6d36e 100644 --- a/RemoteCam/DeviceScannerViewController.swift +++ b/RemoteCam/DeviceScannerViewController.swift @@ -109,6 +109,11 @@ public class DeviceScannerViewController: UIViewController { remoteCamSession.setFrameSender(frameSender) self.remoteCamSession ! SetScannerLobby(lobby: self) scannerViewModel.role = role + // Multicam director collecting: only the monitor role, only behind the + // flag. Off, the coordinator's scanning path is byte-identical. + if FeatureFlags.ENABLE_MULTICAM && role == .monitor { + remoteCamSession ! UICmd.SetMulticamCollecting(on: true) + } // The reconnect overlay's only action, routed like every other UI // command; the overlay itself is pure state (PeerLinkStatus). PeerLinkStatus.shared.onCancel = { [weak self] in @@ -188,7 +193,10 @@ public class DeviceScannerViewController: UIViewController { }, onHelp: { [weak self] in self?.showHelpModal() - } + }, + onStartMulticam: (FeatureFlags.ENABLE_MULTICAM && role == .monitor) + ? { [weak self] in self?.startMulticamSession() } + : nil ) swiftUIHostingController = embedSwiftUIView(scannerView) @@ -331,6 +339,29 @@ public class DeviceScannerViewController: UIViewController { } } + /// Multicam "Start (N)": one camera runs the classic 1:1 monitor + /// (unchanged); two or more hands the live transport to a + /// `MulticamController` and pushes the director screen. + private func startMulticamSession() { + Task { @MainActor in + // Two or more cameras: hand the live transport to a director. + if let handoff = await remoteCamSession.detachTransportForMulticam() { + let controller = MulticamController() + await controller.install(transport: handoff.transport, + initialPeers: handoff.peers, + mode: .photo) + let directorVC = MulticamViewController(controller: controller) + navigationController?.pushViewController(directorVC, animated: true) + return + } + // Exactly one camera: promote it to a normal session and run the + // classic 1:1 monitor, unchanged. + if await remoteCamSession.promoteSingleCollectedToConnected() { + goToRole() + } + } + } + func goToAppSettings() { #if targetEnvironment(macCatalyst) // Local-network permission lives in System Settings on the Mac. @@ -377,6 +408,11 @@ extension DeviceScannerViewController: ScannerLobby { navigationController?.popToViewController(self, animated: true) } + /// Multicam collecting: surface the running count so "Start (N)" appears. + func didCollectMulticamCameras(_ peers: [MCPeerID]) { + scannerViewModel.multicamCollectedCount = peers.count + } + func presentScanningError() { let alert = UIAlertController( title: NSLocalizedString("Scanning Error", comment: ""), diff --git a/RemoteCam/DeviceScannerViewModel.swift b/RemoteCam/DeviceScannerViewModel.swift index 687bfa0c..29e494a4 100644 --- a/RemoteCam/DeviceScannerViewModel.swift +++ b/RemoteCam/DeviceScannerViewModel.swift @@ -53,6 +53,9 @@ final class DeviceScannerViewModel: ObservableObject { @Published var hasScanningError: Bool = false @Published var isConnecting: Bool = false @Published var hasConnectionError: Bool = false + /// Multicam director collecting: how many cameras are connected so far. + /// Drives the "Start (N)" affordance; stays 0 in the single-camera build. + @Published var multicamCollectedCount: Int = 0 /// When the current scan began. Not @Published: the view samples it on a /// TimelineView clock, so publishing would only cause redundant redraws. diff --git a/RemoteCam/MulticamController.swift b/RemoteCam/MulticamController.swift new file mode 100644 index 00000000..0b9d82d1 --- /dev/null +++ b/RemoteCam/MulticamController.swift @@ -0,0 +1,495 @@ +// +// MulticamController.swift +// RemoteShutter +// +// Copyright © 2026 Security Union LLC. All rights reserved. +// + +// swiftlint:disable cyclomatic_complexity function_body_length + +import Foundation +import MPCCompat +import Stormo +import UIKit + +/// The director's aggregate posture across all cameras. One shutter drives +/// them all, so the capture states are aggregate, not per-camera. PR3 only +/// needs `.monitoring`; the capture cases arrive with synced photo/video. +enum MulticamState: Equatable { + case monitoring(mode: MonitorMode) +} + +/// A UI-facing snapshot of one lane. Deliberately a value type carrying only +/// what the chrome/tile needs, so the actor never hands the UI a live +/// reference into its own state. +struct MulticamLaneInfo: Equatable { + let peerID: MCPeerID + let displayName: String + let status: CameraLink.Status + let isFocused: Bool + /// Clock-offset estimate in ms (nil until the first pong), for a future + /// sync-quality indicator; unused by PR3's UI beyond diagnostics. + let clockOffsetMillis: Int64? +} + +/// The main-actor bridge from the controller to the multicam screen — the +/// multicam analog of `MonitorDisplay`. Low-frequency lane changes go through +/// `applyLanes`; the ~20fps preview stream goes through `receiveFrame`, which +/// the view controller routes to exactly one lane's decoder so a frame from +/// camera B never re-renders camera A. +protocol MulticamDisplay: AnyObject { + func applyLanes(_ lanes: [MulticamLaneInfo]) + func receiveFrame(_ frame: RemoteCmd.OnFrame) + func exitMulticam() +} + +/// Director side of a multicam session: one controller, several cameras. +/// +/// A sibling of `SessionCoordinator`, not a replacement — `SessionCoordinator` +/// stays the sole brain for the camera role and for 1:1 monitoring, both +/// byte-identical to before. This actor is reached only when the director +/// starts a multicam session (≥2 cameras, behind `ENABLE_MULTICAM`). The +/// camera side is unchanged: a camera cannot tell a multicam director from a +/// single monitor. +/// +/// Mirrors `SessionCoordinator`'s concurrency shape: a FIFO inbox fed by +/// `tell(_:)`, nonisolated transport-delegate callbacks that enqueue, and a +/// lock-boxed transport mirror for the sends that must not queue behind +/// state-machine work (frame acks, clock-sync answers). +public actor MulticamController { + + // MARK: Inbox (mirrors SessionCoordinator) + + private nonisolated let inboxContinuation: Locked.Continuation?> = Locked(nil) + private nonisolated let pendingCount = Locked(0) + + public init() { + var continuation: AsyncStream.Continuation! + let stream = AsyncStream(bufferingPolicy: .unbounded) { continuation = $0 } + self.inboxContinuation.value = continuation + let pending = pendingCount + Task { [weak self] in + for await msg in stream { + guard let self else { break } + await self.handle(msg) + pending.mutate { $0 -= 1 } + } + } + } + + public nonisolated func tell(_ msg: Message) { + pendingCount.mutate { $0 += 1 } + inboxContinuation.value?.yield(msg) + } + + /// Test support: suspend until every enqueued message is processed. + public nonisolated func waitForIdle() async { + while pendingCount.value > 0 { + await Task.yield() + } + } + + public nonisolated func stop() { + clockSyncTask.value?.cancel() + transportShared.value?.stopSession() + inboxContinuation.value?.finish() + } + + // MARK: Transport + + private var multipeerService: (any MultipeerServiceProtocol)? + private let transportShared = Locked<(any MultipeerServiceProtocol)?>(nil) + private nonisolated let clockSyncTask = Locked?>(nil) + + // MARK: State + + private var state: MulticamState = .monitoring(mode: .photo) + /// Insertion-ordered peer ids, so the strip/grid order is stable as lanes + /// come and go. + private var order: [MCPeerID] = [] + private var links: [MCPeerID: CameraLink] = [:] + private var focusedPeer: MCPeerID? + + private weak var display: MulticamDisplay? + + /// The interval between clock-offset refreshes per camera. + private let clockSyncInterval: TimeInterval = 30 + /// How far ahead a re-invite waits before re-browsing a dropped camera. + private var reconnectRetryDelay: TimeInterval = 3 + private let reconnectInviteTimeout: TimeInterval = 10 + + // MARK: Test / wiring seams + + func setDisplay(_ display: MulticamDisplay) { + self.display = display + // The display is wired after `install` (the screen is pushed only once + // the handoff is done), so replay the current lanes now — otherwise the + // first snapshot, emitted during install, reaches no one. + publishLanes() + } + func setReconnectRetryDelay(_ delay: TimeInterval) { reconnectRetryDelay = delay } + + /// Test support. + func lanesForTesting() -> [MulticamLaneInfo] { laneSnapshot() } + func focusedPeerForTesting() -> MCPeerID? { focusedPeer } + func statusForTesting(_ peer: MCPeerID) -> CameraLink.Status? { links[peer]?.status } + func offsetForTesting(_ peer: MCPeerID) -> Int64? { links[peer]?.latestOffset?.offsetMillis } + + // MARK: - Handoff + + /// Take over a transport the scanner already connected to `initialPeers`. + /// Becomes the transport delegate (the scanner's `SessionCoordinator` + /// stops receiving callbacks from here on), seeds a lane per peer, kicks + /// the capability handshake + clock sync, and keeps browsing so more + /// cameras can be invited later. + func install(transport: any MultipeerServiceProtocol, + initialPeers: [MCPeerID], + mode: MonitorMode) { + multipeerService = transport + transportShared.value = transport + transport.delegate = self + state = .monitoring(mode: mode) + + for peer in initialPeers where links[peer] == nil { + order.append(peer) + links[peer] = CameraLink(peerID: peer) + } + focusedPeer = focusedPeer ?? order.first + + // Keep discovering so the in-session "add camera" flow (PR7) has a + // live peer list; a director that stopped browsing on connect could + // never grow the rig. + transport.startBrowsingOnly() + + for peer in order { beginHandshake(with: peer) } + startClockSyncLoop() + publishLanes() + } + + /// The initial per-camera handshake: announce the director role (carries + /// our version so the camera can gate us) and ask for capabilities. The + /// camera answers with `CameraCapabilitiesResp`, at which point the lane + /// goes live and its frame pump starts. + private func beginHandshake(with peer: MCPeerID) { + sendTo(peer, RemoteCmd.PeerBecameMonitor.createWithDefaults()) + sendTo(peer, RemoteCmd.RequestCameraCapabilities()) + // Prime the stream: the camera streams once it holds a frame credit. + sendTo(peer, RemoteCmd.RequestFrame(sender: nil)) + } + + // MARK: - Message handling + + func handle(_ msg: Message) async { + switch msg { + case let connected as OnConnectToDevice: + handlePeerConnected(connected.peer) + + case let disconnected as DisconnectPeer: + if let peer = disconnected.peer { handlePeerDisconnected(peer) } + + case let found as UICmd.BrowserFoundPeer: + handleBrowserFound(found.peer) + + case let routed as RoutedMessage: + await handleRouted(routed.message, from: routed.peer) + + case let frame as RemoteCmd.OnFrame: + handleFrame(frame) + + case let measured as ClockPongMeasured: + storePong(measured.pong, t3: measured.t3, from: measured.peer) + + case is UICmd.AppForegrounded: + // Clocks freeze while backgrounded; the estimates are stale. Drop + // them and re-measure, mirroring the frame path's foreground rearm. + for link in links.values { link.clockEstimator.reset() } + pingClocks() + + default: + break + } + } + + /// A camera-addressed message that arrived with its source peer (Seam A). + private func handleRouted(_ message: Message, from peer: MCPeerID) async { + guard let link = links[peer] else { return } + + switch message { + case let became as RemoteCmd.PeerBecameCamera: + // Same-major-or-refuse, per camera. A refused camera is dropped + // from the rig rather than silently streaming a peer we can't + // fully drive. + if !isPeerCompatible(became) { + link.status = .failed + publishLanes() + } + + case let caps as RemoteCmd.CameraCapabilitiesResp: + link.capabilities = caps + if link.status != .failed { link.status = .linked } + publishLanes() + // A multicam-capable camera gets an immediate clock probe so its + // offset is ready well before the first synced capture (PR4). + if link.supportsMulticam { + sendTo(peer, RemoteCmd.ClockSyncPing(t0Millis: SyncClock.nowMillis())) + } + + default: + // Per-camera command responses (zoom/lens/flash/torch acks) update + // only the focused lane's controls, wired to the UI in a later PR; + // PR3 surfaces frames + status, so these are accepted and ignored. + break + } + } + + private func handlePeerConnected(_ peer: MCPeerID) { + if let existing = links[peer] { + // A reconnecting lane came back — rehandshake and relight it. + existing.status = .linked + } else { + order.append(peer) + links[peer] = CameraLink(peerID: peer) + } + focusedPeer = focusedPeer ?? peer + beginHandshake(with: peer) + publishLanes() + } + + private func handlePeerDisconnected(_ peer: MCPeerID) { + guard let link = links[peer] else { return } + // Degrade the tile, keep the rest of the rig recording/monitoring. The + // controller stays browsing, so `browserDidFindPeer` re-invites. + link.status = .reconnecting + publishLanes() + armReconnect(peer) + } + + private func handleBrowserFound(_ peer: MCPeerID) { + // Re-invite only a camera we are actively missing; a fresh peer is a + // job for the add-camera flow (PR7), not an auto-join. + guard let link = links[peer], link.status == .reconnecting else { return } + multipeerService?.invitePeer(peer, timeout: reconnectInviteTimeout) + } + + private func armReconnect(_ peer: MCPeerID) { + let delay = reconnectRetryDelay + Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + guard let self else { return } + await self.reBrowseIfStillMissing(peer) + } + } + + private func reBrowseIfStillMissing(_ peer: MCPeerID) { + guard links[peer]?.status == .reconnecting else { return } + // Rebuild the browse so the lost peer is re-reported at a live address. + multipeerService?.startBrowsingOnly() + } + + private func handleFrame(_ frame: RemoteCmd.OnFrame) { + guard let link = links[frame.peerId] else { return } + if frame.codec == .vp9 { link.sawVP9 = true } + // Route to exactly this lane's decoder (rendering isolation), then ack + // only this camera so its credit window advances and no other camera + // sends on a frame it didn't produce (Seam B). + display?.receiveFrame(frame) + sendTo(frame.peerId, RemoteCmd.RequestFrame(sender: nil)) + } + + // MARK: - Per-camera controls (focused peer only) + + /// PR3 wires these to the focused lane; capture (all-camera) lands in PR4. + func setZoom(_ factor: CGFloat) { focusedSend(RemoteCmd.SetZoom(zoomFactor: factor)) } + func toggleTorch() { focusedSend(RemoteCmd.ToggleTorch()) } + + func focusAtPoint(x: Float, y: Float) { + guard let peer = focusedPeer, links[peer]?.capabilities?.supportsFocusPoint == true else { return } + sendTo(peer, RemoteCmd.FocusAtPoint(x: x, y: y)) + } + + func switchLens(_ lens: CameraLensType) { focusedSend(RemoteCmd.SwitchLens(lensType: lens)) } + + func setFocusedPeer(_ peer: MCPeerID) { + guard links[peer] != nil else { return } + focusedPeer = peer + publishLanes() + } + + /// Logically remove a camera from the rig. The QUIC session has no + /// per-peer teardown, so this stops the lane (no more acks/handshakes) and + /// drops it from the UI; the peer times out on its side. A true per-peer + /// disconnect needs a transport API and is out of scope here. + func removeCamera(_ peer: MCPeerID) { + links[peer] = nil + order.removeAll { $0 == peer } + if focusedPeer == peer { focusedPeer = order.first } + publishLanes() + } + + private func focusedSend(_ msg: Message) { + guard let peer = focusedPeer else { return } + sendTo(peer, msg) + } + + /// A lane's stall watchdog fired — re-request a frame to unstick just that + /// camera's pump (the others are unaffected). + func nudgeFrame(for peer: MCPeerID) { + guard links[peer] != nil else { return } + sendTo(peer, RemoteCmd.RequestFrame(sender: nil)) + } + + /// A lane's decoder desynced — force a keyframe, but only from a camera + /// that has proven it speaks VP9 (else an old peer reads the unknown + /// action as TakePicture). Mirrors the 1:1 `requestKeyframeIfVP9` gate. + func requestKeyframe(for peer: MCPeerID) { + guard links[peer]?.sawVP9 == true else { return } + sendTo(peer, RemoteCmd.RequestKeyframe(sender: nil), mode: .reliable) + } + + // MARK: - Clock sync + + private func startClockSyncLoop() { + clockSyncTask.value?.cancel() + let interval = clockSyncInterval + clockSyncTask.value = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + guard let self, !Task.isCancelled else { return } + await self.pingClocks() + } + } + } + + private func pingClocks() { + for peer in order where links[peer]?.supportsMulticam == true { + sendTo(peer, RemoteCmd.ClockSyncPing(t0Millis: SyncClock.nowMillis())) + } + } + + // MARK: - Sending + + @discardableResult + private func sendTo(_ peer: MCPeerID, _ msg: Message, + mode: MCSessionSendDataMode = .reliable) -> Bool { + transportShared.value?.send(msg, to: [peer], mode: mode) ?? false + } + + // MARK: - Snapshots + + private func laneSnapshot() -> [MulticamLaneInfo] { + order.compactMap { peer in + guard let link = links[peer] else { return nil } + return MulticamLaneInfo( + peerID: peer, + displayName: link.displayName, + status: link.status, + isFocused: peer == focusedPeer, + clockOffsetMillis: link.latestOffset?.offsetMillis) + } + } + + private func publishLanes() { + let snapshot = laneSnapshot() + let display = display + OperationQueue.main.addOperation { + display?.applyLanes(snapshot) + } + } + + private func isPeerCompatible(_ became: RemoteCmd.RoleAnnouncement) -> Bool { + guard let local = PeerAppCompatibility.localVersion else { return true } + return PeerAppCompatibility.decide(local: local, + remoteShortVersion: became.shortVersion) == .compatible + } +} + +/// Wraps a camera-addressed inbound message with its source peer, so the +/// nonisolated delegate can enqueue routing work onto the FIFO inbox without +/// losing the `from` that Seam A preserved. +final class RoutedMessage: Message, @unchecked Sendable { + let message: Message + let peer: MCPeerID + init(message: Message, peer: MCPeerID) { + self.message = message + self.peer = peer + super.init(sender: nil) + } +} + +/// A clock-sync pong with its arrival time already stamped at receipt, so the +/// RTT is measured before inbox queuing but the record is still ordered. +final class ClockPongMeasured: Message, @unchecked Sendable { + let pong: RemoteCmd.ClockSyncPong + let t3: UInt64 + let peer: MCPeerID + init(pong: RemoteCmd.ClockSyncPong, t3: UInt64, peer: MCPeerID) { + self.pong = pong + self.t3 = t3 + self.peer = peer + super.init(sender: nil) + } +} + +// MARK: - MultipeerServiceDelegate + +extension MulticamController: MultipeerServiceDelegate { + + public nonisolated func didReceiveMessage(_ message: Message, from peer: MCPeerID) { + // Stamp the pong's arrival time (`t3`) here, at receipt, so the RTT is + // measured before any queuing — then route it through the inbox like + // everything else, so ordering and `waitForIdle` hold. Everything else + // carries its source through `RoutedMessage`. + if let pong = message as? RemoteCmd.ClockSyncPong { + tell(ClockPongMeasured(pong: pong, t3: SyncClock.nowMillis(), peer: peer)) + return + } + tell(RoutedMessage(message: message, peer: peer)) + } + + private func storePong(_ pong: RemoteCmd.ClockSyncPong, t3: UInt64, from peer: MCPeerID) { + guard let link = links[peer] else { return } + link.clockEstimator.recordExchange( + t0Millis: pong.echoT0Millis, + cameraClockMillis: pong.cameraClockMillis, + t3Millis: t3) + publishLanes() + } + + public nonisolated func didReceiveFrameRequest(_ request: RemoteCmd.RequestFrame) { + // The director never sends frames, so a frame-credit ack is a no-op. + } + + public nonisolated func didReceiveFrame(_ frame: RemoteCmd.SendFrame, from peer: MCPeerID) { + tell(RemoteCmd.OnFrame(data: frame.data, + sender: nil, + peerId: peer, + fps: frame.fps, + camPosition: frame.camPosition, + camOrientation: frame.camOrientation, + codec: frame.codec, + sequenceNumber: frame.sequenceNumber)) + } + + public nonisolated func peerDidConnect(_ peer: MCPeerID) { + tell(OnConnectToDevice(peer: peer, sender: nil)) + } + + public nonisolated func peerDidDisconnect(_ peer: MCPeerID) { + tell(DisconnectPeer(peer: peer, sender: nil)) + } + + public nonisolated func didDetectIncompatibility() {} + + public nonisolated func browserDidFindPeer(_ peer: MCPeerID) { + tell(UICmd.BrowserFoundPeer(peer: peer)) + } + + public nonisolated func browserDidLosePeer(_ peer: MCPeerID) { + tell(UICmd.BrowserLostPeer(peer: peer)) + } + + public nonisolated func browserDidFail(_ error: Error) {} + public nonisolated func advertiserDidFail(_ error: Error) {} + public nonisolated func didStartReceivingResource(name: String, progress: Progress) {} + public nonisolated func didFinishReceivingResource(name: String, at localURL: URL?, error: Error?) {} +} diff --git a/RemoteCam/MulticamView.swift b/RemoteCam/MulticamView.swift new file mode 100644 index 00000000..ca3b8af6 --- /dev/null +++ b/RemoteCam/MulticamView.swift @@ -0,0 +1,139 @@ +// +// MulticamView.swift +// RemoteShutter +// +// Copyright © 2026 Security Union LLC. All rights reserved. +// + +import SwiftUI + +/// The director screen in focus mode: the selected camera fills the viewfinder +/// (reusing the 1:1 monitor's `LiveFrameView` so it looks and behaves the same) +/// with a floating strip of the other cameras' live thumbnails. Grid mode is a +/// later PR; this is the default surface. +struct MulticamView: View { + @ObservedObject var viewModel: MulticamViewModel + + /// Tap a thumbnail to make that camera the focused one. + let onFocusLane: (CameraLane) -> Void + + var body: some View { + GeometryReader { geo in + let dock = MonitorChromeLayout.dock( + viewSize: geo.size, + interfaceOrientation: viewModel.interfaceOrientation, + input: chromeInput) + + ZStack { + Color.black.ignoresSafeArea() + + focusedViewfinder + + stripOverlay(dock: dock) + } + } + } + + private var chromeInput: MonitorChromeInput { + #if targetEnvironment(macCatalyst) + return .pointer + #else + return .touch + #endif + } + + @ViewBuilder + private var focusedViewfinder: some View { + if let focused = viewModel.focusedLane { + LiveFrameView(frames: focused.frames, aspectRatio: .sixteenNine) + .ignoresSafeArea() + } else { + // No camera focused yet (all reconnecting, or none linked). + Rectangle() + .fill(Color.gray.opacity(0.25)) + .overlay( + Image(systemName: "video.slash") + .font(.system(size: 44)) + .foregroundColor(.white.opacity(0.5))) + .ignoresSafeArea() + } + } + + /// The thumbnail rail, docked opposite the (future) action cluster on the + /// same axis the 1:1 chrome uses, so the two screens feel of a piece. + @ViewBuilder + private func stripOverlay(dock: MonitorChromeDock) -> some View { + let others = viewModel.otherLanes + if !others.isEmpty { + switch dock { + case .bottom: + VStack { + Spacer() + HStack(spacing: 8) { + ForEach(others) { lane in + CameraTileView(lane: lane, isThumbnail: true) + .frame(width: 96, height: 128) + .onTapGesture { onFocusLane(lane) } + } + } + .padding(.bottom, 96) + } + case .leading, .trailing: + HStack { + if dock == .trailing { Spacer() } + VStack(spacing: 8) { + ForEach(others) { lane in + CameraTileView(lane: lane, isThumbnail: true) + .frame(width: 128, height: 96) + .onTapGesture { onFocusLane(lane) } + } + } + .padding(dock == .leading ? .leading : .trailing, 12) + if dock == .leading { Spacer() } + } + } + } + } +} + +/// One camera's tile: its isolated live frame, a name chip, a focus ring, and +/// a reconnecting scrim. `Equatable` on the value inputs so a frame delivered +/// to another lane can't invalidate this tile's chrome — only its own +/// `LiveFrameView` (observing its own `FrameDisplayModel`) re-renders. +struct CameraTileView: View { + @ObservedObject var lane: CameraLane + var isThumbnail: Bool = false + + var body: some View { + ZStack { + LiveFrameView(frames: lane.frames, aspectRatio: .sixteenNine) + .clipShape(RoundedRectangle(cornerRadius: isThumbnail ? 10 : 0)) + .saturation(lane.status == .linked ? 1 : 0) + + if lane.status == .reconnecting { + RoundedRectangle(cornerRadius: isThumbnail ? 10 : 0) + .fill(Color.black.opacity(0.45)) + .overlay( + Text(NSLocalizedString("RECONNECTING", comment: "peer link dropped")) + .font(.caption2.weight(.semibold)) + .foregroundColor(.white)) + } + + VStack { + Spacer() + Text(lane.displayName) + .font(.caption2) + .lineLimit(1) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.black.opacity(0.5)) + .clipShape(Capsule()) + .padding(4) + } + } + .overlay( + RoundedRectangle(cornerRadius: isThumbnail ? 10 : 0) + .stroke(lane.isFocused ? AppTheme.accent : .clear, lineWidth: 3)) + } +} diff --git a/RemoteCam/MulticamViewController.swift b/RemoteCam/MulticamViewController.swift new file mode 100644 index 00000000..0dd11c1b --- /dev/null +++ b/RemoteCam/MulticamViewController.swift @@ -0,0 +1,118 @@ +// +// MulticamViewController.swift +// RemoteShutter +// +// Copyright © 2026 Security Union LLC. All rights reserved. +// + +import MPCCompat +import SwiftUI +import UIKit + +/// Hosts the multicam director screen. Owns the `MulticamController` actor and +/// the `MulticamViewModel`, and bridges the two: controller snapshots become +/// lane updates, and per-lane preview frames are routed to exactly one lane's +/// decoder (the rendering-isolation contract). +/// +/// The 1:1 `MonitorViewController` is untouched; this is a parallel screen +/// reached only from a multicam session. +public final class MulticamViewController: UIViewController { + + private let controller: MulticamController + private let viewModel = MulticamViewModel() + private var hosting: UIHostingController? + + /// `controller` must already be `install`-ed with its transport + peers by + /// the caller (the scanner handoff), so lanes light up immediately. + init(controller: MulticamController) { + self.controller = controller + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { .allButUpsideDown } + public override var shouldAutorotate: Bool { true } + + public override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + + let multicamView = MulticamView( + viewModel: viewModel, + onFocusLane: { [weak self] lane in + guard let self else { return } + Task { await self.controller.setFocusedPeer(lane.peerID) } + }) + hosting = embedSwiftUIView(multicamView) + + Task { await controller.setDisplay(self) } + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(true, animated: animated) + syncInterfaceOrientation() + } + + public override func viewWillTransition(to size: CGSize, + with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + coordinator.animate(alongsideTransition: { _ in self.syncInterfaceOrientation() }) + } + + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + navigationController?.setNavigationBarHidden(false, animated: animated) + } + + private func syncInterfaceOrientation() { + let orientation = view.window?.windowScene?.interfaceOrientation ?? .portrait + if viewModel.interfaceOrientation != orientation { + viewModel.interfaceOrientation = orientation + } + } + + deinit { + for lane in viewModel.lanes { lane.receiver.invalidate() } + controller.stop() + } + + /// Wire a freshly created lane's decoder: its frames drive only its own + /// `FrameDisplayModel`, and its stall/keyframe recovery targets only its + /// own peer on the controller. + private func wire(_ lane: CameraLane) { + let peer = lane.peerID + lane.receiver.onImage = { [weak lane] image in + OperationQueue.main.addOperation { lane?.frames.cameraImage = image } + } + lane.receiver.onStall = { [weak self] in + Task { await self?.controller.nudgeFrame(for: peer) } + } + lane.receiver.onKeyframeNeeded = { [weak self] in + Task { await self?.controller.requestKeyframe(for: peer) } + } + lane.receiver.start() + } +} + +// MARK: - MulticamDisplay + +extension MulticamViewController: MulticamDisplay { + + func applyLanes(_ lanes: [MulticamLaneInfo]) { + let created = viewModel.apply(lanes) + for lane in created { wire(lane) } + } + + func receiveFrame(_ frame: RemoteCmd.OnFrame) { + // Route to exactly the source lane's decoder; a frame for camera B + // never touches camera A's tile. + viewModel.lane(for: frame.peerId)?.receiver.receive(frame) + } + + func exitMulticam() { + navigationController?.popViewController(animated: true) + } +} diff --git a/RemoteCam/MulticamViewModel.swift b/RemoteCam/MulticamViewModel.swift new file mode 100644 index 00000000..799056d6 --- /dev/null +++ b/RemoteCam/MulticamViewModel.swift @@ -0,0 +1,83 @@ +// +// MulticamViewModel.swift +// RemoteShutter +// +// Copyright © 2026 Security Union LLC. All rights reserved. +// + +import Combine +import Foundation +import MPCCompat +import SwiftUI + +/// One camera's UI state in the director screen. Its `frames` model is +/// isolated exactly like the 1:1 monitor's `FrameDisplayModel`: only this +/// lane's tile observes it, so a 20fps stream from camera B never re-renders +/// camera A's tile or the surrounding chrome. +final class CameraLane: ObservableObject, Identifiable { + let peerID: MCPeerID + var id: MCPeerID { peerID } + let displayName: String + + /// Live preview frames for this lane only (not `@Published` on the parent + /// view model — see `FrameDisplayModel`). + let frames = FrameDisplayModel() + + @Published var status: CameraLink.Status + @Published var isFocused: Bool + + /// This lane's own decoder + stall watchdog. The view controller wires its + /// `onImage` to set `frames.cameraImage`, and its stall/keyframe callbacks + /// back to the controller for this peer. + let receiver = FrameStreamReceiver() + + init(info: MulticamLaneInfo) { + self.peerID = info.peerID + self.displayName = info.displayName + self.status = info.status + self.isFocused = info.isFocused + } +} + +/// Top-level state for the multicam director screen. Holds the ordered lanes +/// and which one is focused; per-lane frame churn lives in each `CameraLane`. +final class MulticamViewModel: ObservableObject { + @Published private(set) var lanes: [CameraLane] = [] + @Published var interfaceOrientation: UIInterfaceOrientation = .portrait + + var focusedLane: CameraLane? { lanes.first { $0.isFocused } } + var otherLanes: [CameraLane] { lanes.filter { !$0.isFocused } } + + /// Reconcile against a controller snapshot: add new lanes, drop gone ones, + /// preserve existing `CameraLane` instances (and their receivers/frames) + /// so streams are never interrupted by a status change elsewhere. + /// + /// Returns the lanes that were newly created, so the view controller can + /// wire their receivers. + @discardableResult + func apply(_ infos: [MulticamLaneInfo]) -> [CameraLane] { + var existing = Dictionary(uniqueKeysWithValues: lanes.map { ($0.peerID, $0) }) + var created: [CameraLane] = [] + + let next: [CameraLane] = infos.map { info in + if let lane = existing.removeValue(forKey: info.peerID) { + if lane.status != info.status { lane.status = info.status } + if lane.isFocused != info.isFocused { lane.isFocused = info.isFocused } + return lane + } + let lane = CameraLane(info: info) + created.append(lane) + return lane + } + + // Tear down receivers for lanes that went away. + for gone in existing.values { gone.receiver.invalidate() } + + lanes = next + return created + } + + func lane(for peer: MCPeerID) -> CameraLane? { + lanes.first { $0.peerID == peer } + } +} diff --git a/RemoteCam/ScannerLobby.swift b/RemoteCam/ScannerLobby.swift index 4a74c0b3..dd9ec1e9 100644 --- a/RemoteCam/ScannerLobby.swift +++ b/RemoteCam/ScannerLobby.swift @@ -26,6 +26,11 @@ protocol ScannerLobby: AnyObject, Sendable { /// Navigate to the role picker after a peer connects. func goToRole() + /// Multicam collecting: the set of cameras connected so far grew. Lets the + /// scanner show a "Start (N)" affordance. Default no-op — only the + /// production scanner implements it, and only when `ENABLE_MULTICAM`. + func didCollectMulticamCameras(_ peers: [MCPeerID]) + /// Pop navigation back to the scanner screen (called when scanning restarts). func returnToLobby() @@ -33,6 +38,10 @@ protocol ScannerLobby: AnyObject, Sendable { func presentScanningError() } +extension ScannerLobby { + func didCollectMulticamCameras(_ peers: [MCPeerID]) {} +} + /// Binds a `ScannerLobby` to `RemoteCamSession` — the protocol-typed /// counterpart of Theater's `SetViewCtrl` (whose generic parameter requires /// a concrete class). diff --git a/RemoteCam/SessionCoordinator.swift b/RemoteCam/SessionCoordinator.swift index 1894b3bc..d93a1b49 100644 --- a/RemoteCam/SessionCoordinator.swift +++ b/RemoteCam/SessionCoordinator.swift @@ -222,6 +222,19 @@ public actor SessionCoordinator { /// camera peer speaks VP9, which gates sending `RemoteCmd.RequestKeyframe`. private var monitorReceivedVP9Frame = false + /// Multicam director "collecting" mode. Off by default and only ever set + /// by the scanner when `ENABLE_MULTICAM` and the monitor role, so every + /// non-multicam path is byte-identical. While set, a peer connecting in + /// `.scanning` is accumulated (the machine stays scanning, keeps browsing) + /// instead of transitioning to `.connected` and auto-advancing to the 1:1 + /// monitor; the scanner reads `multicamCollectedPeers` on "Start". + private var multicamCollecting = false + private var multicamCollectedPeers: [MCPeerID] = [] + + /// Test support. + func multicamCollectingForTesting() -> Bool { multicamCollecting } + func multicamCollectedPeersForTesting() -> [MCPeerID] { multicamCollectedPeers } + /// Test support. func monitorReceivedVP9FrameForTesting() -> Bool { monitorReceivedVP9Frame } @@ -295,6 +308,45 @@ public actor SessionCoordinator { var connectedPeers: [MCPeerID] { multipeerService?.connectedPeers ?? [] } + /// How many cameras the collecting scanner has connected. The scanner + /// reads this on "Start" to choose the single-camera vs director path. + func multicamConnectedCount() -> Int { multicamCollecting ? connectedPeers.count : 0 } + + /// Hand the live transport (and the ≥2 cameras it is connected to) to a + /// `MulticamController`. Detaches this coordinator from the transport — + /// nils its references without stopping the session — so the multicam + /// controller becomes the sole delegate and this coordinator's `stop()` + /// (on scanner teardown) cannot kill a session the director is using. + /// Returns nil unless collecting with two or more cameras (the single + /// camera case stays on the classic monitor — see below). + func detachTransportForMulticam() -> (transport: any MultipeerServiceProtocol, peers: [MCPeerID])? { + guard multicamCollecting, let transport = multipeerService else { return nil } + let peers = transport.connectedPeers + guard peers.count >= 2 else { return nil } + multipeerService = nil + transportShared.value = nil + multicamCollecting = false + multicamCollectedPeers = [] + return (transport, peers) + } + + /// The single-camera exit from collecting: promote the one connected peer + /// to a normal `.connected` session so the classic `MonitorViewController` + /// path runs exactly as it does without the flag. Returns false (leaving + /// the scanner as-is) unless collecting with exactly one camera. + func promoteSingleCollectedToConnected() async -> Bool { + guard multicamCollecting, connectedPeers.count == 1, + let peer = connectedPeers.first, let liveLobby = lobby?.value else { return false } + multicamCollecting = false + multicamCollectedPeers = [] + link = .linked(peer) + OperationQueue.main.addOperation { + liveLobby.scannerViewModel.connectedToPeer() + } + await transition(to: .connected) + return true + } + private func unableToProcessError(_ msg: Message) async -> NSError { let deviceName = await MainActor.run { UIDevice.current.name } return NSError( @@ -691,6 +743,22 @@ public actor SessionCoordinator { break case let connected as OnConnectToDevice: + if multicamCollecting { + // Accumulate and stay scanning: the director wants several + // cameras, so keep browsing/inviting and let the scanner show a + // growing set. The transport holds every connection; the handoff + // reads them on "Start". No transition to `.connected` (which + // would stop browsing and auto-advance). + if !multicamCollectedPeers.contains(connected.peer) { + multicamCollectedPeers.append(connected.peer) + } + link = .none // free the invite slot so the next camera can dial + let peers = multicamCollectedPeers + OperationQueue.main.addOperation { + liveLobby.didCollectMulticamCameras(peers) + } + break + } link = .linked(connected.peer) OperationQueue.main.addOperation { liveLobby.scannerViewModel.connectedToPeer() @@ -1402,6 +1470,9 @@ public actor SessionCoordinator { case is UICmd.AppForegrounded: await rearmAfterForeground() + case let collect as UICmd.SetMulticamCollecting: + multicamCollecting = collect.on + case is UICmd.PeerTrafficObserved: // Arrives with every inbound message; only a wait cares (see // `inReconnecting`), and it is swallowed here so the rest of the diff --git a/RemoteCam/UICmds.swift b/RemoteCam/UICmds.swift index f934eb84..182093e8 100644 --- a/RemoteCam/UICmds.swift +++ b/RemoteCam/UICmds.swift @@ -564,4 +564,17 @@ extension UICmd { super.init(sender: nil) } } + + /// Multicam director "collecting" mode: while set, the scanner accumulates + /// several connected cameras instead of auto-advancing to the 1:1 monitor + /// on the first connect. Sent by the scanner only when `ENABLE_MULTICAM` + /// and the monitor role — off, the coordinator's scanning path is + /// byte-identical to before. + public class SetMulticamCollecting: Message, @unchecked Sendable { + let on: Bool + init(on: Bool) { + self.on = on + super.init(sender: nil) + } + } } diff --git a/RemoteCam/en.lproj/Localizable.strings b/RemoteCam/en.lproj/Localizable.strings index 622cb8ca..be15bbe8 100644 --- a/RemoteCam/en.lproj/Localizable.strings +++ b/RemoteCam/en.lproj/Localizable.strings @@ -359,3 +359,6 @@ "IncompatibleBothTitle" = "App is out of date"; "IncompatibleBothBody" = "Please update Remote Shutter on both devices."; "IncompatibleUpdateButton" = "Update"; + +// Multicam director (behind ENABLE_MULTICAM) +"Start (%d)" = "Start (%d)"; diff --git a/RemoteCamTests/MulticamControllerTests.swift b/RemoteCamTests/MulticamControllerTests.swift new file mode 100644 index 00000000..9a1ab656 --- /dev/null +++ b/RemoteCamTests/MulticamControllerTests.swift @@ -0,0 +1,190 @@ +// +// MulticamControllerTests.swift +// RemoteShutterTests +// +// Copyright © 2026 Security Union LLC. All rights reserved. +// + +import MPCCompat +import XCTest +@testable import RemoteShutter + +/// Captures what the controller pushes to the screen. +private final class FakeMulticamDisplay: MulticamDisplay, @unchecked Sendable { + var lastLanes: [MulticamLaneInfo] = [] + var receivedFrames: [MCPeerID] = [] + var didExit = false + + func applyLanes(_ lanes: [MulticamLaneInfo]) { lastLanes = lanes } + func receiveFrame(_ frame: RemoteCmd.OnFrame) { receivedFrames.append(frame.peerId) } + func exitMulticam() { didExit = true } +} + +final class MulticamControllerTests: XCTestCase { + + private let camA = MCPeerID(displayName: "CameraA") + private let camB = MCPeerID(displayName: "CameraB") + + private func makeController(peers: [MCPeerID]) + async -> (MulticamController, FakeMultipeerService, FakeMulticamDisplay) { + let controller = MulticamController() + let transport = FakeMultipeerService() + transport.sendResult = true + transport.connectedPeers = peers + let display = FakeMulticamDisplay() + await controller.setDisplay(display) + await controller.install(transport: transport, initialPeers: peers, mode: .photo) + await controller.waitForIdle() + return (controller, transport, display) + } + + private func sent(_ transport: FakeMultipeerService, _ type: T.Type) + -> [(msg: Message, peers: [MCPeerID], mode: MCSessionSendDataMode)] { + transport.sentMessages.filter { $0.msg is T } + } + + // MARK: - Handshake + + func testInstallSeedsLaneAndHandshakesEveryCamera() async { + let (controller, transport, _) = await makeController(peers: [camA, camB]) + + let lanes = await controller.lanesForTesting() + XCTAssertEqual(Set(lanes.map(\.peerID)), [camA, camB]) + + // Each camera is asked for capabilities, exactly addressed to itself. + let requests = sent(transport, RemoteCmd.RequestCameraCapabilities.self) + XCTAssertEqual(Set(requests.flatMap(\.peers)), [camA, camB]) + for req in requests { XCTAssertEqual(req.peers.count, 1) } + + // And discovery keeps running so more cameras can be added later. + XCTAssertGreaterThanOrEqual(transport.discoveryStarts, 1) + } + + func testFirstCameraIsFocused() async { + let (controller, _, _) = await makeController(peers: [camA, camB]) + let focused = await controller.focusedPeerForTesting() + XCTAssertEqual(focused, camA) + } + + // MARK: - Capabilities → live + + func testCapabilitiesMarkLaneLinkedAndPingClockWhenMulticam() async { + let (controller, transport, _) = await makeController(peers: [camA, camB]) + transport.sentMessages.removeAll() + + controller.didReceiveMessage(multicamCaps(), from: camA) + await controller.waitForIdle() + + let statusA = await controller.statusForTesting(camA) + XCTAssertEqual(statusA, .linked) + // A multicam-capable camera gets an immediate clock probe, to itself. + let pings = sent(transport, RemoteCmd.ClockSyncPing.self) + XCTAssertEqual(pings.map(\.peers), [[camA]]) + } + + // MARK: - Frame routing (Seam B) + + func testFrameRoutesToItsLaneAndAcksOnlyItsSource() async { + let (controller, transport, display) = await makeController(peers: [camA, camB]) + transport.sentMessages.removeAll() + + controller.didReceiveFrame(sendFrame(), from: camB) + await controller.waitForIdle() + + XCTAssertEqual(display.receivedFrames, [camB]) + let acks = sent(transport, RemoteCmd.RequestFrame.self) + XCTAssertEqual(acks.map(\.peers), [[camB]], + "the frame ack must address only the camera that sent the frame") + } + + // MARK: - Focused-camera commands + + func testPerCameraCommandTargetsOnlyTheFocusedPeer() async { + let (controller, transport, _) = await makeController(peers: [camA, camB]) + await controller.setFocusedPeer(camB) + transport.sentMessages.removeAll() + + await controller.setZoom(2.0) + await controller.waitForIdle() + + let zooms = sent(transport, RemoteCmd.SetZoom.self) + XCTAssertEqual(zooms.map(\.peers), [[camB]]) + } + + // MARK: - Disconnect / reconnect + + func testDisconnectDegradesOnlyThatLane() async { + let (controller, _, _) = await makeController(peers: [camA, camB]) + + controller.peerDidDisconnect(camA) + await controller.waitForIdle() + + let statusA = await controller.statusForTesting(camA) + let statusB = await controller.statusForTesting(camB) + XCTAssertEqual(statusA, .reconnecting) + XCTAssertEqual(statusB, .linked, "one camera dropping must not disturb the others") + } + + func testBrowserReinvitesOnlyAReconnectingCamera() async { + let (controller, transport, _) = await makeController(peers: [camA, camB]) + controller.peerDidDisconnect(camA) + await controller.waitForIdle() + transport.invitedPeers.removeAll() + + controller.browserDidFindPeer(camA) + await controller.waitForIdle() + XCTAssertEqual(transport.invitedPeers.map(\.peer), [camA]) + + // A camera that is already linked is not re-invited on a browser hit. + transport.invitedPeers.removeAll() + controller.browserDidFindPeer(camB) + await controller.waitForIdle() + XCTAssertTrue(transport.invitedPeers.isEmpty) + } + + // MARK: - Clock sync + + func testPongUpdatesThatLanesOffset() async { + let (controller, _, _) = await makeController(peers: [camA, camB]) + + // Camera A is 500ms ahead; symmetric 20ms RTT is faked via the pong's + // camera clock relative to our own — we only assert an offset landed. + controller.didReceiveMessage( + RemoteCmd.ClockSyncPong(echoT0Millis: SyncClock.nowMillis(), + cameraClockMillis: SyncClock.nowMillis() + 500), + from: camA) + await controller.waitForIdle() + + let offsetA = await controller.offsetForTesting(camA) + let offsetB = await controller.offsetForTesting(camB) + XCTAssertNotNil(offsetA) + XCTAssertNil(offsetB) + } + + // MARK: - Removal + + func testRemoveCameraDropsTheLaneAndRefocuses() async { + let (controller, _, _) = await makeController(peers: [camA, camB]) + + await controller.removeCamera(camA) + let lanes = await controller.lanesForTesting() + XCTAssertEqual(lanes.map(\.peerID), [camB]) + let focusedAfter = await controller.focusedPeerForTesting() + XCTAssertEqual(focusedAfter, camB) + } + + // MARK: - Fixtures + + private func multicamCaps() -> RemoteCmd.CameraCapabilitiesResp { + RemoteCmd.CameraCapabilitiesResp( + frontCamera: nil, backCamera: nil, + currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, + supportsMulticam: true, error: nil) + } + + private func sendFrame() -> RemoteCmd.SendFrame { + RemoteCmd.SendFrame(data: Data([1, 2, 3]), sender: nil, + fps: 30, camPosition: .back, camOrientation: .portrait, + codec: .vp9, sequenceNumber: 1) + } +} diff --git a/RemoteCamTests/MulticamViewModelTests.swift b/RemoteCamTests/MulticamViewModelTests.swift new file mode 100644 index 00000000..a97ce3fb --- /dev/null +++ b/RemoteCamTests/MulticamViewModelTests.swift @@ -0,0 +1,62 @@ +// +// MulticamViewModelTests.swift +// RemoteShutterTests +// +// Copyright © 2026 Security Union LLC. All rights reserved. +// + +import MPCCompat +import XCTest +@testable import RemoteShutter + +final class MulticamViewModelTests: XCTestCase { + + private let camA = MCPeerID(displayName: "CameraA") + private let camB = MCPeerID(displayName: "CameraB") + + private func info(_ peer: MCPeerID, status: CameraLink.Status = .linked, + focused: Bool = false) -> MulticamLaneInfo { + MulticamLaneInfo(peerID: peer, displayName: peer.displayName, + status: status, isFocused: focused, clockOffsetMillis: nil) + } + + func testApplyAddsLanesAndReportsCreated() { + let vm = MulticamViewModel() + let created = vm.apply([info(camA, focused: true), info(camB)]) + XCTAssertEqual(vm.lanes.map(\.peerID), [camA, camB]) + XCTAssertEqual(created.map(\.peerID), [camA, camB]) + } + + func testApplyPreservesExistingLaneInstances() { + let vm = MulticamViewModel() + vm.apply([info(camA, focused: true), info(camB)]) + let laneABefore = vm.lane(for: camA) + + // Second apply changes only status; the CameraLane (and its live + // frames/receiver) must be the same instance, not rebuilt. + let created = vm.apply([info(camA, status: .reconnecting, focused: true), info(camB)]) + XCTAssertTrue(created.isEmpty, "no new lanes should be created") + XCTAssertTrue(vm.lane(for: camA) === laneABefore) + XCTAssertEqual(vm.lane(for: camA)?.status, .reconnecting) + } + + func testApplyDropsGoneLanes() { + let vm = MulticamViewModel() + vm.apply([info(camA, focused: true), info(camB)]) + vm.apply([info(camA, focused: true)]) + XCTAssertEqual(vm.lanes.map(\.peerID), [camA]) + XCTAssertNil(vm.lane(for: camB)) + } + + func testFocusedAndOtherLanesPartition() { + let vm = MulticamViewModel() + vm.apply([info(camA, focused: true), info(camB)]) + XCTAssertEqual(vm.focusedLane?.peerID, camA) + XCTAssertEqual(vm.otherLanes.map(\.peerID), [camB]) + + // Refocusing moves the partition without rebuilding lanes. + vm.apply([info(camA), info(camB, focused: true)]) + XCTAssertEqual(vm.focusedLane?.peerID, camB) + XCTAssertEqual(vm.otherLanes.map(\.peerID), [camA]) + } +} diff --git a/RemoteShutter.xcodeproj/project.pbxproj b/RemoteShutter.xcodeproj/project.pbxproj index 1fb41648..9de1589e 100644 --- a/RemoteShutter.xcodeproj/project.pbxproj +++ b/RemoteShutter.xcodeproj/project.pbxproj @@ -203,6 +203,13 @@ CAFEBABE0120000000000001 /* MonitorChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0120000000000002 /* MonitorChrome.swift */; }; CAFEBABE0130000000000001 /* CaptureSyncMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */; }; CAFEBABE0132000000000001 /* ClockOffsetEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0132000000000002 /* ClockOffsetEstimator.swift */; }; + CAFEBABE0140000000000001 /* CameraLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0140000000000002 /* CameraLink.swift */; }; + CAFEBABE0141000000000001 /* MulticamController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0141000000000002 /* MulticamController.swift */; }; + CAFEBABE0142000000000001 /* MulticamViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0142000000000002 /* MulticamViewModel.swift */; }; + CAFEBABE0143000000000001 /* MulticamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0143000000000002 /* MulticamView.swift */; }; + CAFEBABE0144000000000001 /* MulticamViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0144000000000002 /* MulticamViewController.swift */; }; + CAFEBABE0145000000000002 /* MulticamControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0145000000000001 /* MulticamControllerTests.swift */; }; + CAFEBABE0146000000000002 /* MulticamViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0146000000000001 /* MulticamViewModelTests.swift */; }; CAFEBABE0133000000000002 /* ClockOffsetEstimatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0133000000000001 /* ClockOffsetEstimatorTests.swift */; }; CAFEBABE0131000000000002 /* CaptureSyncMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0131000000000001 /* CaptureSyncMetadataTests.swift */; }; FEEDFACE0000000000000001 /* StormoLoopbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDFACE0000000000000002 /* StormoLoopbackTests.swift */; }; @@ -448,6 +455,13 @@ CAFEBABE0120000000000002 /* MonitorChrome.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MonitorChrome.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 = ""; }; + CAFEBABE0140000000000002 /* CameraLink.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraLink.swift; sourceTree = ""; }; + CAFEBABE0141000000000002 /* MulticamController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MulticamController.swift; sourceTree = ""; }; + CAFEBABE0142000000000002 /* MulticamViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MulticamViewModel.swift; sourceTree = ""; }; + CAFEBABE0143000000000002 /* MulticamView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MulticamView.swift; sourceTree = ""; }; + CAFEBABE0144000000000002 /* MulticamViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MulticamViewController.swift; sourceTree = ""; }; + CAFEBABE0145000000000001 /* MulticamControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MulticamControllerTests.swift; sourceTree = ""; }; + CAFEBABE0146000000000001 /* MulticamViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MulticamViewModelTests.swift; sourceTree = ""; }; CAFEBABE0133000000000001 /* ClockOffsetEstimatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClockOffsetEstimatorTests.swift; sourceTree = ""; }; CAFEBABE0131000000000001 /* CaptureSyncMetadataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptureSyncMetadataTests.swift; sourceTree = ""; }; FCB0F6BA2086BF9BB4242D66 /* Pods_RemoteShutterWatch.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RemoteShutterWatch.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -601,6 +615,8 @@ CAFEBABE0121000000000001 /* MonitorChromeTests.swift */, CAFEBABE0131000000000001 /* CaptureSyncMetadataTests.swift */, CAFEBABE0133000000000001 /* ClockOffsetEstimatorTests.swift */, + CAFEBABE0145000000000001 /* MulticamControllerTests.swift */, + CAFEBABE0146000000000001 /* MulticamViewModelTests.swift */, CAFEBABE0002000000000001 /* WatchCaptureCountdownTests.swift */, CAFEBABE0003000000000001 /* WatchSerializationTests.swift */, CAFEBABE0006000000000001 /* WatchPreviewStreamerTests.swift */, @@ -695,6 +711,11 @@ CAFEBABE0120000000000002 /* MonitorChrome.swift */, CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */, CAFEBABE0132000000000002 /* ClockOffsetEstimator.swift */, + CAFEBABE0140000000000002 /* CameraLink.swift */, + CAFEBABE0141000000000002 /* MulticamController.swift */, + CAFEBABE0142000000000002 /* MulticamViewModel.swift */, + CAFEBABE0143000000000002 /* MulticamView.swift */, + CAFEBABE0144000000000002 /* MulticamViewController.swift */, 060B1E141BE7079800077BCC /* Helpers */, 06E965202535199400E5A8B3 /* MediaProcessors.swift */, 06E9652625351E3F00E5A8B3 /* SwiftConstants.swift */, @@ -1189,6 +1210,11 @@ CAFEBABE0120000000000001 /* MonitorChrome.swift in Sources */, CAFEBABE0130000000000001 /* CaptureSyncMetadata.swift in Sources */, CAFEBABE0132000000000001 /* ClockOffsetEstimator.swift in Sources */, + CAFEBABE0140000000000001 /* CameraLink.swift in Sources */, + CAFEBABE0141000000000001 /* MulticamController.swift in Sources */, + CAFEBABE0142000000000001 /* MulticamViewModel.swift in Sources */, + CAFEBABE0143000000000001 /* MulticamView.swift in Sources */, + CAFEBABE0144000000000001 /* MulticamViewController.swift in Sources */, 06BB79B82E374D410094E085 /* FeatureFlags.swift in Sources */, 0692844F1BE5C0E600AF4678 /* MultipeerMessages.swift in Sources */, 069284531BE5C0E600AF4678 /* RemoteCamStateNames.swift in Sources */, @@ -1263,6 +1289,8 @@ CAFEBABE0121000000000002 /* MonitorChromeTests.swift in Sources */, CAFEBABE0131000000000002 /* CaptureSyncMetadataTests.swift in Sources */, CAFEBABE0133000000000002 /* ClockOffsetEstimatorTests.swift in Sources */, + CAFEBABE0145000000000002 /* MulticamControllerTests.swift in Sources */, + CAFEBABE0146000000000002 /* MulticamViewModelTests.swift in Sources */, CAFEBABE0002000000000002 /* WatchCaptureCountdownTests.swift in Sources */, CAFEBABE0003000000000002 /* WatchSerializationTests.swift in Sources */, CAFEBABE0006000000000002 /* WatchPreviewStreamerTests.swift in Sources */, From a35ad292e8780d2daba3c1330c00f7094b37355e Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Mon, 10 Aug 2026 23:35:59 -0700 Subject: [PATCH 05/17] Multicam PR4: synced photo capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schedule-at-timestamp synced stills across the rig, behind ENABLE_MULTICAM. Wire (additive, same evolution pattern): - ScheduledCapture (action 26): director -> camera, carrying the shutter instant in the camera's own SyncClock domain (director applied the offset), the shared director-clock anchor (the alignment key stamped into each clip), capture id / session id, and the camera's 1-based index. - CameraStateResponse gains capture_id_echo for the ack. RemoteCmd ScheduledCapture / ScheduledCaptureAck, both dispatch switches, round-trips. Camera side (SessionCoordinator, single-cam untouched): - On ScheduledCapture: ack (or nack if the fire time is > 1s past) immediately, then schedule the shutter. The delay runs OFF the actor and only enqueues FireScheduledCapture, so the capture is pulled by the message pump in order and never races a state transition (the flagged concern). The photo saves locally as today, additionally stamped with CaptureSyncMetadata: EXIF UserComment JSON + DateTimeOriginal/SubSec, and a shared RS___cam originalFilename so any editor can group and align the angles. A stamping failure never costs the user the photo. Director side (MulticamController): - capturePhoto(): picks fireAt = now + 150ms, sends per-camera ScheduledCapture with fireAt + lane.offset to every linked multicam lane; if any offset is missing, falls back to a plain TakePic fan-out under the same shot id. Aggregate state capturingPhoto(captureId, acksRemaining); best-effort policy — per-lane 3s ack timeout marks a silent camera failed; returns to monitoring when all lanes acked/nacked/timed out. Per-lane outcome (captured/failed) surfaced to the tile badge. UI: the MulticamView shutter (reusing the 1:1 ShutterButton + activity ring) fires capturePhoto(); tiles show a captured/failed badge. Tests: serialization round-trips; MulticamControllerTests (per-lane offsets => different fire instants, non-multicam lane excluded, ack aggregation, timeout completion, fallback); camera-side (past-fire nack, valid fire acks immediately then pulls the shutter). Full suite green (677 tests). Co-Authored-By: Claude Fable 5 --- RemoteCam/CameraLink.swift | 4 + RemoteCam/CaptureSyncMetadata.swift | 47 ++++++ RemoteCam/FlatBufferSchemas.fbs | 20 ++- RemoteCam/FlatBufferSchemas_generated.swift | 50 +++++- RemoteCam/MulticamController.swift | 153 +++++++++++++++++- RemoteCam/MulticamView.swift | 40 +++++ RemoteCam/MulticamViewController.swift | 8 + RemoteCam/MulticamViewModel.swift | 6 + RemoteCam/RemoteCmdFlatBuffers.swift | 44 +++++ RemoteCam/RemoteCmds.swift | 44 +++++ RemoteCam/SessionCoordinator.swift | 119 +++++++++++++- RemoteCamTests/MulticamControllerTests.swift | 112 +++++++++++++ RemoteCamTests/MulticamViewModelTests.swift | 3 +- RemoteCamTests/RemoteCamSessionTests.swift | 41 +++++ .../RemoteCmdSerializationTests.swift | 28 ++++ 15 files changed, 708 insertions(+), 11 deletions(-) diff --git a/RemoteCam/CameraLink.swift b/RemoteCam/CameraLink.swift index 2d3128b7..572088ab 100644 --- a/RemoteCam/CameraLink.swift +++ b/RemoteCam/CameraLink.swift @@ -51,6 +51,10 @@ final class CameraLink { /// `SessionCoordinator.monitorReceivedVP9Frame`, but per camera). var sawVP9 = false + /// How this camera answered the most recent synced capture (nil before the + /// first). Drives the tile's captured/failed badge. + var captureOutcome: CaptureOutcome? + /// This camera's own preview decoder + stall watchdog. Frames tagged with /// this peer's id are fed here; its `onImage` drives exactly this lane's /// tile, so a frame from another camera never touches it. diff --git a/RemoteCam/CaptureSyncMetadata.swift b/RemoteCam/CaptureSyncMetadata.swift index 376e34a5..128456e3 100644 --- a/RemoteCam/CaptureSyncMetadata.swift +++ b/RemoteCam/CaptureSyncMetadata.swift @@ -8,6 +8,8 @@ import AVFoundation import Foundation +import ImageIO +import UniformTypeIdentifiers /// Alignment metadata attached to every clip and photo captured in a multicam /// director session. Each camera saves full-res media locally; these fields @@ -73,6 +75,51 @@ struct CaptureSyncMetadata: Codable, Equatable { return try? JSONDecoder().decode(CaptureSyncMetadata.self, from: data) } + /// Re-encode `imageData` with this shot's sync fields embedded in EXIF, so + /// the alignment travels inside the photo itself (survives export/AirDrop): + /// the JSON in `UserComment`, and the anchor instant in + /// `DateTimeOriginal`/`SubSecTimeOriginal`. Format (JPEG/HEIC) is preserved. + /// Returns the original data unchanged if re-encoding isn't possible, so a + /// stamping failure never costs the user the photo. + func stamped(_ imageData: Data) -> Data { + guard let source = CGImageSourceCreateWithData(imageData as CFData, nil), + let type = CGImageSourceGetType(source) else { return imageData } + + var properties = (CGImageSourceCopyPropertiesAtIndex(source, 0, nil) + as? [CFString: Any]) ?? [:] + var exif = (properties[kCGImagePropertyExifDictionary] as? [CFString: Any]) ?? [:] + + if let json = jsonString() { + exif[kCGImagePropertyExifUserComment] = json + } + let anchorSeconds = Double(anchorMillis) / 1000.0 + let date = Date(timeIntervalSince1970: anchorSeconds) + exif[kCGImagePropertyExifDateTimeOriginal] = Self.exifDateFormatter.string(from: date) + exif[kCGImagePropertyExifSubsecTimeOriginal] = String(format: "%03d", anchorMillis % 1000) + properties[kCGImagePropertyExifDictionary] = exif + + let output = NSMutableData() + guard let dest = CGImageDestinationCreateWithData( + output, type, 1, nil) else { return imageData } + CGImageDestinationAddImageFromSource(dest, source, 0, properties as CFDictionary) + guard CGImageDestinationFinalize(dest) else { return imageData } + return output as Data + } + + /// A Photos `originalFilename` for this shot, e.g. + /// `RS___cam2.heic`. Groups a capture's files across cameras. + func photoFilename(isHEIC: Bool) -> String { + "\(filenamePrefix).\(isHEIC ? "heic" : "jpg")" + } + + /// EXIF wants `yyyy:MM:dd HH:mm:ss` in the local zone. + private static let exifDateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy:MM:dd HH:mm:ss" + f.locale = Locale(identifier: "en_US_POSIX") + return f + }() + private static func shortID(_ uuidString: String) -> String { String(uuidString.prefix(8)).lowercased() } diff --git a/RemoteCam/FlatBufferSchemas.fbs b/RemoteCam/FlatBufferSchemas.fbs index 3dfee4b3..5c826741 100644 --- a/RemoteCam/FlatBufferSchemas.fbs +++ b/RemoteCam/FlatBufferSchemas.fbs @@ -40,10 +40,16 @@ enum CommandAction : byte { EndSession = 23, // either side: "I am leaving on purpose" SetCameraPreviewMode = 24, // monitor -> camera: local preview on/standby; // only sent to peers advertising supports_preview_mode - ClockSyncPing = 25 // director -> camera: clock-offset probe carrying the + ClockSyncPing = 25, // director -> camera: clock-offset probe carrying the // director's send time; the camera answers with a // ClockSyncPing response echoing it plus its own clock. // Only sent to peers advertising supports_multicam. + ScheduledCapture = 26 // director -> camera: take a photo at a wall-clock + // instant expressed in the camera's own clock domain + // (the director already applied the per-camera offset), + // so N cameras fire together. Only sent to peers + // advertising supports_multicam; the camera acks with a + // CameraStateResponse echoing the capture id. } // Whether the camera device drives its own on-screen live preview. On is the @@ -163,6 +169,17 @@ table CommandParameters { focus_point_y: float; // upright-display space; origin top-left) camera_preview_mode: CameraPreviewModeEnum; // payload for SetCameraPreviewMode clock_sync_t0_ms: uint64; // payload for ClockSyncPing: director clock at send + // ScheduledCapture payload. `fire_at_camera_clock_ms` is the shutter instant + // in the CAMERA's SyncClock domain (director already added the offset), used + // to schedule. `anchor_ms` is the same instant in the DIRECTOR's clock — the + // value is identical across every camera in the shot, so it is the alignment + // key stamped into each clip. `capture_id`/`capture_session_id` group the + // shot; `capture_camera_index` is this camera's 1-based slot for filenames. + capture_fire_at_camera_clock_ms: uint64; + capture_anchor_ms: uint64; + capture_id: string; + capture_session_id: string; + capture_camera_index: int; } // MARK: - Command Structure @@ -285,6 +302,7 @@ table CameraStateResponse { // 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 } // MARK: - Frame Data diff --git a/RemoteCam/FlatBufferSchemas_generated.swift b/RemoteCam/FlatBufferSchemas_generated.swift index ef8adaa5..f8674ee4 100644 --- a/RemoteCam/FlatBufferSchemas_generated.swift +++ b/RemoteCam/FlatBufferSchemas_generated.swift @@ -34,8 +34,9 @@ public enum RemoteShutter_CommandAction: Int8, Enum, Verifiable { case endsession = 23 case setcamerapreviewmode = 24 case clocksyncping = 25 + case scheduledcapture = 26 - public static var max: RemoteShutter_CommandAction { return .clocksyncping } + public static var max: RemoteShutter_CommandAction { return .scheduledcapture } public static var min: RemoteShutter_CommandAction { return .unknown } } @@ -333,6 +334,11 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { case focusPointY = 38 case cameraPreviewMode = 40 case clockSyncT0Ms = 42 + case captureFireAtCameraClockMs = 44 + case captureAnchorMs = 46 + case captureId = 48 + case captureSessionId = 50 + case captureCameraIndex = 52 var v: Int32 { Int32(self.rawValue) } var p: VOffset { self.rawValue } } @@ -360,7 +366,14 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { public var focusPointY: Float32 { let o = _accessor.offset(VTOFFSET.focusPointY.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Float32.self, at: o) } public var cameraPreviewMode: RemoteShutter_CameraPreviewModeEnum { let o = _accessor.offset(VTOFFSET.cameraPreviewMode.v); return o == 0 ? .unknown : RemoteShutter_CameraPreviewModeEnum(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? .unknown } public var clockSyncT0Ms: UInt64 { let o = _accessor.offset(VTOFFSET.clockSyncT0Ms.v); return o == 0 ? 0 : _accessor.readBuffer(of: UInt64.self, at: o) } - public static func startCommandParameters(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 20) } + public var captureFireAtCameraClockMs: UInt64 { let o = _accessor.offset(VTOFFSET.captureFireAtCameraClockMs.v); return o == 0 ? 0 : _accessor.readBuffer(of: UInt64.self, at: o) } + public var captureAnchorMs: UInt64 { let o = _accessor.offset(VTOFFSET.captureAnchorMs.v); return o == 0 ? 0 : _accessor.readBuffer(of: UInt64.self, at: o) } + public var captureId: String? { let o = _accessor.offset(VTOFFSET.captureId.v); return o == 0 ? nil : _accessor.string(at: o) } + public var captureIdSegmentArray: [UInt8]? { return _accessor.getVector(at: VTOFFSET.captureId.v) } + public var captureSessionId: String? { let o = _accessor.offset(VTOFFSET.captureSessionId.v); return o == 0 ? nil : _accessor.string(at: o) } + public var captureSessionIdSegmentArray: [UInt8]? { return _accessor.getVector(at: VTOFFSET.captureSessionId.v) } + public var captureCameraIndex: Int32 { let o = _accessor.offset(VTOFFSET.captureCameraIndex.v); return o == 0 ? 0 : _accessor.readBuffer(of: Int32.self, at: o) } + public static func startCommandParameters(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 25) } 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) } @@ -382,6 +395,11 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { public static func add(focusPointY: Float32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: focusPointY, def: 0.0, at: VTOFFSET.focusPointY.p) } public static func add(cameraPreviewMode: RemoteShutter_CameraPreviewModeEnum, _ fbb: inout FlatBufferBuilder) { fbb.add(element: cameraPreviewMode.rawValue, def: 0, at: VTOFFSET.cameraPreviewMode.p) } public static func add(clockSyncT0Ms: UInt64, _ fbb: inout FlatBufferBuilder) { fbb.add(element: clockSyncT0Ms, def: 0, at: VTOFFSET.clockSyncT0Ms.p) } + public static func add(captureFireAtCameraClockMs: UInt64, _ fbb: inout FlatBufferBuilder) { fbb.add(element: captureFireAtCameraClockMs, def: 0, at: VTOFFSET.captureFireAtCameraClockMs.p) } + public static func add(captureAnchorMs: UInt64, _ fbb: inout FlatBufferBuilder) { fbb.add(element: captureAnchorMs, def: 0, at: VTOFFSET.captureAnchorMs.p) } + public static func add(captureId: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: captureId, at: VTOFFSET.captureId.p) } + public static func add(captureSessionId: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: captureSessionId, at: VTOFFSET.captureSessionId.p) } + public static func add(captureCameraIndex: Int32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: captureCameraIndex, def: 0, at: VTOFFSET.captureCameraIndex.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, @@ -404,7 +422,12 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { focusPointX: Float32 = 0.0, focusPointY: Float32 = 0.0, cameraPreviewMode: RemoteShutter_CameraPreviewModeEnum = .unknown, - clockSyncT0Ms: UInt64 = 0 + clockSyncT0Ms: UInt64 = 0, + captureFireAtCameraClockMs: UInt64 = 0, + captureAnchorMs: UInt64 = 0, + captureIdOffset captureId: Offset = Offset(), + captureSessionIdOffset captureSessionId: Offset = Offset(), + captureCameraIndex: Int32 = 0 ) -> Offset { let __start = RemoteShutter_CommandParameters.startCommandParameters(&fbb) RemoteShutter_CommandParameters.add(sendToRemote: sendToRemote, &fbb) @@ -427,6 +450,11 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { RemoteShutter_CommandParameters.add(focusPointY: focusPointY, &fbb) RemoteShutter_CommandParameters.add(cameraPreviewMode: cameraPreviewMode, &fbb) RemoteShutter_CommandParameters.add(clockSyncT0Ms: clockSyncT0Ms, &fbb) + RemoteShutter_CommandParameters.add(captureFireAtCameraClockMs: captureFireAtCameraClockMs, &fbb) + RemoteShutter_CommandParameters.add(captureAnchorMs: captureAnchorMs, &fbb) + RemoteShutter_CommandParameters.add(captureId: captureId, &fbb) + RemoteShutter_CommandParameters.add(captureSessionId: captureSessionId, &fbb) + RemoteShutter_CommandParameters.add(captureCameraIndex: captureCameraIndex, &fbb) return RemoteShutter_CommandParameters.endCommandParameters(&fbb, start: __start) } @@ -452,6 +480,11 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { try _v.visit(field: VTOFFSET.focusPointY.p, fieldName: "focusPointY", required: false, type: Float32.self) try _v.visit(field: VTOFFSET.cameraPreviewMode.p, fieldName: "cameraPreviewMode", required: false, type: RemoteShutter_CameraPreviewModeEnum.self) try _v.visit(field: VTOFFSET.clockSyncT0Ms.p, fieldName: "clockSyncT0Ms", required: false, type: UInt64.self) + try _v.visit(field: VTOFFSET.captureFireAtCameraClockMs.p, fieldName: "captureFireAtCameraClockMs", required: false, type: UInt64.self) + try _v.visit(field: VTOFFSET.captureAnchorMs.p, fieldName: "captureAnchorMs", required: false, type: UInt64.self) + try _v.visit(field: VTOFFSET.captureId.p, fieldName: "captureId", required: false, type: ForwardOffset.self) + try _v.visit(field: VTOFFSET.captureSessionId.p, fieldName: "captureSessionId", required: false, type: ForwardOffset.self) + try _v.visit(field: VTOFFSET.captureCameraIndex.p, fieldName: "captureCameraIndex", required: false, type: Int32.self) _v.finish() } } @@ -1103,6 +1136,7 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { case currentZoom = 22 case clockSyncEchoT0Ms = 24 case clockSyncCameraClockMs = 26 + case captureIdEcho = 28 var v: Int32 { Int32(self.rawValue) } var p: VOffset { self.rawValue } } @@ -1125,7 +1159,9 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { 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 static func startCameraStateResponse(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 12) } + public var captureIdEcho: String? { let o = _accessor.offset(VTOFFSET.captureIdEcho.v); return o == 0 ? nil : _accessor.string(at: o) } + public var captureIdEchoSegmentArray: [UInt8]? { return _accessor.getVector(at: VTOFFSET.captureIdEcho.v) } + public static func startCameraStateResponse(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 13) } public static func add(action: RemoteShutter_CommandAction, _ fbb: inout FlatBufferBuilder) { fbb.add(element: action.rawValue, def: 0, at: VTOFFSET.action.p) } public static func add(success: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: success, def: false, at: VTOFFSET.success.p) } @@ -1139,6 +1175,7 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { 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 endCameraStateResponse(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } public static func createCameraStateResponse( _ fbb: inout FlatBufferBuilder, @@ -1153,7 +1190,8 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { zoomRangeOffset zoomRange: Offset = Offset(), currentZoom: Double = 0.0, clockSyncEchoT0Ms: UInt64 = 0, - clockSyncCameraClockMs: UInt64 = 0 + clockSyncCameraClockMs: UInt64 = 0, + captureIdEchoOffset captureIdEcho: Offset = Offset() ) -> Offset { let __start = RemoteShutter_CameraStateResponse.startCameraStateResponse(&fbb) RemoteShutter_CameraStateResponse.add(action: action, &fbb) @@ -1168,6 +1206,7 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { RemoteShutter_CameraStateResponse.add(currentZoom: currentZoom, &fbb) RemoteShutter_CameraStateResponse.add(clockSyncEchoT0Ms: clockSyncEchoT0Ms, &fbb) RemoteShutter_CameraStateResponse.add(clockSyncCameraClockMs: clockSyncCameraClockMs, &fbb) + RemoteShutter_CameraStateResponse.add(captureIdEcho: captureIdEcho, &fbb) return RemoteShutter_CameraStateResponse.endCameraStateResponse(&fbb, start: __start) } @@ -1185,6 +1224,7 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { 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) _v.finish() } } diff --git a/RemoteCam/MulticamController.swift b/RemoteCam/MulticamController.swift index 0b9d82d1..d717d06e 100644 --- a/RemoteCam/MulticamController.swift +++ b/RemoteCam/MulticamController.swift @@ -13,10 +13,18 @@ import Stormo import UIKit /// The director's aggregate posture across all cameras. One shutter drives -/// them all, so the capture states are aggregate, not per-camera. PR3 only -/// needs `.monitoring`; the capture cases arrive with synced photo/video. +/// them all, so the capture states are aggregate, not per-camera. enum MulticamState: Equatable { case monitoring(mode: MonitorMode) + /// A synced photo is in flight: `acksRemaining` cameras have yet to accept + /// or refuse `captureId`. Returns to `.monitoring` when it reaches zero. + case capturingPhoto(captureId: String, acksRemaining: Int) +} + +/// How a camera answered the last synced capture, for the tile badge. +enum CaptureOutcome: Equatable { + case captured + case failed } /// A UI-facing snapshot of one lane. Deliberately a value type carrying only @@ -30,6 +38,8 @@ struct MulticamLaneInfo: Equatable { /// Clock-offset estimate in ms (nil until the first pong), for a future /// sync-quality indicator; unused by PR3's UI beyond diagnostics. let clockOffsetMillis: Int64? + /// How this camera answered the last synced capture, for the tile badge. + let captureOutcome: CaptureOutcome? } /// The main-actor bridge from the controller to the multicam screen — the @@ -39,6 +49,8 @@ struct MulticamLaneInfo: Equatable { /// camera B never re-renders camera A. protocol MulticamDisplay: AnyObject { func applyLanes(_ lanes: [MulticamLaneInfo]) + /// A synced photo is (or is no longer) in flight — drives the shutter ring. + func applyCaptureInFlight(_ inFlight: Bool) func receiveFrame(_ frame: RemoteCmd.OnFrame) func exitMulticam() } @@ -110,6 +122,20 @@ public actor MulticamController { private var links: [MCPeerID: CameraLink] = [:] private var focusedPeer: MCPeerID? + /// One id per director session, part of every capture's filename group. + private let sessionID = UUID().uuidString + /// Cameras that have yet to answer the in-flight synced capture. Empty + /// unless `state` is `.capturingPhoto`. + private var capturingLanes: Set = [] + private var currentCaptureID: String? + + /// Lead time before a synced shutter fires — long enough to cover the + /// worst-case one-way latency plus a retransmit and scheduling slop, so + /// every camera has the command in hand before the instant arrives. + private let captureLeadMillis: UInt64 = 150 + /// How long a camera has to answer before it is counted as failed. + private var captureAckTimeout: TimeInterval = 3 + private weak var display: MulticamDisplay? /// The interval between clock-offset refreshes per camera. @@ -234,6 +260,18 @@ public actor MulticamController { sendTo(peer, RemoteCmd.ClockSyncPing(t0Millis: SyncClock.nowMillis())) } + case let ack as RemoteCmd.ScheduledCaptureAck: + recordCaptureAck(from: peer, outcome: ack.error == nil ? .captured : .failed) + + case is RemoteCmd.TakePicAck: + // The fallback (plain TakePic) path's positive ack. + recordCaptureAck(from: peer, outcome: .captured) + + case let resp as RemoteCmd.TakePicResp: + // Fallback failure signal; a success here is a duplicate of the ack + // and is ignored (the lane is already resolved). + if resp.error != nil { recordCaptureAck(from: peer, outcome: .failed) } + default: // Per-camera command responses (zoom/lens/flash/torch acks) update // only the focused lane's controls, wired to the UI in a later PR; @@ -331,6 +369,111 @@ public actor MulticamController { sendTo(peer, msg) } + // MARK: - Synced photo capture (all cameras) + + /// Test seam. + func setCaptureAckTimeout(_ t: TimeInterval) { captureAckTimeout = t } + func captureStateForTesting() -> (id: String, remaining: Int)? { + if case .capturingPhoto(let id, let remaining) = state { return (id, remaining) } + return nil + } + func captureOutcomeForTesting(_ peer: MCPeerID) -> CaptureOutcome? { links[peer]?.captureOutcome } + + /// Test seam: put a lane in the state a real handshake would — linked, with + /// known multicam capability and (optionally) a known clock offset — so + /// capture behavior can be asserted deterministically. + func seedLaneForTesting(_ peer: MCPeerID, supportsMulticam: Bool, offsetMillis: Int64?) { + guard let link = links[peer] else { return } + 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. + link.clockEstimator.recordExchange( + t0Millis: 0, cameraClockMillis: UInt64(bitPattern: offsetMillis), t3Millis: 0) + } + } + + /// Fire a synced photo on every ready multicam camera. When each camera's + /// clock offset is known, the shutter is scheduled at a shared instant + /// (`ScheduledCapture`) so the exposures line up sub-frame; if any offset + /// is still missing it falls back to a plain fan-out (`TakePic`) — the same + /// shot id groups both paths. No-op if nothing is ready or a capture is + /// already in flight. + func capturePhoto() { + guard case .monitoring = state else { return } + let ready = order.compactMap { links[$0] } + .filter { $0.status == .linked && $0.supportsMulticam } + guard !ready.isEmpty else { return } + + let captureID = UUID().uuidString + capturingLanes = Set(ready.map(\.peerID)) + currentCaptureID = captureID + for link in ready { link.captureOutcome = nil } + + let everyOffsetKnown = ready.allSatisfy { $0.latestOffset != nil } + if everyOffsetKnown { + let fireAt = SyncClock.nowMillis() + captureLeadMillis + for (index, link) in ready.enumerated() { + let offset = link.latestOffset?.offsetMillis ?? 0 + let fireAtCameraClock = UInt64(Int64(fireAt) + offset) + sendTo(link.peerID, RemoteCmd.ScheduledCapture( + fireAtCameraClockMillis: fireAtCameraClock, + anchorMillis: fireAt, + captureId: captureID, + sessionId: sessionID, + cameraIndex: index + 1)) + } + } else { + // Fallback: not every clock is measured yet, so we can't promise a + // sub-frame shutter — take the shot immediately on each camera. + for link in ready { + sendTo(link.peerID, RemoteCmd.TakePic(sender: nil, sendMediaToPeer: false)) + } + } + + state = .capturingPhoto(captureId: captureID, acksRemaining: capturingLanes.count) + publishLanes() + armCaptureTimeout(captureID) + } + + /// A camera answered the in-flight capture (accepted or refused). Counts it + /// once; later duplicate acks (e.g. a scheduled camera also sends TakePic + /// acks after it fires) are ignored. + private func recordCaptureAck(from peer: MCPeerID, outcome: CaptureOutcome) { + guard case .capturingPhoto(let captureID, _) = state, + capturingLanes.contains(peer) else { return } + links[peer]?.captureOutcome = outcome + capturingLanes.remove(peer) + let remaining = capturingLanes.count + state = remaining == 0 + ? .monitoring(mode: .photo) + : .capturingPhoto(captureId: captureID, acksRemaining: remaining) + publishLanes() + } + + private func armCaptureTimeout(_ captureID: String) { + let timeout = captureAckTimeout + Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + guard let self else { return } + await self.expireCaptureAcks(captureID) + } + } + + /// Any camera that never answered by the deadline is counted as failed, so + /// the aggregate always resolves back to monitoring. + private func expireCaptureAcks(_ captureID: String) { + guard case .capturingPhoto(let inFlight, _) = state, inFlight == captureID else { return } + for peer in capturingLanes { links[peer]?.captureOutcome = .failed } + capturingLanes.removeAll() + currentCaptureID = nil + state = .monitoring(mode: .photo) + publishLanes() + } + /// A lane's stall watchdog fired — re-request a frame to unstick just that /// camera's pump (the others are unaffected). func nudgeFrame(for peer: MCPeerID) { @@ -384,15 +527,19 @@ public actor MulticamController { displayName: link.displayName, status: link.status, isFocused: peer == focusedPeer, - clockOffsetMillis: link.latestOffset?.offsetMillis) + clockOffsetMillis: link.latestOffset?.offsetMillis, + captureOutcome: link.captureOutcome) } } private func publishLanes() { let snapshot = laneSnapshot() + let inFlight: Bool + if case .capturingPhoto = state { inFlight = true } else { inFlight = false } let display = display OperationQueue.main.addOperation { display?.applyLanes(snapshot) + display?.applyCaptureInFlight(inFlight) } } diff --git a/RemoteCam/MulticamView.swift b/RemoteCam/MulticamView.swift index ca3b8af6..f4f914db 100644 --- a/RemoteCam/MulticamView.swift +++ b/RemoteCam/MulticamView.swift @@ -16,6 +16,8 @@ struct MulticamView: View { /// Tap a thumbnail to make that camera the focused one. let onFocusLane: (CameraLane) -> Void + /// The synced shutter — fires a photo on every ready camera at once. + let onCapture: () -> Void var body: some View { GeometryReader { geo in @@ -30,10 +32,34 @@ struct MulticamView: View { focusedViewfinder stripOverlay(dock: dock) + + shutterOverlay(dock: dock) } } } + /// The all-camera shutter, docked on the same edge the 1:1 monitor uses so + /// the two screens feel of a piece. Reuses the monitor's `ShutterButton` + /// (and its activity ring) for a consistent feel. + @ViewBuilder + private func shutterOverlay(dock: MonitorChromeDock) -> some View { + let shutter = ShutterButton( + uiState: .photoMode, + isRecording: false, + activity: viewModel.isCapturing ? .capturing : nil, + isEnabled: !viewModel.isCapturing && viewModel.focusedLane != nil, + action: onCapture) + + switch dock { + case .bottom: + VStack { Spacer(); shutter.padding(.bottom, 24) } + case .leading: + HStack { shutter.padding(.leading, 24); Spacer() } + case .trailing: + HStack { Spacer(); shutter.padding(.trailing, 24) } + } + } + private var chromeInput: MonitorChromeInput { #if targetEnvironment(macCatalyst) return .pointer @@ -119,6 +145,20 @@ struct CameraTileView: View { .foregroundColor(.white)) } + if let outcome = lane.captureOutcome { + VStack { + HStack { + Spacer() + Image(systemName: outcome == .captured + ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .font(.body) + .foregroundColor(outcome == .captured ? .green : .yellow) + .padding(6) + } + Spacer() + } + } + VStack { Spacer() Text(lane.displayName) diff --git a/RemoteCam/MulticamViewController.swift b/RemoteCam/MulticamViewController.swift index 0dd11c1b..475feaed 100644 --- a/RemoteCam/MulticamViewController.swift +++ b/RemoteCam/MulticamViewController.swift @@ -44,6 +44,10 @@ public final class MulticamViewController: UIViewController { onFocusLane: { [weak self] lane in guard let self else { return } Task { await self.controller.setFocusedPeer(lane.peerID) } + }, + onCapture: { [weak self] in + guard let self else { return } + Task { await self.controller.capturePhoto() } }) hosting = embedSwiftUIView(multicamView) @@ -106,6 +110,10 @@ extension MulticamViewController: MulticamDisplay { for lane in created { wire(lane) } } + func applyCaptureInFlight(_ inFlight: Bool) { + viewModel.isCapturing = inFlight + } + func receiveFrame(_ frame: RemoteCmd.OnFrame) { // Route to exactly the source lane's decoder; a frame for camera B // never touches camera A's tile. diff --git a/RemoteCam/MulticamViewModel.swift b/RemoteCam/MulticamViewModel.swift index 799056d6..b8228b8e 100644 --- a/RemoteCam/MulticamViewModel.swift +++ b/RemoteCam/MulticamViewModel.swift @@ -25,6 +25,8 @@ final class CameraLane: ObservableObject, Identifiable { @Published var status: CameraLink.Status @Published var isFocused: Bool + /// How this camera answered the last synced capture — a brief tile badge. + @Published var captureOutcome: CaptureOutcome? /// This lane's own decoder + stall watchdog. The view controller wires its /// `onImage` to set `frames.cameraImage`, and its stall/keyframe callbacks @@ -36,6 +38,7 @@ final class CameraLane: ObservableObject, Identifiable { self.displayName = info.displayName self.status = info.status self.isFocused = info.isFocused + self.captureOutcome = info.captureOutcome } } @@ -44,6 +47,8 @@ final class CameraLane: ObservableObject, Identifiable { final class MulticamViewModel: ObservableObject { @Published private(set) var lanes: [CameraLane] = [] @Published var interfaceOrientation: UIInterfaceOrientation = .portrait + /// A synced photo is in flight — drives the shutter's activity ring. + @Published var isCapturing: Bool = false var focusedLane: CameraLane? { lanes.first { $0.isFocused } } var otherLanes: [CameraLane] { lanes.filter { !$0.isFocused } } @@ -63,6 +68,7 @@ final class MulticamViewModel: ObservableObject { if let lane = existing.removeValue(forKey: info.peerID) { if lane.status != info.status { lane.status = info.status } if lane.isFocused != info.isFocused { lane.isFocused = info.isFocused } + if lane.captureOutcome != info.captureOutcome { lane.captureOutcome = info.captureOutcome } return lane } let lane = CameraLane(info: info) diff --git a/RemoteCam/RemoteCmdFlatBuffers.swift b/RemoteCam/RemoteCmdFlatBuffers.swift index ed3636e0..b5a7b3af 100644 --- a/RemoteCam/RemoteCmdFlatBuffers.swift +++ b/RemoteCam/RemoteCmdFlatBuffers.swift @@ -31,6 +31,8 @@ func serializeToFlatBuffer(_ msg: Message) -> Data? { case let m as RemoteCmd.RequestKeyframe: return m.toFlatBuffer() case let m as RemoteCmd.ClockSyncPing: return m.toFlatBuffer() case let m as RemoteCmd.ClockSyncPong: return m.toFlatBuffer() + case let m as RemoteCmd.ScheduledCapture: return m.toFlatBuffer() + case let m as RemoteCmd.ScheduledCaptureAck: 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() @@ -765,6 +767,37 @@ extension RemoteCmd.ClockSyncPong { } } +extension RemoteCmd.ScheduledCapture { + func toFlatBuffer() -> Data { + var fbb = FlatBufferBuilder() + let captureIdOffset = fbb.create(string: captureId) + let sessionIdOffset = fbb.create(string: sessionId) + let params = RemoteShutter_CommandParameters.createCommandParameters( + &fbb, + captureFireAtCameraClockMs: fireAtCameraClockMillis, + captureAnchorMs: anchorMillis, + captureIdOffset: captureIdOffset, + captureSessionIdOffset: sessionIdOffset, + captureCameraIndex: Int32(cameraIndex)) + return buildCommand(&fbb, action: .scheduledcapture, parameters: params) + } +} + +extension RemoteCmd.ScheduledCaptureAck { + func toFlatBuffer() -> Data { + var fbb = FlatBufferBuilder() + let echoOffset = fbb.create(string: captureId) + let errorOffset = (error as NSError?).map { fbb.create(string: $0.localizedDescription) } ?? Offset() + let resp = RemoteShutter_CameraStateResponse.createCameraStateResponse( + &fbb, + action: .scheduledcapture, + success: error == nil, + errorOffset: errorOffset, + captureIdEchoOffset: echoOffset) + return buildResponse(&fbb, action: .scheduledcapture, response: resp) + } +} + extension RemoteCmd.ToggleFlash { func toFlatBuffer() -> Data { var fbb = FlatBufferBuilder() @@ -1128,6 +1161,14 @@ extension RemoteCmd { case .clocksyncping: return ClockSyncPing(t0Millis: params?.clockSyncT0Ms ?? 0) + case .scheduledcapture: + return ScheduledCapture( + fireAtCameraClockMillis: params?.captureFireAtCameraClockMs ?? 0, + anchorMillis: params?.captureAnchorMs ?? 0, + captureId: params?.captureId ?? "", + sessionId: params?.captureSessionId ?? "", + cameraIndex: Int(params?.captureCameraIndex ?? 0)) + case .toggleflash: return ToggleFlash() @@ -1192,6 +1233,9 @@ extension RemoteCmd { return ClockSyncPong(echoT0Millis: resp.clockSyncEchoT0Ms, cameraClockMillis: resp.clockSyncCameraClockMs) + case .scheduledcapture: + return ScheduledCaptureAck(captureId: resp.captureIdEcho ?? "", error: nsError) + case .startrecording: let startTime: Date? = resp.recordingStartTime > 0 ? Date(timeIntervalSince1970: Double(resp.recordingStartTime) / 1000.0) : nil return StartRecordingVideoAck(sender: nil, recordingStartTime: startTime, error: nsError) diff --git a/RemoteCam/RemoteCmds.swift b/RemoteCam/RemoteCmds.swift index 4ff87284..d91c6766 100644 --- a/RemoteCam/RemoteCmds.swift +++ b/RemoteCam/RemoteCmds.swift @@ -220,6 +220,50 @@ public class RemoteCmd: Message, @unchecked Sendable { } } + /// Director → camera: fire the shutter at `fireAtCameraClockMillis`, a + /// wall-clock instant already translated into this camera's own + /// `SyncClock` domain (the director applied the per-camera offset), so all + /// cameras expose together. `anchorMillis` is the same instant in the + /// director's clock — identical across every camera in the shot, so it is + /// the alignment key each clip is stamped with. Only sent to peers that + /// advertised `supportsMulticam`. + public class ScheduledCapture: Message, @unchecked Sendable { + public let fireAtCameraClockMillis: UInt64 + public let anchorMillis: UInt64 + public let captureId: String + public let sessionId: String + public let cameraIndex: Int + + public init(fireAtCameraClockMillis: UInt64, + anchorMillis: UInt64, + captureId: String, + sessionId: String, + cameraIndex: Int, + sender: AnyObject? = nil) { + self.fireAtCameraClockMillis = fireAtCameraClockMillis + self.anchorMillis = anchorMillis + self.captureId = captureId + self.sessionId = sessionId + self.cameraIndex = cameraIndex + super.init(sender: sender) + } + } + + /// Camera → director: the scheduled capture was accepted (or refused). Sent + /// immediately on receipt — before the shutter actually fires — so the + /// director can aggregate acks without waiting for N photos. `error` set = + /// the camera could not schedule it (wrong state, fire time long past). + public class ScheduledCaptureAck: Message, @unchecked Sendable { + public let captureId: String + public let error: Error? + + public init(captureId: String, error: Error? = nil, sender: AnyObject? = nil) { + self.captureId = captureId + self.error = error + super.init(sender: sender) + } + } + public class OnFrame: Message, @unchecked Sendable { public let data: Data public let peerId: MCPeerID diff --git a/RemoteCam/SessionCoordinator.swift b/RemoteCam/SessionCoordinator.swift index d93a1b49..ca807bee 100644 --- a/RemoteCam/SessionCoordinator.swift +++ b/RemoteCam/SessionCoordinator.swift @@ -126,6 +126,16 @@ final class DeferredPopAndScan: Message, @unchecked Sendable { /// Incompatibility detected by the transport (routed through the inbox — the /// old code mutated state directly on the delegate thread). final class IncompatibilityDetected: Message, @unchecked Sendable {} +/// Camera side: a scheduled multicam capture's fire time has arrived. Enqueued +/// by the off-actor delay task so the shutter is pulled by the message pump, +/// in order with every other state transition — never racing it. +final class FireScheduledCapture: Message, @unchecked Sendable { + let metadata: CaptureSyncMetadata + init(metadata: CaptureSyncMetadata) { + self.metadata = metadata + super.init(sender: nil) + } +} /// Retry tick for the capabilities ladder. final class RetryCapabilities: Message, @unchecked Sendable { let attempt: Int @@ -222,6 +232,18 @@ public actor SessionCoordinator { /// camera peer speaks VP9, which gates sending `RemoteCmd.RequestKeyframe`. private var monitorReceivedVP9Frame = false + /// Camera side: the sync metadata for a scheduled multicam capture that is + /// about to fire. Set when `FireScheduledCapture` triggers the shutter and + /// consumed by the next `OnPicture`, so exactly that photo is stamped and + /// saved under its `RS___cam` filename. Nil for ordinary + /// single-camera captures, which save exactly as before. + private var pendingSyncMetadata: CaptureSyncMetadata? + + /// How far in the past a scheduled fire time may be before the camera + /// refuses it (clock error and one retransmit can nudge it slightly late; + /// beyond this it is stale and would fire out of sync). + private let scheduledCaptureMaxLatenessMillis: Int64 = 1000 + /// Multicam director "collecting" mode. Off by default and only ever set /// by the scanner when `ENABLE_MULTICAM` and the monitor role, so every /// non-multicam path is byte-identical. While set, a peer connecting in @@ -971,6 +993,22 @@ public actor SessionCoordinator { await showCameraAlert(NSLocalizedString("Taking picture", comment: "")) await transition(to: .cameraTakingPic(sendMediaToPeer: pic.sendMediaToPeer, generation: generation)) + case let scheduled as RemoteCmd.ScheduledCapture: + await handleScheduledCapture(scheduled) + + case let fire as FireScheduledCapture: + // The fire instant arrived (enqueued by the off-actor delay task). + // Pull the shutter through the normal photo path, saving locally + // only — the director does not collect stills in v1 — but stamp + // this photo with its sync metadata on the way to Photos. + pendingSyncMetadata = fire.metadata + ctrl.currentCameraMode = .Photo + ctrl.updateCameraStatus() + ctrl.takePicture(false) + let generation = scheduleTimeout(.cameraTakingPic) + await showCameraAlert(NSLocalizedString("Taking picture", comment: "")) + await transition(to: .cameraTakingPic(sendMediaToPeer: false, generation: generation)) + case is RemoteCmd.ToggleCamera: do { _ = try await ctrl.toggleCamera() @@ -1173,6 +1211,41 @@ public actor SessionCoordinator { sendMessage(RemoteCmd.RequestKeyframe(sender: nil), mode: .reliable) } + /// Camera side of a synced multicam shot. Acks (or nacks) immediately so + /// the director can aggregate without waiting for the photo, then schedules + /// the shutter for the fire instant. The delay runs off the actor and only + /// enqueues `FireScheduledCapture`, so the capture is pulled by the message + /// pump in order — never racing a state transition. + private func handleScheduledCapture(_ scheduled: RemoteCmd.ScheduledCapture) async { + let now = SyncClock.nowMillis() + let lateness = Int64(now) - Int64(scheduled.fireAtCameraClockMillis) + guard lateness <= scheduledCaptureMaxLatenessMillis else { + await sendOrGoToScanning(RemoteCmd.ScheduledCaptureAck( + captureId: scheduled.captureId, + error: NSError(domain: "Scheduled capture fire time already passed", + code: 0, userInfo: nil))) + return + } + await sendOrGoToScanning(RemoteCmd.ScheduledCaptureAck(captureId: scheduled.captureId)) + + let metadata = CaptureSyncMetadata( + sessionID: scheduled.sessionId, + captureID: scheduled.captureId, + cameraIndex: scheduled.cameraIndex, + anchorMillis: scheduled.anchorMillis, + // The camera does not compute its own clock offset — the director + // did, when it translated the fire time into this clock. The + // shared `anchorMillis` is the alignment key; these are diagnostics. + clockOffsetMillis: 0, + roundTripMillis: 0) + + let delayMillis = max(0, -lateness) + Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delayMillis) * 1_000_000) + self?.tell(FireScheduledCapture(metadata: metadata)) + } + } + private func inCameraTakingPic(_ msg: Message, sendMediaToPeer: Bool, generation: Int) async { switch msg { case is RemoteCmd.RequestKeyframe: @@ -1183,6 +1256,7 @@ public actor SessionCoordinator { case let timeout as UICmd.StateTimeout: guard timeout.stateName == .cameraTakingPic && timeout.generation == generation else { break } + pendingSyncMetadata = nil await dismissCameraAlert() let sent = sendMessage(RemoteCmd.TakePicResp( sender: nil, @@ -1195,8 +1269,16 @@ public actor SessionCoordinator { case let picture as UICmd.OnPicture: if let pic = picture.pic { - photoLibrarySaver(pic) + // A scheduled multicam capture stamps its sync metadata and + // saves under the shared RS___cam name; an + // ordinary capture saves exactly as before. + if let metadata = pendingSyncMetadata { + saveSyncedPictureToLibrary(pic, metadata: metadata) + } else { + photoLibrarySaver(pic) + } } + pendingSyncMetadata = nil await dismissCameraAlert() guard sendMessage(RemoteCmd.TakePicAck(sender: nil)) else { await popToScanning() @@ -1498,6 +1580,11 @@ public actor SessionCoordinator { case is RemoteCmd.TakePic: await sendOrGoToScanning(RemoteCmd.TakePicResp(sender: nil, error: unableToProcessError(msg))) + case let scheduled as RemoteCmd.ScheduledCapture: + // Not in a state that can take a picture — nack so the director's + // ack aggregation completes instead of timing out. + await sendOrGoToScanning(RemoteCmd.ScheduledCaptureAck( + captureId: scheduled.captureId, error: unableToProcessError(msg))) case is RemoteCmd.ToggleCamera: await sendOrGoToScanning(RemoteCmd.ToggleCameraResp(cameraCapabilities: nil, error: unableToProcessError(msg))) case is RemoteCmd.SelectCameraDevice: @@ -2437,6 +2524,36 @@ public actor SessionCoordinator { } } + /// Save a synced multicam still: the image is stamped with the shot's sync + /// fields (EXIF) and lands under the shared `RS___cam` name, + /// so any editor can group and align the angles. Full-res stays on this + /// camera — the director does not collect stills in v1. + private nonisolated func saveSyncedPictureToLibrary(_ data: Data, + metadata: CaptureSyncMetadata) { + let stamped = metadata.stamped(data) + // HEIC magic: bytes 4..12 are "ftyp" then a heic/heix/mif1 brand. + let isHEIC = stamped.count > 12 + && stamped[4] == 0x66 && stamped[5] == 0x74 + && stamped[6] == 0x79 && stamped[7] == 0x70 + PHPhotoLibrary.requestAuthorization { status in + guard status == .authorized else { + DispatchQueue.main.async { + showPhotosAccessDeniedModal(for: .photo) + } + return + } + PHPhotoLibrary.shared().performChanges({ + let options = PHAssetResourceCreationOptions() + options.originalFilename = metadata.photoFilename(isHEIC: isHEIC) + PHAssetCreationRequest.forAsset() + .addResource(with: .photo, data: stamped, options: options) + }) { (success: Bool, _: Error?) in + print(success ? "Saved synced photo \(metadata.filenamePrefix)" + : "Failed to save synced photo!") + } + } + } + /// Monitor-side picture save (with the App Store review prompt). private nonisolated func savePictureOnMonitor(_ imageData: Data) { PHPhotoLibrary.requestAuthorization { status in diff --git a/RemoteCamTests/MulticamControllerTests.swift b/RemoteCamTests/MulticamControllerTests.swift index 9a1ab656..bdd03ce7 100644 --- a/RemoteCamTests/MulticamControllerTests.swift +++ b/RemoteCamTests/MulticamControllerTests.swift @@ -13,9 +13,11 @@ import XCTest private final class FakeMulticamDisplay: MulticamDisplay, @unchecked Sendable { var lastLanes: [MulticamLaneInfo] = [] var receivedFrames: [MCPeerID] = [] + var captureInFlight = false var didExit = false func applyLanes(_ lanes: [MulticamLaneInfo]) { lastLanes = lanes } + func applyCaptureInFlight(_ inFlight: Bool) { captureInFlight = inFlight } func receiveFrame(_ frame: RemoteCmd.OnFrame) { receivedFrames.append(frame.peerId) } func exitMulticam() { didExit = true } } @@ -161,6 +163,116 @@ final class MulticamControllerTests: XCTestCase { XCTAssertNil(offsetB) } + // MARK: - Synced photo capture + + func testScheduledCaptureAppliesPerLaneOffsets() async { + let (controller, transport, _) = await makeController(peers: [camA, camB]) + await controller.seedLaneForTesting(camA, supportsMulticam: true, offsetMillis: 100) + await controller.seedLaneForTesting(camB, supportsMulticam: true, offsetMillis: -50) + transport.sentMessages.removeAll() + + await controller.capturePhoto() + await controller.waitForIdle() + + let scheduled = sent(transport, RemoteCmd.ScheduledCapture.self) + XCTAssertEqual(scheduled.count, 2) + for s in scheduled { XCTAssertEqual(s.peers.count, 1) } + + func fireAt(_ peer: MCPeerID) -> UInt64? { + (scheduled.first { $0.peers == [peer] }?.msg as? RemoteCmd.ScheduledCapture)? + .fireAtCameraClockMillis + } + // Same shared anchor + each lane's own offset ⇒ the fire instants differ + // by exactly the offset difference (100 − (−50) = 150), regardless of + // the nondeterministic base. + let a = fireAt(camA), b = fireAt(camB) + XCTAssertNotNil(a); XCTAssertNotNil(b) + XCTAssertEqual(Int64(a!) - Int64(b!), 150) + + // One shared capture id across both cameras. + let ids = Set(scheduled.compactMap { ($0.msg as? RemoteCmd.ScheduledCapture)?.captureId }) + XCTAssertEqual(ids.count, 1) + } + + func testNonMulticamLaneIsExcludedFromCapture() async { + let (controller, transport, _) = await makeController(peers: [camA, camB]) + await controller.seedLaneForTesting(camA, supportsMulticam: true, offsetMillis: 10) + await controller.seedLaneForTesting(camB, supportsMulticam: false, offsetMillis: 10) + transport.sentMessages.removeAll() + + await controller.capturePhoto() + await controller.waitForIdle() + + let scheduled = sent(transport, RemoteCmd.ScheduledCapture.self) + XCTAssertEqual(scheduled.map(\.peers), [[camA]]) + let capture = await controller.captureStateForTesting() + XCTAssertEqual(capture?.remaining, 1) + } + + func testAckAggregationReturnsToMonitoring() async { + let (controller, _, _) = await makeController(peers: [camA, camB]) + await controller.seedLaneForTesting(camA, supportsMulticam: true, offsetMillis: 0) + await controller.seedLaneForTesting(camB, supportsMulticam: true, offsetMillis: 0) + + await controller.capturePhoto() + await controller.waitForIdle() + let captureID = await controller.captureStateForTesting()?.id + XCTAssertNotNil(captureID) + + controller.didReceiveMessage(RemoteCmd.ScheduledCaptureAck(captureId: captureID!), from: camA) + await controller.waitForIdle() + let afterFirst = await controller.captureStateForTesting() + XCTAssertEqual(afterFirst?.remaining, 1) + + controller.didReceiveMessage(RemoteCmd.ScheduledCaptureAck(captureId: captureID!), from: camB) + await controller.waitForIdle() + let afterSecond = await controller.captureStateForTesting() + let outA = await controller.captureOutcomeForTesting(camA) + let outB = await controller.captureOutcomeForTesting(camB) + XCTAssertNil(afterSecond, "back to monitoring") + XCTAssertEqual(outA, .captured) + XCTAssertEqual(outB, .captured) + } + + func testCaptureTimeoutMarksTheSilentLaneAndCompletes() async { + let (controller, _, _) = await makeController(peers: [camA, camB]) + await controller.setCaptureAckTimeout(0.1) + await controller.seedLaneForTesting(camA, supportsMulticam: true, offsetMillis: 0) + await controller.seedLaneForTesting(camB, supportsMulticam: true, offsetMillis: 0) + + await controller.capturePhoto() + await controller.waitForIdle() + let captureID = await controller.captureStateForTesting()?.id + controller.didReceiveMessage(RemoteCmd.ScheduledCaptureAck(captureId: captureID!), from: camA) + await controller.waitForIdle() + + // Wait out the ack timeout; camB never answered. + try? await Task.sleep(nanoseconds: 300_000_000) + let resolved = await controller.captureStateForTesting() + let outA = await controller.captureOutcomeForTesting(camA) + let outB = await controller.captureOutcomeForTesting(camB) + XCTAssertNil(resolved, "aggregate resolves") + XCTAssertEqual(outA, .captured) + XCTAssertEqual(outB, .failed) + } + + func testFallbackToPlainTakePicWhenAnOffsetIsMissing() async { + let (controller, transport, _) = await makeController(peers: [camA, camB]) + await controller.seedLaneForTesting(camA, supportsMulticam: true, offsetMillis: 20) + await controller.seedLaneForTesting(camB, supportsMulticam: true, offsetMillis: nil) + transport.sentMessages.removeAll() + + await controller.capturePhoto() + await controller.waitForIdle() + + XCTAssertTrue(sent(transport, RemoteCmd.ScheduledCapture.self).isEmpty, + "a missing offset forces the plain fan-out") + let takes = sent(transport, RemoteCmd.TakePic.self) + XCTAssertEqual(Set(takes.flatMap(\.peers)), [camA, camB]) + let fallbackState = await controller.captureStateForTesting() + XCTAssertEqual(fallbackState?.remaining, 2) + } + // MARK: - Removal func testRemoveCameraDropsTheLaneAndRefocuses() async { diff --git a/RemoteCamTests/MulticamViewModelTests.swift b/RemoteCamTests/MulticamViewModelTests.swift index a97ce3fb..1edab499 100644 --- a/RemoteCamTests/MulticamViewModelTests.swift +++ b/RemoteCamTests/MulticamViewModelTests.swift @@ -17,7 +17,8 @@ final class MulticamViewModelTests: XCTestCase { private func info(_ peer: MCPeerID, status: CameraLink.Status = .linked, focused: Bool = false) -> MulticamLaneInfo { MulticamLaneInfo(peerID: peer, displayName: peer.displayName, - status: status, isFocused: focused, clockOffsetMillis: nil) + status: status, isFocused: focused, clockOffsetMillis: nil, + captureOutcome: nil) } func testApplyAddsLanesAndReportsCreated() { diff --git a/RemoteCamTests/RemoteCamSessionTests.swift b/RemoteCamTests/RemoteCamSessionTests.swift index a3359976..05039e51 100644 --- a/RemoteCamTests/RemoteCamSessionTests.swift +++ b/RemoteCamTests/RemoteCamSessionTests.swift @@ -302,6 +302,47 @@ class SessionCoordinatorTests: XCTestCase { (pongs[0].msg as? RemoteCmd.ClockSyncPong)?.cameraClockMillis ?? 0, 0) } + // MARK: - Scheduled (multicam) capture, camera side + + func testScheduledCaptureInThePastNacks() async { + await enterCamera() + harness.fakeMP.sendResult = true + + await harness.deliver(RemoteCmd.ScheduledCapture( + fireAtCameraClockMillis: 1, // long past + anchorMillis: 1, captureId: "CAP-1", sessionId: "S", cameraIndex: 1)) + + let acks = sent(RemoteCmd.ScheduledCaptureAck.self) + .compactMap { $0.msg as? RemoteCmd.ScheduledCaptureAck } + XCTAssertEqual(acks.count, 1) + XCTAssertEqual(acks[0].captureId, "CAP-1") + XCTAssertNotNil(acks[0].error, "a fire time in the past is refused") + XCTAssertTrue(camera.takePictureCalls.isEmpty, "no shutter on a nack") + } + + func testValidScheduledCaptureAcksImmediatelyAndFires() async { + await enterCamera() + harness.fakeMP.sendResult = true + + // Fire ~now: acked immediately, then the shutter pulls a moment later. + await harness.deliver(RemoteCmd.ScheduledCapture( + fireAtCameraClockMillis: SyncClock.nowMillis(), + anchorMillis: SyncClock.nowMillis(), captureId: "CAP-2", + sessionId: "S", cameraIndex: 2)) + + let acks = sent(RemoteCmd.ScheduledCaptureAck.self) + .compactMap { $0.msg as? RemoteCmd.ScheduledCaptureAck } + XCTAssertEqual(acks.map(\.captureId), ["CAP-2"]) + XCTAssertNil(acks[0].error, "accepted") + + // The fire is enqueued by an off-actor delay task; wait for it. + for _ in 0..<200 where camera.takePictureCalls.isEmpty { + try? await Task.sleep(nanoseconds: 10_000_000) + } + XCTAssertEqual(camera.takePictureCalls, [false], + "the scheduled shutter fires, saving locally (not to the director)") + } + func testMonitorPhotoModeUnbecomeMonitorPopsToConnected() async { await enterMonitor(.Photo) await harness.deliver(UICmd.UnbecomeMonitor(sender: nil)) diff --git a/RemoteCamTests/RemoteCmdSerializationTests.swift b/RemoteCamTests/RemoteCmdSerializationTests.swift index b5b95859..dafc51e1 100644 --- a/RemoteCamTests/RemoteCmdSerializationTests.swift +++ b/RemoteCamTests/RemoteCmdSerializationTests.swift @@ -49,6 +49,8 @@ final class RemoteCmdSerializationTests: XCTestCase { case let m as RemoteCmd.RequestKeyframe: return m.toFlatBuffer() case let m as RemoteCmd.ClockSyncPing: return m.toFlatBuffer() case let m as RemoteCmd.ClockSyncPong: return m.toFlatBuffer() + case let m as RemoteCmd.ScheduledCapture: return m.toFlatBuffer() + case let m as RemoteCmd.ScheduledCaptureAck: 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() @@ -1197,6 +1199,32 @@ extension RemoteCmdSerializationTests { XCTAssertEqual(result.cameraClockMillis, 123_456_789_345) } + func testScheduledCapture_roundTrip() { + let result = roundTrip(RemoteCmd.ScheduledCapture( + fireAtCameraClockMillis: 1_754_800_000_123, + anchorMillis: 1_754_800_000_000, + captureId: "CAP-123", + sessionId: "SESS-9", + cameraIndex: 3)) + XCTAssertEqual(result.fireAtCameraClockMillis, 1_754_800_000_123) + XCTAssertEqual(result.anchorMillis, 1_754_800_000_000) + XCTAssertEqual(result.captureId, "CAP-123") + XCTAssertEqual(result.sessionId, "SESS-9") + XCTAssertEqual(result.cameraIndex, 3) + } + + func testScheduledCaptureAck_roundTrip() { + let ok = roundTrip(RemoteCmd.ScheduledCaptureAck(captureId: "CAP-42")) + XCTAssertEqual(ok.captureId, "CAP-42") + XCTAssertNil(ok.error) + + let nack = roundTrip(RemoteCmd.ScheduledCaptureAck( + captureId: "CAP-43", + error: NSError(domain: "too late", code: 0))) + XCTAssertEqual(nack.captureId, "CAP-43") + XCTAssertNotNil(nack.error) + } + /// The multicam capability survives the wire, and a peer that predates it /// (absent field) decodes as not-multicam-capable. func testCapabilitiesCarryMulticamSupport() { From 43570d818aa91e645043067044b89119be80e496 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Mon, 10 Aug 2026 23:53:37 -0700 Subject: [PATCH 06/17] Multicam PR5: synced video + resilient camera (+ EXIF wall-clock fix) Fix (from PR4 review): CaptureSyncMetadata.stamped derived EXIF DateTimeOriginal from anchorMillis, which is monotonic SyncClock uptime, so photos got ~1970 dates. DateTimeOriginal/SubSec now come from the camera's wall clock at capture (capturedAt); anchorMillis stays an opaque alignment key inside the UserComment JSON (and the QuickTime keys for video). Wire (additive): ScheduledStartRecording (27), ScheduledStopRecording (28), reusing the PR4 capture params; ScheduledRecordingAck echoes the capture id and carries isStop so the director routes start vs stop acks. Both dispatch switches + round-trips. Camera side (single-cam untouched): scheduled start/stop fire at their instants via the same off-actor-delay -> pump-message pattern as PR4 (no actor/sleep race). The recording pipeline runs as today (local save, no auto-transfer in multicam) plus QuickTime metadata items on the AVAssetWriter and an RS___cam.mov filename. Resilient camera (gated hard on inMulticamSession): the latch is set only by an incoming scheduled multicam command and cleared on session teardown, so with ENABLE_MULTICAM off no camera advertises multicam, no director sends scheduled commands, and the latch never sets -> single-cam byte-identical. When set, a mid-recording director drop keeps the clip rolling (the rest of the rig is still recording) and enters the reconnect path instead of stopping. Proven both ways. Director side (MulticamController): startRecording/stopRecording with the same per-lane offset scheduling; aggregate recording/stoppingRecording states; per-lane REC badge; shutter gains a photo/video mode toggle and a record/stop button reusing the 1:1 ShutterButton. Tests: serialization round-trips; per-lane offsets give distinct start AND stop fire times with matching clip lengths; start acks mark lanes recording and stop returns to monitoring; multicam disconnect keeps recording, single-cam still stops; scheduled recording stamps metadata; EXIF date is wall-clock not the anchor. Full suite green (686 tests). Co-Authored-By: Claude Fable 5 --- RemoteCam/CameraControlling.swift | 5 + RemoteCam/CameraLink.swift | 4 + RemoteCam/CameraRig.swift | 4 + RemoteCam/CaptureSyncMetadata.swift | 24 +- RemoteCam/FlatBufferSchemas.fbs | 7 +- RemoteCam/FlatBufferSchemas_generated.swift | 4 +- RemoteCam/MulticamController.swift | 242 ++++++++++++++---- RemoteCam/MulticamView.swift | 62 ++++- RemoteCam/MulticamViewController.swift | 26 +- RemoteCam/MulticamViewModel.swift | 8 + RemoteCam/RecordingPipeline.swift | 17 ++ RemoteCam/RemoteCmdFlatBuffers.swift | 73 ++++++ RemoteCam/RemoteCmds.swift | 60 +++++ RemoteCam/SessionCoordinator.swift | 114 ++++++++- RemoteCamTests/CaptureSyncMetadataTests.swift | 45 ++++ RemoteCamTests/MulticamControllerTests.swift | 85 +++++- RemoteCamTests/MulticamViewModelTests.swift | 2 +- RemoteCamTests/RemoteCamSessionTests.swift | 87 +++++++ .../RemoteCmdSerializationTests.swift | 36 +++ RemoteCamTests/SessionTestSupport.swift | 2 + 20 files changed, 818 insertions(+), 89 deletions(-) diff --git a/RemoteCam/CameraControlling.swift b/RemoteCam/CameraControlling.swift index 9bc9da61..bf64b61c 100644 --- a/RemoteCam/CameraControlling.swift +++ b/RemoteCam/CameraControlling.swift @@ -32,6 +32,11 @@ protocol CameraControlling: AnyObject, Sendable { func takePicture(_ sendMediaToRemote: Bool) func startRecordingVideo() func stopRecordingVideo(_ shouldSendVideo: Bool) + /// Multicam only: the sync metadata for the recording about to start, so + /// the pipeline stamps the .mov and names it under the shared RS_ group. + /// Cleared (nil) for ordinary single-camera recording — never called + /// there, so that path is byte-identical. + func setVideoSyncMetadata(_ metadata: CaptureSyncMetadata?) func setZoom(zoomFactor: CGFloat) async throws -> (CGFloat, CameraLensType, RemoteCmd.ZoomRange) /// Sets the focus/exposure point of interest from a monitor tap. `x`/`y` are diff --git a/RemoteCam/CameraLink.swift b/RemoteCam/CameraLink.swift index 572088ab..e5212ffe 100644 --- a/RemoteCam/CameraLink.swift +++ b/RemoteCam/CameraLink.swift @@ -55,6 +55,10 @@ final class CameraLink { /// first). Drives the tile's captured/failed badge. var captureOutcome: CaptureOutcome? + /// This camera is rolling as part of a synced recording — drives the tile's + /// REC badge. + var isRecording = false + /// This camera's own preview decoder + stall watchdog. Frames tagged with /// this peer's id are fed here; its `onImage` drives exactly this lane's /// tile, so a frame from another camera never touches it. diff --git a/RemoteCam/CameraRig.swift b/RemoteCam/CameraRig.swift index 9e117ee5..d9105a98 100644 --- a/RemoteCam/CameraRig.swift +++ b/RemoteCam/CameraRig.swift @@ -549,6 +549,10 @@ extension CameraRig: CameraControlling { pipeline.stopRecording(shouldSendVideo) } + func setVideoSyncMetadata(_ metadata: CaptureSyncMetadata?) { + pipeline.pendingSyncMetadata = metadata + } + func updateTimerCountdown(value: Int) { OperationQueue.main.addOperation { if value > 0 { diff --git a/RemoteCam/CaptureSyncMetadata.swift b/RemoteCam/CaptureSyncMetadata.swift index 128456e3..fc070342 100644 --- a/RemoteCam/CaptureSyncMetadata.swift +++ b/RemoteCam/CaptureSyncMetadata.swift @@ -76,12 +76,14 @@ struct CaptureSyncMetadata: Codable, Equatable { } /// Re-encode `imageData` with this shot's sync fields embedded in EXIF, so - /// the alignment travels inside the photo itself (survives export/AirDrop): - /// the JSON in `UserComment`, and the anchor instant in - /// `DateTimeOriginal`/`SubSecTimeOriginal`. Format (JPEG/HEIC) is preserved. - /// Returns the original data unchanged if re-encoding isn't possible, so a - /// stamping failure never costs the user the photo. - func stamped(_ imageData: Data) -> Data { + /// the alignment travels inside the photo itself (survives export/AirDrop). + /// The alignment key (`anchorMillis`) rides in `UserComment` as opaque JSON; + /// `DateTimeOriginal`/`SubSecTimeOriginal` are the camera's **wall clock at + /// capture** (`capturedAt`) — EXIF's meaning is "when the photo was taken", + /// and `anchorMillis` is monotonic uptime, not a wall-clock date. Format + /// (JPEG/HEIC) is preserved. Returns the original data unchanged if + /// re-encoding isn't possible, so a stamping failure never costs the photo. + func stamped(_ imageData: Data, capturedAt: Date = Date()) -> Data { guard let source = CGImageSourceCreateWithData(imageData as CFData, nil), let type = CGImageSourceGetType(source) else { return imageData } @@ -92,10 +94,9 @@ struct CaptureSyncMetadata: Codable, Equatable { if let json = jsonString() { exif[kCGImagePropertyExifUserComment] = json } - let anchorSeconds = Double(anchorMillis) / 1000.0 - let date = Date(timeIntervalSince1970: anchorSeconds) - exif[kCGImagePropertyExifDateTimeOriginal] = Self.exifDateFormatter.string(from: date) - exif[kCGImagePropertyExifSubsecTimeOriginal] = String(format: "%03d", anchorMillis % 1000) + exif[kCGImagePropertyExifDateTimeOriginal] = Self.exifDateFormatter.string(from: capturedAt) + let subsec = Int((capturedAt.timeIntervalSince1970.truncatingRemainder(dividingBy: 1)) * 1000) + exif[kCGImagePropertyExifSubsecTimeOriginal] = String(format: "%03d", subsec) properties[kCGImagePropertyExifDictionary] = exif let output = NSMutableData() @@ -112,6 +113,9 @@ struct CaptureSyncMetadata: Codable, Equatable { "\(filenamePrefix).\(isHEIC ? "heic" : "jpg")" } + /// A Photos `originalFilename` for this clip, e.g. `RS___cam2.mov`. + func videoFilename() -> String { "\(filenamePrefix).mov" } + /// EXIF wants `yyyy:MM:dd HH:mm:ss` in the local zone. private static let exifDateFormatter: DateFormatter = { let f = DateFormatter() diff --git a/RemoteCam/FlatBufferSchemas.fbs b/RemoteCam/FlatBufferSchemas.fbs index 5c826741..247db78e 100644 --- a/RemoteCam/FlatBufferSchemas.fbs +++ b/RemoteCam/FlatBufferSchemas.fbs @@ -44,12 +44,17 @@ enum CommandAction : byte { // director's send time; the camera answers with a // ClockSyncPing response echoing it plus its own clock. // Only sent to peers advertising supports_multicam. - ScheduledCapture = 26 // director -> camera: take a photo at a wall-clock + ScheduledCapture = 26, // director -> camera: take a photo at a wall-clock // instant expressed in the camera's own clock domain // (the director already applied the per-camera offset), // so N cameras fire together. Only sent to peers // advertising supports_multicam; the camera acks with a // CameraStateResponse echoing the capture id. + ScheduledStartRecording = 27, // director -> camera: start recording at the fire + // instant. Reuses the ScheduledCapture params; acks by + // echoing the capture id. Only sent to multicam peers. + ScheduledStopRecording = 28 // director -> camera: stop recording at the fire + // instant, so clip lengths match across the rig. } // Whether the camera device drives its own on-screen live preview. On is the diff --git a/RemoteCam/FlatBufferSchemas_generated.swift b/RemoteCam/FlatBufferSchemas_generated.swift index f8674ee4..011606d6 100644 --- a/RemoteCam/FlatBufferSchemas_generated.swift +++ b/RemoteCam/FlatBufferSchemas_generated.swift @@ -35,8 +35,10 @@ public enum RemoteShutter_CommandAction: Int8, Enum, Verifiable { case setcamerapreviewmode = 24 case clocksyncping = 25 case scheduledcapture = 26 + case scheduledstartrecording = 27 + case scheduledstoprecording = 28 - public static var max: RemoteShutter_CommandAction { return .scheduledcapture } + public static var max: RemoteShutter_CommandAction { return .scheduledstoprecording } public static var min: RemoteShutter_CommandAction { return .unknown } } diff --git a/RemoteCam/MulticamController.swift b/RemoteCam/MulticamController.swift index d717d06e..e01dd128 100644 --- a/RemoteCam/MulticamController.swift +++ b/RemoteCam/MulticamController.swift @@ -19,6 +19,12 @@ enum MulticamState: Equatable { /// A synced photo is in flight: `acksRemaining` cameras have yet to accept /// or refuse `captureId`. Returns to `.monitoring` when it reaches zero. case capturingPhoto(captureId: String, acksRemaining: Int) + /// The rig is recording (or still collecting start acks). `acksRemaining` + /// counts cameras that have yet to confirm the scheduled start. + case recording(captureId: String, acksRemaining: Int) + /// A synced stop is in flight; returns to `.monitoring` when every camera + /// has confirmed (or timed out). + case stoppingRecording(captureId: String, acksRemaining: Int) } /// How a camera answered the last synced capture, for the tile badge. @@ -40,6 +46,8 @@ struct MulticamLaneInfo: Equatable { let clockOffsetMillis: Int64? /// How this camera answered the last synced capture, for the tile badge. let captureOutcome: CaptureOutcome? + /// This camera is rolling as part of a synced recording (REC badge). + let isRecording: Bool } /// The main-actor bridge from the controller to the multicam screen — the @@ -49,8 +57,9 @@ struct MulticamLaneInfo: Equatable { /// camera B never re-renders camera A. protocol MulticamDisplay: AnyObject { func applyLanes(_ lanes: [MulticamLaneInfo]) - /// A synced photo is (or is no longer) in flight — drives the shutter ring. - func applyCaptureInFlight(_ inFlight: Bool) + /// Aggregate shutter state: `capturing` = a synced photo is in flight + /// (activity ring); `recording` = the rig is rolling (record/stop button). + func applyShutterState(capturing: Bool, recording: Bool) func receiveFrame(_ frame: RemoteCmd.OnFrame) func exitMulticam() } @@ -261,16 +270,23 @@ public actor MulticamController { } case let ack as RemoteCmd.ScheduledCaptureAck: - recordCaptureAck(from: peer, outcome: ack.error == nil ? .captured : .failed) + resolvePhotoAck(from: peer, success: ack.error == nil) case is RemoteCmd.TakePicAck: // The fallback (plain TakePic) path's positive ack. - recordCaptureAck(from: peer, outcome: .captured) + resolvePhotoAck(from: peer, success: true) case let resp as RemoteCmd.TakePicResp: // Fallback failure signal; a success here is a duplicate of the ack // and is ignored (the lane is already resolved). - if resp.error != nil { recordCaptureAck(from: peer, outcome: .failed) } + if resp.error != nil { resolvePhotoAck(from: peer, success: false) } + + case let ack as RemoteCmd.ScheduledRecordingAck: + resolveRecordingAck(from: peer, isStop: ack.isStop, success: ack.error == nil) + + case let ack as RemoteCmd.StartRecordingVideoAck: + // The fallback (plain StartRecordingVideo) path's positive ack. + if ack.error == nil { resolveRecordingAck(from: peer, isStop: false, success: true) } default: // Per-camera command responses (zoom/lens/flash/torch acks) update @@ -371,13 +387,22 @@ public actor MulticamController { // MARK: - Synced photo capture (all cameras) - /// Test seam. + /// Test seams. func setCaptureAckTimeout(_ t: TimeInterval) { captureAckTimeout = t } func captureStateForTesting() -> (id: String, remaining: Int)? { if case .capturingPhoto(let id, let remaining) = state { return (id, remaining) } return nil } + func recordingStateForTesting() -> (id: String, remaining: Int)? { + if case .recording(let id, let remaining) = state { return (id, remaining) } + return nil + } + func stoppingStateForTesting() -> (id: String, remaining: Int)? { + if case .stoppingRecording(let id, let remaining) = state { return (id, remaining) } + return nil + } func captureOutcomeForTesting(_ peer: MCPeerID) -> CaptureOutcome? { links[peer]?.captureOutcome } + func isRecordingForTesting(_ peer: MCPeerID) -> Bool { links[peer]?.isRecording ?? false } /// Test seam: put a lane in the state a real handshake would — linked, with /// known multicam capability and (optionally) a known clock offset — so @@ -396,81 +421,180 @@ public actor MulticamController { } } - /// Fire a synced photo on every ready multicam camera. When each camera's - /// clock offset is known, the shutter is scheduled at a shared instant - /// (`ScheduledCapture`) so the exposures line up sub-frame; if any offset - /// is still missing it falls back to a plain fan-out (`TakePic`) — the same - /// shot id groups both paths. No-op if nothing is ready or a capture is - /// already in flight. - func capturePhoto() { - guard case .monitoring = state else { return } - let ready = order.compactMap { links[$0] } - .filter { $0.status == .linked && $0.supportsMulticam } - guard !ready.isEmpty else { return } + /// The ready multicam cameras, in strip order. + private func readyMulticamLanes() -> [CameraLink] { + order.compactMap { links[$0] }.filter { $0.status == .linked && $0.supportsMulticam } + } + /// Send a scheduled command to each ready camera at a shared instant, each + /// translated into that camera's own clock by its offset. Returns the + /// captureID and the lanes addressed. `build` makes the per-lane message. + private func fanOutScheduled( + _ build: (_ fireAtCameraClock: UInt64, _ anchor: UInt64, _ captureID: String, + _ index: Int) -> Message, + fallback: (CameraLink) -> Message, + lanes: [CameraLink]) -> String { let captureID = UUID().uuidString - capturingLanes = Set(ready.map(\.peerID)) + capturingLanes = Set(lanes.map(\.peerID)) currentCaptureID = captureID - for link in ready { link.captureOutcome = nil } - let everyOffsetKnown = ready.allSatisfy { $0.latestOffset != nil } - if everyOffsetKnown { + if lanes.allSatisfy({ $0.latestOffset != nil }) { let fireAt = SyncClock.nowMillis() + captureLeadMillis - for (index, link) in ready.enumerated() { + for (index, link) in lanes.enumerated() { let offset = link.latestOffset?.offsetMillis ?? 0 let fireAtCameraClock = UInt64(Int64(fireAt) + offset) - sendTo(link.peerID, RemoteCmd.ScheduledCapture( - fireAtCameraClockMillis: fireAtCameraClock, - anchorMillis: fireAt, - captureId: captureID, - sessionId: sessionID, - cameraIndex: index + 1)) + sendTo(link.peerID, build(fireAtCameraClock, fireAt, captureID, index + 1)) } } else { - // Fallback: not every clock is measured yet, so we can't promise a - // sub-frame shutter — take the shot immediately on each camera. - for link in ready { - sendTo(link.peerID, RemoteCmd.TakePic(sender: nil, sendMediaToPeer: false)) - } + // A missing offset means we can't promise a sub-frame instant — fire + // now on each camera, under the same shot id. + for link in lanes { sendTo(link.peerID, fallback(link)) } } + return captureID + } + + /// Fire a synced photo on every ready multicam camera. Scheduled at a shared + /// instant when every clock offset is known; else a plain `TakePic` fan-out + /// under the same shot id. No-op unless idle with a ready camera. + func capturePhoto() { + guard case .monitoring = state else { return } + let ready = readyMulticamLanes() + guard !ready.isEmpty else { return } + for link in ready { link.captureOutcome = nil } + + let captureID = fanOutScheduled( + { fire, anchor, id, index in + RemoteCmd.ScheduledCapture(fireAtCameraClockMillis: fire, anchorMillis: anchor, + captureId: id, sessionId: self.sessionID, cameraIndex: index) + }, + fallback: { _ in RemoteCmd.TakePic(sender: nil, sendMediaToPeer: false) }, + lanes: ready) state = .capturingPhoto(captureId: captureID, acksRemaining: capturingLanes.count) publishLanes() - armCaptureTimeout(captureID) + armAckTimeout(captureID) + } + + /// Start a synced recording on every ready multicam camera. + func startRecording() { + guard case .monitoring = state else { return } + let ready = readyMulticamLanes() + guard !ready.isEmpty else { return } + for link in ready { link.captureOutcome = nil } + + let captureID = fanOutScheduled( + { fire, anchor, id, index in + RemoteCmd.ScheduledStartRecording(fireAtCameraClockMillis: fire, anchorMillis: anchor, + captureId: id, sessionId: self.sessionID, cameraIndex: index) + }, + fallback: { _ in RemoteCmd.StartRecordingVideo(sender: nil) }, + lanes: ready) + + state = .recording(captureId: captureID, acksRemaining: capturingLanes.count) + publishLanes() + armAckTimeout(captureID) + } + + /// Stop the synced recording on every rolling camera, anchored so the clips + /// end together. + func stopRecording() { + guard case .recording(_, _) = state else { return } + let rolling = order.compactMap { links[$0] }.filter { $0.isRecording } + guard !rolling.isEmpty else { return } + + let captureID = fanOutScheduled( + { fire, anchor, id, index in + RemoteCmd.ScheduledStopRecording(fireAtCameraClockMillis: fire, anchorMillis: anchor, + captureId: id, sessionId: self.sessionID, cameraIndex: index) + }, + fallback: { _ in RemoteCmd.StopRecordingVideo(sender: nil, sendMediaToPeer: false) }, + lanes: rolling) + + state = .stoppingRecording(captureId: captureID, acksRemaining: capturingLanes.count) + publishLanes() + armAckTimeout(captureID) + } + + // MARK: Ack aggregation (shared across photo / start / stop) + + private func resolvePhotoAck(from peer: MCPeerID, success: Bool) { + guard case .capturingPhoto = state, capturingLanes.contains(peer) else { return } + links[peer]?.captureOutcome = success ? .captured : .failed + finishLane(peer) + } + + private func resolveRecordingAck(from peer: MCPeerID, isStop: Bool, success: Bool) { + switch state { + case .recording where !isStop: + guard capturingLanes.contains(peer) else { return } + if success { links[peer]?.isRecording = true } else { links[peer]?.captureOutcome = .failed } + finishLane(peer) + case .stoppingRecording where isStop: + guard capturingLanes.contains(peer) else { return } + links[peer]?.isRecording = false + finishLane(peer) + default: + break + } } - /// A camera answered the in-flight capture (accepted or refused). Counts it - /// once; later duplicate acks (e.g. a scheduled camera also sends TakePic - /// acks after it fires) are ignored. - private func recordCaptureAck(from peer: MCPeerID, outcome: CaptureOutcome) { - guard case .capturingPhoto(let captureID, _) = state, - capturingLanes.contains(peer) else { return } - links[peer]?.captureOutcome = outcome + /// Count one lane's answer and advance the aggregate. A photo or a stop + /// that empties the set returns to monitoring; a start that empties it + /// stays `.recording` (the rig is now rolling). + private func finishLane(_ peer: MCPeerID) { capturingLanes.remove(peer) let remaining = capturingLanes.count - state = remaining == 0 - ? .monitoring(mode: .photo) - : .capturingPhoto(captureId: captureID, acksRemaining: remaining) + switch state { + case .capturingPhoto(let id, _): + state = remaining == 0 ? .monitoring(mode: .photo) : .capturingPhoto(captureId: id, acksRemaining: remaining) + case .recording(let id, _): + state = .recording(captureId: id, acksRemaining: remaining) + case .stoppingRecording(let id, _): + state = remaining == 0 ? .monitoring(mode: .photo) : .stoppingRecording(captureId: id, acksRemaining: remaining) + case .monitoring: + break + } publishLanes() } - private func armCaptureTimeout(_ captureID: String) { + private func armAckTimeout(_ captureID: String) { let timeout = captureAckTimeout Task { [weak self] in try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) guard let self else { return } - await self.expireCaptureAcks(captureID) + await self.expireAcks(captureID) } } - /// Any camera that never answered by the deadline is counted as failed, so - /// the aggregate always resolves back to monitoring. - private func expireCaptureAcks(_ captureID: String) { - guard case .capturingPhoto(let inFlight, _) = state, inFlight == captureID else { return } - for peer in capturingLanes { links[peer]?.captureOutcome = .failed } + /// Any camera silent past the deadline is settled so the aggregate always + /// resolves: a silent photo lane failed; a silent start lane failed (not + /// rolling); a silent stop lane is forced stopped. + private func expireAcks(_ captureID: String) { + let matches: Bool + switch state { + case .capturingPhoto(let id, _), .recording(let id, _), .stoppingRecording(let id, _): + matches = id == captureID + case .monitoring: + matches = false + } + guard matches else { return } + + for peer in capturingLanes { + switch state { + case .capturingPhoto: links[peer]?.captureOutcome = .failed + case .recording: links[peer]?.captureOutcome = .failed + case .stoppingRecording: links[peer]?.isRecording = false + case .monitoring: break + } + } capturingLanes.removeAll() currentCaptureID = nil - state = .monitoring(mode: .photo) + switch state { + case .recording(let id, _): + state = .recording(captureId: id, acksRemaining: 0) // rolling, start acks settled + default: + state = .monitoring(mode: .photo) + } publishLanes() } @@ -528,18 +652,24 @@ public actor MulticamController { status: link.status, isFocused: peer == focusedPeer, clockOffsetMillis: link.latestOffset?.offsetMillis, - captureOutcome: link.captureOutcome) + captureOutcome: link.captureOutcome, + isRecording: link.isRecording) } } private func publishLanes() { let snapshot = laneSnapshot() - let inFlight: Bool - if case .capturingPhoto = state { inFlight = true } else { inFlight = false } + let capturing: Bool + if case .capturingPhoto = state { capturing = true } else { capturing = false } + let recording: Bool + switch state { + case .recording, .stoppingRecording: recording = true + default: recording = false + } let display = display OperationQueue.main.addOperation { display?.applyLanes(snapshot) - display?.applyCaptureInFlight(inFlight) + display?.applyShutterState(capturing: capturing, recording: recording) } } diff --git a/RemoteCam/MulticamView.swift b/RemoteCam/MulticamView.swift index f4f914db..1fb03469 100644 --- a/RemoteCam/MulticamView.swift +++ b/RemoteCam/MulticamView.swift @@ -16,8 +16,10 @@ struct MulticamView: View { /// Tap a thumbnail to make that camera the focused one. let onFocusLane: (CameraLane) -> Void - /// The synced shutter — fires a photo on every ready camera at once. - let onCapture: () -> Void + /// The synced shutter — photo, or record start/stop depending on mode. + let onShutter: () -> Void + /// Toggle the shutter between photo and video mode. + let onToggleMode: () -> Void var body: some View { GeometryReader { geo in @@ -38,25 +40,50 @@ struct MulticamView: View { } } - /// The all-camera shutter, docked on the same edge the 1:1 monitor uses so - /// the two screens feel of a piece. Reuses the monitor's `ShutterButton` - /// (and its activity ring) for a consistent feel. + /// The all-camera shutter + a photo/video mode toggle, docked on the same + /// edge the 1:1 monitor uses. Reuses the monitor's `ShutterButton` (and its + /// activity ring) so the two screens feel of a piece. @ViewBuilder private func shutterOverlay(dock: MonitorChromeDock) -> some View { let shutter = ShutterButton( - uiState: .photoMode, - isRecording: false, + uiState: viewModel.mode == .video ? .videoMode : .photoMode, + isRecording: viewModel.isRecording, activity: viewModel.isCapturing ? .capturing : nil, isEnabled: !viewModel.isCapturing && viewModel.focusedLane != nil, - action: onCapture) + action: onShutter) + + // The mode toggle is hidden while recording (you can't switch mid-clip). + let modeToggle = Button(action: onToggleMode) { + Image(systemName: viewModel.mode == .video ? "video.fill" : "camera.fill") + .font(.title3) + .foregroundColor(.white) + .frame(width: 44, height: 44) + .background(Color.black.opacity(0.4)) + .clipShape(Circle()) + } + .opacity(viewModel.isRecording ? 0 : 1) + .disabled(viewModel.isRecording) switch dock { case .bottom: - VStack { Spacer(); shutter.padding(.bottom, 24) } + VStack { + Spacer() + ZStack { + shutter + HStack { Spacer(); modeToggle.padding(.trailing, 40) } + } + .padding(.bottom, 24) + } case .leading: - HStack { shutter.padding(.leading, 24); Spacer() } + HStack { + VStack { Spacer(); modeToggle; shutter; Spacer() }.padding(.leading, 24) + Spacer() + } case .trailing: - HStack { Spacer(); shutter.padding(.trailing, 24) } + HStack { + Spacer() + VStack { Spacer(); modeToggle; shutter; Spacer() }.padding(.trailing, 24) + } } } @@ -145,7 +172,18 @@ struct CameraTileView: View { .foregroundColor(.white)) } - if let outcome = lane.captureOutcome { + if lane.isRecording { + VStack { + HStack { + Image(systemName: "record.circle.fill") + .font(.body) + .foregroundColor(.red) + Spacer() + } + Spacer() + } + .padding(6) + } else if let outcome = lane.captureOutcome { VStack { HStack { Spacer() diff --git a/RemoteCam/MulticamViewController.swift b/RemoteCam/MulticamViewController.swift index 475feaed..66258fdd 100644 --- a/RemoteCam/MulticamViewController.swift +++ b/RemoteCam/MulticamViewController.swift @@ -45,9 +45,13 @@ public final class MulticamViewController: UIViewController { guard let self else { return } Task { await self.controller.setFocusedPeer(lane.peerID) } }, - onCapture: { [weak self] in + onShutter: { [weak self] in guard let self else { return } - Task { await self.controller.capturePhoto() } + self.triggerShutter() + }, + onToggleMode: { [weak self] in + guard let self, !self.viewModel.isRecording else { return } + self.viewModel.mode = self.viewModel.mode == .photo ? .video : .photo }) hosting = embedSwiftUIView(multicamView) @@ -71,6 +75,19 @@ public final class MulticamViewController: UIViewController { navigationController?.setNavigationBarHidden(false, animated: animated) } + /// Route the shutter: a photo, or record start/stop, per the current mode. + private func triggerShutter() { + let mode = viewModel.mode + let recording = viewModel.isRecording + Task { + switch (mode, recording) { + case (.photo, _): await controller.capturePhoto() + case (.video, false): await controller.startRecording() + case (.video, true): await controller.stopRecording() + } + } + } + private func syncInterfaceOrientation() { let orientation = view.window?.windowScene?.interfaceOrientation ?? .portrait if viewModel.interfaceOrientation != orientation { @@ -110,8 +127,9 @@ extension MulticamViewController: MulticamDisplay { for lane in created { wire(lane) } } - func applyCaptureInFlight(_ inFlight: Bool) { - viewModel.isCapturing = inFlight + func applyShutterState(capturing: Bool, recording: Bool) { + viewModel.isCapturing = capturing + viewModel.isRecording = recording } func receiveFrame(_ frame: RemoteCmd.OnFrame) { diff --git a/RemoteCam/MulticamViewModel.swift b/RemoteCam/MulticamViewModel.swift index b8228b8e..c68d0229 100644 --- a/RemoteCam/MulticamViewModel.swift +++ b/RemoteCam/MulticamViewModel.swift @@ -27,6 +27,8 @@ final class CameraLane: ObservableObject, Identifiable { @Published var isFocused: Bool /// How this camera answered the last synced capture — a brief tile badge. @Published var captureOutcome: CaptureOutcome? + /// This camera is rolling in a synced recording — REC badge. + @Published var isRecording: Bool /// This lane's own decoder + stall watchdog. The view controller wires its /// `onImage` to set `frames.cameraImage`, and its stall/keyframe callbacks @@ -39,6 +41,7 @@ final class CameraLane: ObservableObject, Identifiable { self.status = info.status self.isFocused = info.isFocused self.captureOutcome = info.captureOutcome + self.isRecording = info.isRecording } } @@ -49,6 +52,10 @@ final class MulticamViewModel: ObservableObject { @Published var interfaceOrientation: UIInterfaceOrientation = .portrait /// A synced photo is in flight — drives the shutter's activity ring. @Published var isCapturing: Bool = false + /// The rig is recording — the shutter becomes a stop button. + @Published var isRecording: Bool = false + /// Photo vs video shutter mode. + @Published var mode: MonitorMode = .photo var focusedLane: CameraLane? { lanes.first { $0.isFocused } } var otherLanes: [CameraLane] { lanes.filter { !$0.isFocused } } @@ -69,6 +76,7 @@ final class MulticamViewModel: ObservableObject { if lane.status != info.status { lane.status = info.status } if lane.isFocused != info.isFocused { lane.isFocused = info.isFocused } if lane.captureOutcome != info.captureOutcome { lane.captureOutcome = info.captureOutcome } + if lane.isRecording != info.isRecording { lane.isRecording = info.isRecording } return lane } let lane = CameraLane(info: info) diff --git a/RemoteCam/RecordingPipeline.swift b/RemoteCam/RecordingPipeline.swift index 99f51cda..b91c2689 100644 --- a/RemoteCam/RecordingPipeline.swift +++ b/RemoteCam/RecordingPipeline.swift @@ -71,6 +71,13 @@ class RecordingPipeline { /// writer geometry must not chase a mid-recording aspect change. var recordingAspectRatio: AspectRatio = .sixteenNine + /// Set for a synced multicam recording only: embeds the shot's alignment + /// fields as QuickTime metadata in the .mov and names the saved clip + /// `RS___cam.mov`. Nil for ordinary single-camera recording, + /// which writes and saves exactly as before. Consumed (cleared) when the + /// clip is saved. + var pendingSyncMetadata: CaptureSyncMetadata? + private let videoCropContext = CIContext(options: [.useSoftwareRenderer: false]) private var videoInput: AVAssetWriterInput! @@ -132,6 +139,12 @@ class RecordingPipeline { // Create an asset writer do { self.assetWriter = try AVAssetWriter(outputURL: outputFilePath, fileType: .mov) + // Synced multicam: embed the shot's alignment fields in the file so + // an editor can group and time-align the angles. The anchor rides + // as an opaque numeric key, not a wall-clock date. + if let metadata = pendingSyncMetadata { + self.assetWriter?.metadata = metadata.quickTimeMetadataItems() + } } catch { onError?(NSLocalizedString("Unable to start recording", comment: "")) } @@ -193,9 +206,12 @@ class RecordingPipeline { PHPhotoLibrary.requestAuthorization { [weak self] status in if status == .authorized { // Save the movie file to the photo library and cleanup. + let syncMetadata = self?.pendingSyncMetadata PHPhotoLibrary.shared().performChanges({ let options = PHAssetResourceCreationOptions() options.shouldMoveFile = true + // Synced clip: name it under the shared RS_ group. + if let syncMetadata { options.originalFilename = syncMetadata.videoFilename() } let creationRequest = PHAssetCreationRequest.forAsset() creationRequest.addResource(with: .video, fileURL: outputFileURL, options: options) }, completionHandler: { success, error in @@ -205,6 +221,7 @@ class RecordingPipeline { cleanupFileAt(outputFileURL) } ) + self?.pendingSyncMetadata = nil } else { DispatchQueue.main.async { self?.onPhotosAccessDenied?() diff --git a/RemoteCam/RemoteCmdFlatBuffers.swift b/RemoteCam/RemoteCmdFlatBuffers.swift index b5a7b3af..ce858850 100644 --- a/RemoteCam/RemoteCmdFlatBuffers.swift +++ b/RemoteCam/RemoteCmdFlatBuffers.swift @@ -33,6 +33,9 @@ func serializeToFlatBuffer(_ msg: Message) -> Data? { case let m as RemoteCmd.ClockSyncPong: return m.toFlatBuffer() case let m as RemoteCmd.ScheduledCapture: return m.toFlatBuffer() case let m as RemoteCmd.ScheduledCaptureAck: return m.toFlatBuffer() + case let m as RemoteCmd.ScheduledStartRecording: return m.toFlatBuffer() + case let m as RemoteCmd.ScheduledStopRecording: return m.toFlatBuffer() + case let m as RemoteCmd.ScheduledRecordingAck: 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() @@ -798,6 +801,54 @@ extension RemoteCmd.ScheduledCaptureAck { } } +extension RemoteCmd.ScheduledStartRecording { + func toFlatBuffer() -> Data { + var fbb = FlatBufferBuilder() + let captureIdOffset = fbb.create(string: captureId) + let sessionIdOffset = fbb.create(string: sessionId) + let params = RemoteShutter_CommandParameters.createCommandParameters( + &fbb, + captureFireAtCameraClockMs: fireAtCameraClockMillis, + captureAnchorMs: anchorMillis, + captureIdOffset: captureIdOffset, + captureSessionIdOffset: sessionIdOffset, + captureCameraIndex: Int32(cameraIndex)) + return buildCommand(&fbb, action: .scheduledstartrecording, parameters: params) + } +} + +extension RemoteCmd.ScheduledStopRecording { + func toFlatBuffer() -> Data { + var fbb = FlatBufferBuilder() + let captureIdOffset = fbb.create(string: captureId) + let sessionIdOffset = fbb.create(string: sessionId) + let params = RemoteShutter_CommandParameters.createCommandParameters( + &fbb, + captureFireAtCameraClockMs: fireAtCameraClockMillis, + captureAnchorMs: anchorMillis, + captureIdOffset: captureIdOffset, + captureSessionIdOffset: sessionIdOffset, + captureCameraIndex: Int32(cameraIndex)) + return buildCommand(&fbb, action: .scheduledstoprecording, parameters: params) + } +} + +extension RemoteCmd.ScheduledRecordingAck { + func toFlatBuffer() -> Data { + var fbb = FlatBufferBuilder() + let echoOffset = fbb.create(string: captureId) + let errorOffset = (error as NSError?).map { fbb.create(string: $0.localizedDescription) } ?? Offset() + let action: RemoteShutter_CommandAction = isStop ? .scheduledstoprecording : .scheduledstartrecording + let resp = RemoteShutter_CameraStateResponse.createCameraStateResponse( + &fbb, + action: action, + success: error == nil, + errorOffset: errorOffset, + captureIdEchoOffset: echoOffset) + return buildResponse(&fbb, action: action, response: resp) + } +} + extension RemoteCmd.ToggleFlash { func toFlatBuffer() -> Data { var fbb = FlatBufferBuilder() @@ -1169,6 +1220,22 @@ extension RemoteCmd { sessionId: params?.captureSessionId ?? "", cameraIndex: Int(params?.captureCameraIndex ?? 0)) + case .scheduledstartrecording: + return ScheduledStartRecording( + fireAtCameraClockMillis: params?.captureFireAtCameraClockMs ?? 0, + anchorMillis: params?.captureAnchorMs ?? 0, + captureId: params?.captureId ?? "", + sessionId: params?.captureSessionId ?? "", + cameraIndex: Int(params?.captureCameraIndex ?? 0)) + + case .scheduledstoprecording: + return ScheduledStopRecording( + fireAtCameraClockMillis: params?.captureFireAtCameraClockMs ?? 0, + anchorMillis: params?.captureAnchorMs ?? 0, + captureId: params?.captureId ?? "", + sessionId: params?.captureSessionId ?? "", + cameraIndex: Int(params?.captureCameraIndex ?? 0)) + case .toggleflash: return ToggleFlash() @@ -1236,6 +1303,12 @@ extension RemoteCmd { case .scheduledcapture: return ScheduledCaptureAck(captureId: resp.captureIdEcho ?? "", error: nsError) + case .scheduledstartrecording: + return ScheduledRecordingAck(captureId: resp.captureIdEcho ?? "", isStop: false, error: nsError) + + case .scheduledstoprecording: + return ScheduledRecordingAck(captureId: resp.captureIdEcho ?? "", isStop: true, error: nsError) + case .startrecording: let startTime: Date? = resp.recordingStartTime > 0 ? Date(timeIntervalSince1970: Double(resp.recordingStartTime) / 1000.0) : nil return StartRecordingVideoAck(sender: nil, recordingStartTime: startTime, error: nsError) diff --git a/RemoteCam/RemoteCmds.swift b/RemoteCam/RemoteCmds.swift index d91c6766..a13d326c 100644 --- a/RemoteCam/RemoteCmds.swift +++ b/RemoteCam/RemoteCmds.swift @@ -264,6 +264,66 @@ public class RemoteCmd: Message, @unchecked Sendable { } } + /// Director → camera: begin recording at `fireAtCameraClockMillis` (this + /// camera's clock domain), so every camera rolls together. Same fields and + /// meaning as `ScheduledCapture`. Only sent to `supportsMulticam` peers. + public class ScheduledStartRecording: Message, @unchecked Sendable { + public let fireAtCameraClockMillis: UInt64 + public let anchorMillis: UInt64 + public let captureId: String + public let sessionId: String + public let cameraIndex: Int + + public init(fireAtCameraClockMillis: UInt64, anchorMillis: UInt64, + captureId: String, sessionId: String, cameraIndex: Int, + sender: AnyObject? = nil) { + self.fireAtCameraClockMillis = fireAtCameraClockMillis + self.anchorMillis = anchorMillis + self.captureId = captureId + self.sessionId = sessionId + self.cameraIndex = cameraIndex + super.init(sender: sender) + } + } + + /// Director → camera: stop recording at the fire instant, so clip lengths + /// line up across the rig. Reuses the same params (index/anchor unused). + public class ScheduledStopRecording: Message, @unchecked Sendable { + public let fireAtCameraClockMillis: UInt64 + public let anchorMillis: UInt64 + public let captureId: String + public let sessionId: String + public let cameraIndex: Int + + public init(fireAtCameraClockMillis: UInt64, anchorMillis: UInt64, + captureId: String, sessionId: String, cameraIndex: Int, + sender: AnyObject? = nil) { + self.fireAtCameraClockMillis = fireAtCameraClockMillis + self.anchorMillis = anchorMillis + self.captureId = captureId + self.sessionId = sessionId + self.cameraIndex = cameraIndex + super.init(sender: sender) + } + } + + /// Camera → director: the scheduled record start/stop was accepted (or + /// refused). Immediate, like `ScheduledCaptureAck`; `isStop` distinguishes + /// the two so the director's start/stop aggregation stay separate. + public class ScheduledRecordingAck: Message, @unchecked Sendable { + public let captureId: String + public let isStop: Bool + public let error: Error? + + public init(captureId: String, isStop: Bool, error: Error? = nil, + sender: AnyObject? = nil) { + self.captureId = captureId + self.isStop = isStop + self.error = error + super.init(sender: sender) + } + } + public class OnFrame: Message, @unchecked Sendable { public let data: Data public let peerId: MCPeerID diff --git a/RemoteCam/SessionCoordinator.swift b/RemoteCam/SessionCoordinator.swift index ca807bee..647bd92d 100644 --- a/RemoteCam/SessionCoordinator.swift +++ b/RemoteCam/SessionCoordinator.swift @@ -136,6 +136,17 @@ final class FireScheduledCapture: Message, @unchecked Sendable { super.init(sender: nil) } } +/// Camera side: a scheduled multicam recording's start instant has arrived +/// (same off-actor-delay → pump-message pattern as `FireScheduledCapture`). +final class FireScheduledRecordingStart: Message, @unchecked Sendable { + let metadata: CaptureSyncMetadata + init(metadata: CaptureSyncMetadata) { + self.metadata = metadata + super.init(sender: nil) + } +} +/// Camera side: a scheduled multicam recording's stop instant has arrived. +final class FireScheduledRecordingStop: Message, @unchecked Sendable {} /// Retry tick for the capabilities ladder. final class RetryCapabilities: Message, @unchecked Sendable { let attempt: Int @@ -244,6 +255,16 @@ public actor SessionCoordinator { /// beyond this it is stale and would fire out of sync). private let scheduledCaptureMaxLatenessMillis: Int64 = 1000 + /// Camera side: this session is being driven by a multicam director. Latched + /// the first time any scheduled multicam command arrives, cleared when the + /// session ends (`popToScanning`/`leaveSession`). It gates the ONE behavior + /// change on the camera — keep recording through a director drop instead of + /// stopping — so a single-camera session is never affected. + private var inMulticamSession = false + + /// Test support. + func inMulticamSessionForTesting() -> Bool { inMulticamSession } + /// Multicam director "collecting" mode. Off by default and only ever set /// by the scanner when `ENABLE_MULTICAM` and the monitor role, so every /// non-multicam path is byte-identical. While set, a peer connecting in @@ -635,6 +656,12 @@ public actor SessionCoordinator { peerSupportsFocusPoint = false peerSupportsPreviewMode = false monitorReceivedVP9Frame = false + // The session is being torn down for good (deliberate leave, EndSession, + // or a dead link) — a fresh session starts single-cam until a director + // says otherwise. A reconnect goes through `.reconnecting`, not here, so + // the latch survives a transient director drop. + inMulticamSession = false + pendingSyncMetadata = nil switch state { case .scanning: // Already there — re-entering would restart discovery and reset @@ -996,6 +1023,20 @@ public actor SessionCoordinator { case let scheduled as RemoteCmd.ScheduledCapture: await handleScheduledCapture(scheduled) + case let scheduled as RemoteCmd.ScheduledStartRecording: + await handleScheduledStartRecording(scheduled) + + case let fire as FireScheduledRecordingStart: + // The start instant arrived. Stamp the recording with its sync + // metadata, then roll exactly as a normal StartRecordingVideo — + // local save, no auto-transfer to the director in v1. + inMulticamSession = true + ctrl.setVideoSyncMetadata(fire.metadata) + ctrl.currentCameraMode = .Video + ctrl.updateCameraStatus() + ctrl.startRecordingVideo() + await transition(to: .cameraRecordingVideo) + case let fire as FireScheduledCapture: // The fire instant arrived (enqueued by the off-actor delay task). // Pull the shutter through the normal photo path, saving locally @@ -1217,6 +1258,7 @@ public actor SessionCoordinator { /// enqueues `FireScheduledCapture`, so the capture is pulled by the message /// pump in order — never racing a state transition. private func handleScheduledCapture(_ scheduled: RemoteCmd.ScheduledCapture) async { + inMulticamSession = true let now = SyncClock.nowMillis() let lateness = Int64(now) - Int64(scheduled.fireAtCameraClockMillis) guard lateness <= scheduledCaptureMaxLatenessMillis else { @@ -1246,6 +1288,48 @@ public actor SessionCoordinator { } } + /// Camera side of a synced multicam recording start. Same ack-now, + /// schedule-off-the-actor pattern as `handleScheduledCapture`; the fire + /// message rolls the recording through the normal pipeline. + private func handleScheduledStartRecording(_ scheduled: RemoteCmd.ScheduledStartRecording) async { + inMulticamSession = true + let lateness = Int64(SyncClock.nowMillis()) - Int64(scheduled.fireAtCameraClockMillis) + guard lateness <= scheduledCaptureMaxLatenessMillis else { + await sendOrGoToScanning(RemoteCmd.ScheduledRecordingAck( + captureId: scheduled.captureId, isStop: false, + error: NSError(domain: "Scheduled recording start already passed", + code: 0, userInfo: nil))) + return + } + await sendOrGoToScanning(RemoteCmd.ScheduledRecordingAck( + captureId: scheduled.captureId, isStop: false)) + + let metadata = CaptureSyncMetadata( + sessionID: scheduled.sessionId, captureID: scheduled.captureId, + cameraIndex: scheduled.cameraIndex, anchorMillis: scheduled.anchorMillis, + clockOffsetMillis: 0, roundTripMillis: 0) + let delayMillis = max(0, -lateness) + Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delayMillis) * 1_000_000) + self?.tell(FireScheduledRecordingStart(metadata: metadata)) + } + } + + /// Camera side of a synced recording stop — acks now, fires the stop at the + /// instant so every camera's clip ends together. + private func handleScheduledStopRecording(_ scheduled: RemoteCmd.ScheduledStopRecording) async { + let lateness = Int64(SyncClock.nowMillis()) - Int64(scheduled.fireAtCameraClockMillis) + // A late stop still needs to fire (a clip left rolling is worse than a + // slightly-long clip), so we never nack it — just clamp the delay. + await sendOrGoToScanning(RemoteCmd.ScheduledRecordingAck( + captureId: scheduled.captureId, isStop: true)) + let delayMillis = max(0, -lateness) + Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delayMillis) * 1_000_000) + self?.tell(FireScheduledRecordingStop()) + } + } + private func inCameraTakingPic(_ msg: Message, sendMediaToPeer: Bool, generation: Int) async { switch msg { case is RemoteCmd.RequestKeyframe: @@ -1352,10 +1436,30 @@ public actor SessionCoordinator { await sendOrGoToScanning(RemoteCmd.StopRecordingVideoAck(sender: nil), mode: .reliable) await transition(to: .cameraTransmittingVideo) + case let scheduled as RemoteCmd.ScheduledStopRecording: + await handleScheduledStopRecording(scheduled) + + case is FireScheduledRecordingStop: + // The scheduled stop instant arrived. Save locally only (multicam + // never auto-transfers in v1) and return to the camera screen. + ctrl.stopRecordingVideo(false) + await transition(to: .cameraTransmittingVideo) + case let disconnected as DisconnectPeer: if let lost = disconnected.peer, lost == peer, connectedPeers.isEmpty { - ctrl.stopRecordingVideo(false) - await loseSessionPeer(lost) + if inMulticamSession { + // Resilient camera: the director dropped mid-recording, but + // the OTHER cameras in the rig keep rolling — so must this + // one. Keep recording (do NOT stop), and enter the normal + // reconnect path; the clip is saved when the scheduled stop + // finally fires, or when the user stops on-device. This is + // the ONE behavior gated on `inMulticamSession`; a + // single-camera session falls through to the stop below. + await loseSessionPeer(lost) + } else { + ctrl.stopRecordingVideo(false) + await loseSessionPeer(lost) + } } case is UICmd.ScannerDidAppear: @@ -1585,6 +1689,12 @@ public actor SessionCoordinator { // ack aggregation completes instead of timing out. await sendOrGoToScanning(RemoteCmd.ScheduledCaptureAck( captureId: scheduled.captureId, error: unableToProcessError(msg))) + case let scheduled as RemoteCmd.ScheduledStartRecording: + await sendOrGoToScanning(RemoteCmd.ScheduledRecordingAck( + captureId: scheduled.captureId, isStop: false, error: unableToProcessError(msg))) + case let scheduled as RemoteCmd.ScheduledStopRecording: + await sendOrGoToScanning(RemoteCmd.ScheduledRecordingAck( + captureId: scheduled.captureId, isStop: true, error: unableToProcessError(msg))) case is RemoteCmd.ToggleCamera: await sendOrGoToScanning(RemoteCmd.ToggleCameraResp(cameraCapabilities: nil, error: unableToProcessError(msg))) case is RemoteCmd.SelectCameraDevice: diff --git a/RemoteCamTests/CaptureSyncMetadataTests.swift b/RemoteCamTests/CaptureSyncMetadataTests.swift index b47ae2aa..fd9f9cc3 100644 --- a/RemoteCamTests/CaptureSyncMetadataTests.swift +++ b/RemoteCamTests/CaptureSyncMetadataTests.swift @@ -7,6 +7,8 @@ // import AVFoundation +import ImageIO +import UniformTypeIdentifiers import XCTest @testable import RemoteShutter @@ -36,6 +38,49 @@ final class CaptureSyncMetadataTests: XCTestCase { XCTAssertEqual(sample.jsonString(), sample.jsonString()) } + /// The alignment key rides in UserComment as opaque JSON; the EXIF capture + /// date is the camera's WALL clock (a real recent date), never the + /// monotonic-uptime anchor — which would land photos in ~1970. + func testStampedExifDateIsWallClockNotTheMonotonicAnchor() throws { + let png = try makeTinyPNG() + // A 2025 wall-clock instant; the sample's anchorMillis (~1.7e12 ms of + // uptime, i.e. ~55000 years) would parse to a nonsense EXIF date. + let capturedAt = Date(timeIntervalSince1970: 1_754_800_000.250) + let stamped = sample.stamped(png, capturedAt: capturedAt) + + let source = try XCTUnwrap(CGImageSourceCreateWithData(stamped as CFData, nil)) + let props = try XCTUnwrap( + CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]) + let exif = try XCTUnwrap(props[kCGImagePropertyExifDictionary] as? [CFString: Any]) + + // "yyyy:MM:dd HH:mm:ss" — assert the year is the capturedAt year in the + // formatter's own zone (timezone-robust: we don't hard-code HH:mm:ss). + let dateString = try XCTUnwrap(exif[kCGImagePropertyExifDateTimeOriginal] as? String) + let year = Int(dateString.prefix(4)) + XCTAssertEqual(year, 2025, "EXIF DateTimeOriginal must be the wall clock, got \(dateString)") + XCTAssertEqual(exif[kCGImagePropertyExifSubsecTimeOriginal] as? String, "250") + + // The anchor is present, but only inside the opaque UserComment JSON. + let userComment = try XCTUnwrap(exif[kCGImagePropertyExifUserComment] as? String) + let decoded = try XCTUnwrap(CaptureSyncMetadata.fromJSONString(userComment)) + XCTAssertEqual(decoded.anchorMillis, sample.anchorMillis) + } + + /// A 1×1 PNG, enough for CGImageSource to round-trip through the stamper. + private func makeTinyPNG() throws -> Data { + let data = NSMutableData() + let dest = try XCTUnwrap(CGImageDestinationCreateWithData( + data, UTType.png.identifier as CFString, 1, nil)) + let ctx = try XCTUnwrap(CGContext( + data: nil, width: 1, height: 1, bitsPerComponent: 8, bytesPerRow: 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + let image = try XCTUnwrap(ctx.makeImage()) + CGImageDestinationAddImage(dest, image, nil) + XCTAssertTrue(CGImageDestinationFinalize(dest)) + return data as Data + } + func testQuickTimeItemsCarryAnchorAndIDs() { let items = sample.quickTimeMetadataItems() XCTAssertEqual(items.count, 4) diff --git a/RemoteCamTests/MulticamControllerTests.swift b/RemoteCamTests/MulticamControllerTests.swift index bdd03ce7..422875e4 100644 --- a/RemoteCamTests/MulticamControllerTests.swift +++ b/RemoteCamTests/MulticamControllerTests.swift @@ -13,11 +13,15 @@ import XCTest private final class FakeMulticamDisplay: MulticamDisplay, @unchecked Sendable { var lastLanes: [MulticamLaneInfo] = [] var receivedFrames: [MCPeerID] = [] - var captureInFlight = false + var capturing = false + var recording = false var didExit = false func applyLanes(_ lanes: [MulticamLaneInfo]) { lastLanes = lanes } - func applyCaptureInFlight(_ inFlight: Bool) { captureInFlight = inFlight } + func applyShutterState(capturing: Bool, recording: Bool) { + self.capturing = capturing + self.recording = recording + } func receiveFrame(_ frame: RemoteCmd.OnFrame) { receivedFrames.append(frame.peerId) } func exitMulticam() { didExit = true } } @@ -273,6 +277,83 @@ final class MulticamControllerTests: XCTestCase { XCTAssertEqual(fallbackState?.remaining, 2) } + // MARK: - Synced video + + func testStartAndStopAnchorsGiveMatchingClipLengths() async { + let (controller, transport, _) = await makeController(peers: [camA, camB]) + await controller.seedLaneForTesting(camA, supportsMulticam: true, offsetMillis: 100) + await controller.seedLaneForTesting(camB, supportsMulticam: true, offsetMillis: -50) + transport.sentMessages.removeAll() + + await controller.startRecording() + await controller.waitForIdle() + let starts = sent(transport, RemoteCmd.ScheduledStartRecording.self) + func startFire(_ p: MCPeerID) -> UInt64 { + (starts.first { $0.peers == [p] }!.msg as! RemoteCmd.ScheduledStartRecording).fireAtCameraClockMillis + } + + // Mark both rolling so stopRecording targets them. + let recID = await controller.recordingStateForTesting()?.id + controller.didReceiveMessage(RemoteCmd.ScheduledRecordingAck(captureId: recID!, isStop: false), from: camA) + controller.didReceiveMessage(RemoteCmd.ScheduledRecordingAck(captureId: recID!, isStop: false), from: camB) + await controller.waitForIdle() + transport.sentMessages.removeAll() + + await controller.stopRecording() + await controller.waitForIdle() + let stops = sent(transport, RemoteCmd.ScheduledStopRecording.self) + func stopFire(_ p: MCPeerID) -> UInt64 { + (stops.first { $0.peers == [p] }!.msg as! RemoteCmd.ScheduledStopRecording).fireAtCameraClockMillis + } + + // Per-lane clip length (stop − start) is identical across cameras: each + // lane's offset cancels, leaving the shared (stopBase − startBase). + let lenA = Int64(stopFire(camA)) - Int64(startFire(camA)) + let lenB = Int64(stopFire(camB)) - Int64(startFire(camB)) + XCTAssertEqual(lenA, lenB, "clip lengths must match across the rig") + // And the per-lane fire instants differ by the offset delta (150) for + // both start and stop. + XCTAssertEqual(Int64(startFire(camA)) - Int64(startFire(camB)), 150) + XCTAssertEqual(Int64(stopFire(camA)) - Int64(stopFire(camB)), 150) + } + + func testStartAcksMarkLanesRecording() async { + let (controller, _, _) = await makeController(peers: [camA, camB]) + await controller.seedLaneForTesting(camA, supportsMulticam: true, offsetMillis: 0) + await controller.seedLaneForTesting(camB, supportsMulticam: true, offsetMillis: 0) + + await controller.startRecording() + await controller.waitForIdle() + let recID = await controller.recordingStateForTesting()?.id + XCTAssertNotNil(recID) + + controller.didReceiveMessage(RemoteCmd.ScheduledRecordingAck(captureId: recID!, isStop: false), from: camA) + controller.didReceiveMessage(RemoteCmd.ScheduledRecordingAck(captureId: recID!, isStop: false), from: camB) + await controller.waitForIdle() + + let recA = await controller.isRecordingForTesting(camA) + let recB = await controller.isRecordingForTesting(camB) + XCTAssertTrue(recA) + XCTAssertTrue(recB) + // Still recording (all start acks in, remaining 0). + let stillRecording = await controller.recordingStateForTesting() + XCTAssertEqual(stillRecording?.remaining, 0) + + // Stop resolves back to monitoring. + await controller.stopRecording() + await controller.waitForIdle() + let stopID = await controller.stoppingStateForTesting()?.id + controller.didReceiveMessage(RemoteCmd.ScheduledRecordingAck(captureId: stopID!, isStop: true), from: camA) + controller.didReceiveMessage(RemoteCmd.ScheduledRecordingAck(captureId: stopID!, isStop: true), from: camB) + await controller.waitForIdle() + let afterStop = await controller.recordingStateForTesting() + let stoppingAfter = await controller.stoppingStateForTesting() + XCTAssertNil(afterStop) + XCTAssertNil(stoppingAfter, "back to monitoring") + let recAafter = await controller.isRecordingForTesting(camA) + XCTAssertFalse(recAafter) + } + // MARK: - Removal func testRemoveCameraDropsTheLaneAndRefocuses() async { diff --git a/RemoteCamTests/MulticamViewModelTests.swift b/RemoteCamTests/MulticamViewModelTests.swift index 1edab499..845376b2 100644 --- a/RemoteCamTests/MulticamViewModelTests.swift +++ b/RemoteCamTests/MulticamViewModelTests.swift @@ -18,7 +18,7 @@ final class MulticamViewModelTests: XCTestCase { focused: Bool = false) -> MulticamLaneInfo { MulticamLaneInfo(peerID: peer, displayName: peer.displayName, status: status, isFocused: focused, clockOffsetMillis: nil, - captureOutcome: nil) + captureOutcome: nil, isRecording: false) } func testApplyAddsLanesAndReportsCreated() { diff --git a/RemoteCamTests/RemoteCamSessionTests.swift b/RemoteCamTests/RemoteCamSessionTests.swift index 05039e51..31f97adc 100644 --- a/RemoteCamTests/RemoteCamSessionTests.swift +++ b/RemoteCamTests/RemoteCamSessionTests.swift @@ -343,6 +343,93 @@ class SessionCoordinatorTests: XCTestCase { "the scheduled shutter fires, saving locally (not to the director)") } + // MARK: - Resilient camera (multicam only) + + /// In a multicam session the camera keeps recording through a director + /// drop — the rest of the rig is still rolling. It enters the reconnect + /// path without stopping the clip. + func testMulticamDisconnectMidRecordingKeepsRecording() async { + await enterCamera() + harness.fakeMP.sendResult = true + + // A real scheduled start latches the multicam session and rolls tape. + await harness.deliver(RemoteCmd.ScheduledStartRecording( + fireAtCameraClockMillis: SyncClock.nowMillis(), + anchorMillis: SyncClock.nowMillis(), + captureId: "R", sessionId: "S", cameraIndex: 1)) + let latched = await harness.coordinator.inMulticamSessionForTesting() + XCTAssertTrue(latched) + + for _ in 0..<200 where camera.startRecordingCalls == 0 { + try? await Task.sleep(nanoseconds: 10_000_000) + } + XCTAssertEqual(camera.startRecordingCalls, 1) + await harness.coordinator.waitForIdle() + var name = await harness.stateName() + XCTAssertEqual(name, .cameraRecordingVideo) + + // Director drops. + harness.fakeMP.connectedPeers = [] + await harness.deliver(DisconnectPeer(peer: harness.peer, sender: nil)) + + XCTAssertTrue(camera.stopRecordingCalls.isEmpty, + "the clip keeps rolling through a director drop in multicam") + name = await harness.stateName() + XCTAssertEqual(name, .reconnecting) + } + + /// A single-camera session is unchanged: a disconnect mid-recording stops + /// the clip exactly as before (the resilient behavior must not leak here). + func testSingleCamDisconnectMidRecordingStops() async { + await enterCamera() + harness.fakeMP.sendResult = true + + await harness.deliver(RemoteCmd.StartRecordingVideo(sender: nil)) + let name = await harness.stateName() + XCTAssertEqual(name, .cameraRecordingVideo) + let latched = await harness.coordinator.inMulticamSessionForTesting() + XCTAssertFalse(latched, "no multicam command arrived") + + harness.fakeMP.connectedPeers = [] + await harness.deliver(DisconnectPeer(peer: harness.peer, sender: nil)) + + XCTAssertEqual(camera.stopRecordingCalls, [false], + "single-camera recording still stops on disconnect") + } + + /// A scheduled start latches the session and stamps the recording with sync + /// metadata; a scheduled stop later fires the stop. + func testScheduledRecordingStampsMetadataAndFires() async { + await enterCamera() + harness.fakeMP.sendResult = true + + await harness.deliver(RemoteCmd.ScheduledStartRecording( + fireAtCameraClockMillis: SyncClock.nowMillis(), + anchorMillis: 42, captureId: "R7", sessionId: "S3", cameraIndex: 4)) + + let acks = sent(RemoteCmd.ScheduledRecordingAck.self) + .compactMap { $0.msg as? RemoteCmd.ScheduledRecordingAck } + XCTAssertEqual(acks.map(\.captureId), ["R7"]) + XCTAssertFalse(acks[0].isStop) + + for _ in 0..<200 where camera.startRecordingCalls == 0 { + try? await Task.sleep(nanoseconds: 10_000_000) + } + // The recording carries its sync metadata (anchor as opaque key). + XCTAssertEqual(camera.videoSyncMetadata?.captureID, "R7") + XCTAssertEqual(camera.videoSyncMetadata?.cameraIndex, 4) + XCTAssertEqual(camera.videoSyncMetadata?.anchorMillis, 42) + + // A scheduled stop fires the stop. + await harness.deliver(RemoteCmd.ScheduledStopRecording( + fireAtCameraClockMillis: SyncClock.nowMillis(), + anchorMillis: 42, captureId: "R7", sessionId: "S3", cameraIndex: 4)) + for _ in 0..<200 where camera.stopRecordingCalls.isEmpty { + try? await Task.sleep(nanoseconds: 10_000_000) + } + XCTAssertEqual(camera.stopRecordingCalls, [false], "scheduled stop saves locally") + } + func testMonitorPhotoModeUnbecomeMonitorPopsToConnected() async { await enterMonitor(.Photo) await harness.deliver(UICmd.UnbecomeMonitor(sender: nil)) diff --git a/RemoteCamTests/RemoteCmdSerializationTests.swift b/RemoteCamTests/RemoteCmdSerializationTests.swift index dafc51e1..4e52e4d3 100644 --- a/RemoteCamTests/RemoteCmdSerializationTests.swift +++ b/RemoteCamTests/RemoteCmdSerializationTests.swift @@ -51,6 +51,9 @@ final class RemoteCmdSerializationTests: XCTestCase { case let m as RemoteCmd.ClockSyncPong: return m.toFlatBuffer() case let m as RemoteCmd.ScheduledCapture: return m.toFlatBuffer() case let m as RemoteCmd.ScheduledCaptureAck: return m.toFlatBuffer() + case let m as RemoteCmd.ScheduledStartRecording: return m.toFlatBuffer() + case let m as RemoteCmd.ScheduledStopRecording: return m.toFlatBuffer() + case let m as RemoteCmd.ScheduledRecordingAck: 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() @@ -1225,6 +1228,39 @@ extension RemoteCmdSerializationTests { XCTAssertNotNil(nack.error) } + func testScheduledStartRecording_roundTrip() { + let result = roundTrip(RemoteCmd.ScheduledStartRecording( + fireAtCameraClockMillis: 111, anchorMillis: 100, + captureId: "REC-1", sessionId: "S", cameraIndex: 2)) + XCTAssertEqual(result.fireAtCameraClockMillis, 111) + XCTAssertEqual(result.anchorMillis, 100) + XCTAssertEqual(result.captureId, "REC-1") + XCTAssertEqual(result.cameraIndex, 2) + } + + func testScheduledStopRecording_roundTrip() { + let result = roundTrip(RemoteCmd.ScheduledStopRecording( + fireAtCameraClockMillis: 222, anchorMillis: 200, + captureId: "REC-1", sessionId: "S", cameraIndex: 2)) + XCTAssertEqual(result.fireAtCameraClockMillis, 222) + XCTAssertEqual(result.captureId, "REC-1") + } + + /// The start/stop distinction (`isStop`) rides the response action, so the + /// director routes each ack to the right aggregation. + func testScheduledRecordingAck_roundTripPreservesIsStop() { + let start = roundTrip(RemoteCmd.ScheduledRecordingAck(captureId: "R", isStop: false)) + XCTAssertFalse(start.isStop) + XCTAssertEqual(start.captureId, "R") + + let stop = roundTrip(RemoteCmd.ScheduledRecordingAck(captureId: "R", isStop: true)) + XCTAssertTrue(stop.isStop) + + let nack = roundTrip(RemoteCmd.ScheduledRecordingAck( + captureId: "R", isStop: false, error: NSError(domain: "x", code: 0))) + XCTAssertNotNil(nack.error) + } + /// The multicam capability survives the wire, and a peer that predates it /// (absent field) decodes as not-multicam-capable. func testCapabilitiesCarryMulticamSupport() { diff --git a/RemoteCamTests/SessionTestSupport.swift b/RemoteCamTests/SessionTestSupport.swift index 90b60dd7..7127e5a0 100644 --- a/RemoteCamTests/SessionTestSupport.swift +++ b/RemoteCamTests/SessionTestSupport.swift @@ -133,6 +133,8 @@ class FakeCameraControlling: CameraControlling, @unchecked Sendable { func takePicture(_ sendMediaToRemote: Bool) { takePictureCalls.append(sendMediaToRemote) } func startRecordingVideo() { startRecordingCalls += 1 } func stopRecordingVideo(_ shouldSendVideo: Bool) { stopRecordingCalls.append(shouldSendVideo) } + var videoSyncMetadata: CaptureSyncMetadata? + func setVideoSyncMetadata(_ metadata: CaptureSyncMetadata?) { videoSyncMetadata = metadata } // swiftlint:disable:next large_tuple func setZoom(zoomFactor: CGFloat) async throws -> (CGFloat, CameraLensType, RemoteCmd.ZoomRange) { From 6e303b6f05f503d10b1a4798b586bb4c8cc62789 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Tue, 11 Aug 2026 00:05:54 -0700 Subject: [PATCH 07/17] Multicam commit A: grid mode + tiered stream profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tiered previews so N camera streams fit the aggregate bandwidth + decode budget, plus a grid "monitor wall". All behind ENABLE_MULTICAM. Wire (additive): SetStreamProfile (action 29, params max_long_edge / bitrate_kbps / fps). Both dispatch switches + round-trip. Camera side (single-cam untouched): FrameStreamer gains a mutable active profile that defaults to today's full peer values — a streamer that is never re-profiled behaves identically. applyProfile (capture-queue confined via FrameStreamingCoordinator -> CameraRig -> CameraControlling) rebuilds the still chain and drops the video encoder so it is rebuilt at the new resolution on the next frame — the same drop-and-rebuild path the failure fallback already uses, so the new encoder's first frame is a keyframe the monitor re-syncs on. SetStreamProfile is handled in inCamera and inCameraRecordingVideo (the preview keeps streaming while recording). Director side (MulticamController): the focused lane gets StreamProfile .focused (1200/1.2Mbps/30), the rest .thumbnail (640/500kbps/20). Profiles are pushed when a lane goes live and re-tiered on focus switch, de-duped via CameraLink.lastSentProfile so nothing is re-sent when the tier is unchanged. Per-peer keyframe/stall routing was already per-peer from PR3. Grid UI: MultiCamChrome pure layout policy (near-square columns; toggle only when >1 camera) with unit tests. MulticamView gains a focus/grid toggle and a LazyVGrid of the same per-lane tiles; tapping a grid tile focuses it and returns to focus mode. Per-camera controls hide in grid; the capture cluster stays. Tests: SetStreamProfile round-trip; FrameStreamer rebuilds the encoder on profile change (and not on a no-op change); director tiers focused vs thumbnail and re-tiers on focus switch without redundant sends; MultiCamChrome columns/toggle. Full suite green (694). Co-Authored-By: Claude Fable 5 --- RemoteCam/CameraControlling.swift | 3 + RemoteCam/CameraLink.swift | 4 ++ RemoteCam/CameraRig.swift | 4 ++ RemoteCam/FlatBufferSchemas.fbs | 9 ++- RemoteCam/FlatBufferSchemas_generated.swift | 25 +++++++- RemoteCam/FrameStreamer.swift | 40 ++++++++++--- RemoteCam/FrameStreamingCoordinator.swift | 22 ++++++- RemoteCam/MultiCamChrome.swift | 36 ++++++++++++ RemoteCam/MulticamController.swift | 27 ++++++++- RemoteCam/MulticamView.swift | 58 ++++++++++++++++++- RemoteCam/MulticamViewModel.swift | 2 + RemoteCam/RemoteCmdFlatBuffers.swift | 19 ++++++ RemoteCam/RemoteCmds.swift | 16 +++++ RemoteCam/SessionCoordinator.swift | 14 +++++ RemoteCam/StreamingConfig.swift | 17 ++++++ RemoteCamTests/FrameStreamingTests.swift | 31 +++++++++- RemoteCamTests/MultiCamChromeTests.swift | 36 ++++++++++++ RemoteCamTests/MulticamControllerTests.swift | 51 ++++++++++++++++ .../RemoteCmdSerializationTests.swift | 9 +++ RemoteCamTests/SessionTestSupport.swift | 2 + RemoteShutter.xcodeproj/project.pbxproj | 8 +++ 21 files changed, 414 insertions(+), 19 deletions(-) create mode 100644 RemoteCam/MultiCamChrome.swift create mode 100644 RemoteCamTests/MultiCamChromeTests.swift diff --git a/RemoteCam/CameraControlling.swift b/RemoteCam/CameraControlling.swift index bf64b61c..fc6de76b 100644 --- a/RemoteCam/CameraControlling.swift +++ b/RemoteCam/CameraControlling.swift @@ -37,6 +37,9 @@ protocol CameraControlling: AnyObject, Sendable { /// Cleared (nil) for ordinary single-camera recording — never called /// there, so that path is byte-identical. func setVideoSyncMetadata(_ metadata: CaptureSyncMetadata?) + /// Multicam only: retune the live preview encoder (resolution/bitrate/fps) + /// 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) /// Sets the focus/exposure point of interest from a monitor tap. `x`/`y` are diff --git a/RemoteCam/CameraLink.swift b/RemoteCam/CameraLink.swift index e5212ffe..a66083d6 100644 --- a/RemoteCam/CameraLink.swift +++ b/RemoteCam/CameraLink.swift @@ -59,6 +59,10 @@ final class CameraLink { /// REC badge. var isRecording = false + /// The preview profile most recently pushed to this camera, so the director + /// only re-sends `SetStreamProfile` when the tier actually changes. + var lastSentProfile: StreamProfile? + /// This camera's own preview decoder + stall watchdog. Frames tagged with /// this peer's id are fed here; its `onImage` drives exactly this lane's /// tile, so a frame from another camera never touches it. diff --git a/RemoteCam/CameraRig.swift b/RemoteCam/CameraRig.swift index d9105a98..d27087d5 100644 --- a/RemoteCam/CameraRig.swift +++ b/RemoteCam/CameraRig.swift @@ -553,6 +553,10 @@ extension CameraRig: CameraControlling { pipeline.pendingSyncMetadata = metadata } + func applyStreamProfile(_ profile: StreamProfile) { + streamingCoordinator.applyStreamProfile(profile) + } + func updateTimerCountdown(value: Int) { OperationQueue.main.addOperation { if value > 0 { diff --git a/RemoteCam/FlatBufferSchemas.fbs b/RemoteCam/FlatBufferSchemas.fbs index 247db78e..47bd6e8d 100644 --- a/RemoteCam/FlatBufferSchemas.fbs +++ b/RemoteCam/FlatBufferSchemas.fbs @@ -53,8 +53,11 @@ enum CommandAction : byte { ScheduledStartRecording = 27, // director -> camera: start recording at the fire // instant. Reuses the ScheduledCapture params; acks by // echoing the capture id. Only sent to multicam peers. - ScheduledStopRecording = 28 // director -> camera: stop recording at the fire + ScheduledStopRecording = 28, // director -> camera: stop recording at the fire // instant, so clip lengths match across the rig. + SetStreamProfile = 29 // director -> camera: reconfigure the live preview + // encoder (resolution/bitrate/fps) for tiered + // multicam previews. Only sent to multicam peers. } // Whether the camera device drives its own on-screen live preview. On is the @@ -185,6 +188,10 @@ table CommandParameters { capture_id: string; capture_session_id: string; capture_camera_index: int; + // SetStreamProfile payload: live preview-encoder reconfiguration. + stream_max_long_edge: int; + stream_bitrate_kbps: int; + stream_fps: int; } // MARK: - Command Structure diff --git a/RemoteCam/FlatBufferSchemas_generated.swift b/RemoteCam/FlatBufferSchemas_generated.swift index 011606d6..0b2559e9 100644 --- a/RemoteCam/FlatBufferSchemas_generated.swift +++ b/RemoteCam/FlatBufferSchemas_generated.swift @@ -37,8 +37,9 @@ public enum RemoteShutter_CommandAction: Int8, Enum, Verifiable { case scheduledcapture = 26 case scheduledstartrecording = 27 case scheduledstoprecording = 28 + case setstreamprofile = 29 - public static var max: RemoteShutter_CommandAction { return .scheduledstoprecording } + public static var max: RemoteShutter_CommandAction { return .setstreamprofile } public static var min: RemoteShutter_CommandAction { return .unknown } } @@ -341,6 +342,9 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { case captureId = 48 case captureSessionId = 50 case captureCameraIndex = 52 + case streamMaxLongEdge = 54 + case streamBitrateKbps = 56 + case streamFps = 58 var v: Int32 { Int32(self.rawValue) } var p: VOffset { self.rawValue } } @@ -375,7 +379,10 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { public var captureSessionId: String? { let o = _accessor.offset(VTOFFSET.captureSessionId.v); return o == 0 ? nil : _accessor.string(at: o) } public var captureSessionIdSegmentArray: [UInt8]? { return _accessor.getVector(at: VTOFFSET.captureSessionId.v) } public var captureCameraIndex: Int32 { let o = _accessor.offset(VTOFFSET.captureCameraIndex.v); return o == 0 ? 0 : _accessor.readBuffer(of: Int32.self, at: o) } - public static func startCommandParameters(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 25) } + public var streamMaxLongEdge: Int32 { let o = _accessor.offset(VTOFFSET.streamMaxLongEdge.v); return o == 0 ? 0 : _accessor.readBuffer(of: Int32.self, at: o) } + public var streamBitrateKbps: Int32 { let o = _accessor.offset(VTOFFSET.streamBitrateKbps.v); return o == 0 ? 0 : _accessor.readBuffer(of: Int32.self, at: o) } + public var streamFps: Int32 { let o = _accessor.offset(VTOFFSET.streamFps.v); return o == 0 ? 0 : _accessor.readBuffer(of: Int32.self, at: o) } + public static func startCommandParameters(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 28) } 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) } @@ -402,6 +409,9 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { public static func add(captureId: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: captureId, at: VTOFFSET.captureId.p) } public static func add(captureSessionId: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: captureSessionId, at: VTOFFSET.captureSessionId.p) } public static func add(captureCameraIndex: Int32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: captureCameraIndex, def: 0, at: VTOFFSET.captureCameraIndex.p) } + public static func add(streamMaxLongEdge: Int32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: streamMaxLongEdge, def: 0, at: VTOFFSET.streamMaxLongEdge.p) } + public static func add(streamBitrateKbps: Int32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: streamBitrateKbps, def: 0, at: VTOFFSET.streamBitrateKbps.p) } + public static func add(streamFps: Int32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: streamFps, def: 0, at: VTOFFSET.streamFps.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, @@ -429,7 +439,10 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { captureAnchorMs: UInt64 = 0, captureIdOffset captureId: Offset = Offset(), captureSessionIdOffset captureSessionId: Offset = Offset(), - captureCameraIndex: Int32 = 0 + captureCameraIndex: Int32 = 0, + streamMaxLongEdge: Int32 = 0, + streamBitrateKbps: Int32 = 0, + streamFps: Int32 = 0 ) -> Offset { let __start = RemoteShutter_CommandParameters.startCommandParameters(&fbb) RemoteShutter_CommandParameters.add(sendToRemote: sendToRemote, &fbb) @@ -457,6 +470,9 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { RemoteShutter_CommandParameters.add(captureId: captureId, &fbb) RemoteShutter_CommandParameters.add(captureSessionId: captureSessionId, &fbb) RemoteShutter_CommandParameters.add(captureCameraIndex: captureCameraIndex, &fbb) + RemoteShutter_CommandParameters.add(streamMaxLongEdge: streamMaxLongEdge, &fbb) + RemoteShutter_CommandParameters.add(streamBitrateKbps: streamBitrateKbps, &fbb) + RemoteShutter_CommandParameters.add(streamFps: streamFps, &fbb) return RemoteShutter_CommandParameters.endCommandParameters(&fbb, start: __start) } @@ -487,6 +503,9 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { try _v.visit(field: VTOFFSET.captureId.p, fieldName: "captureId", required: false, type: ForwardOffset.self) try _v.visit(field: VTOFFSET.captureSessionId.p, fieldName: "captureSessionId", required: false, type: ForwardOffset.self) try _v.visit(field: VTOFFSET.captureCameraIndex.p, fieldName: "captureCameraIndex", required: false, type: Int32.self) + try _v.visit(field: VTOFFSET.streamMaxLongEdge.p, fieldName: "streamMaxLongEdge", required: false, type: Int32.self) + try _v.visit(field: VTOFFSET.streamBitrateKbps.p, fieldName: "streamBitrateKbps", required: false, type: Int32.self) + try _v.visit(field: VTOFFSET.streamFps.p, fieldName: "streamFps", required: false, type: Int32.self) _v.finish() } } diff --git a/RemoteCam/FrameStreamer.swift b/RemoteCam/FrameStreamer.swift index 000b1cfc..daee4161 100644 --- a/RemoteCam/FrameStreamer.swift +++ b/RemoteCam/FrameStreamer.swift @@ -38,6 +38,12 @@ final class FrameStreamer { private let config: StreamingConfig private let send: Send + /// The live preview profile (resolution/bitrate/fps). Starts at the full + /// peer profile — byte-identical to the 1:1 stream — and only a multicam + /// director changes it (via `applyProfile`) to tier previews. Capture-queue + /// confined, like every other field here. + private var activeProfile: StreamProfile + /// Remaining still-codec chain; first entry is the active still encoder. private var encoders: [FrameEncoding] private var frameCount = 0 @@ -55,7 +61,7 @@ final class FrameStreamer { /// Builds the stateful video encoder (HEVC preferred, VP9 fallback) on first /// use; nil when no video codec is available at runtime or in tests that /// don't exercise video. - private let makeVideoEncoder: () -> StreamVideoEncoding? + private let makeVideoEncoder: (StreamProfile) -> StreamVideoEncoding? private var videoEncoder: StreamVideoEncoding? /// Latched when the video encoder fails on this hardware: never retried, the /// stream stays on stills for the rest of the connection. @@ -74,16 +80,36 @@ final class FrameStreamer { encoders: [FrameEncoding]? = nil, creditAvailable: @escaping () -> Bool = { true }, takeKeyframeRequest: @escaping () -> Bool = { false }, - makeVideoEncoder: @escaping () -> StreamVideoEncoding? = { nil }, + makeVideoEncoder: @escaping (StreamProfile) -> StreamVideoEncoding? = { _ in nil }, send: @escaping Send) { self.config = config self.send = send - self.encoders = encoders ?? Self.makeEncoders(config: config) + // The default profile reproduces today's peer stream exactly, so a + // streamer that is never re-profiled behaves identically to before. + self.activeProfile = StreamProfile( + maxLongEdge: config.maxLongEdge, + bitrateKbps: config.peerHEVC.bitrateKbps, + fps: config.peerHEVC.fps) + self.encoders = encoders ?? Self.makeEncoders(config: config, maxLongEdge: config.maxLongEdge) self.creditAvailable = creditAvailable self.takeKeyframeRequest = takeKeyframeRequest self.makeVideoEncoder = makeVideoEncoder } + /// Multicam: switch this stream to a new preview profile. Capture-queue + /// confined (called from `FrameSender`). Rebuilds the still chain and drops + /// the video encoder so it is rebuilt at the new resolution on the next + /// frame — the same rebuild path the failure fallback already uses, so the + /// new encoder's first frame is a keyframe the monitor re-syncs on. + func applyProfile(_ profile: StreamProfile) { + guard profile != activeProfile else { return } + activeProfile = profile + encoders = Self.makeEncoders(config: config, maxLongEdge: profile.maxLongEdge) + // Only rebuild a video encoder that is actually running; a permanently + // failed one stays failed (stills). + if videoEncoder != nil { videoEncoder = nil } + } + /// The active still-codec head (used by tests). The video stream, when /// enabled, is a separate path and not reflected here. var activeCodec: RemoteCmd.StreamCodec? { encoders.first?.codec } @@ -110,7 +136,7 @@ final class FrameStreamer { /// is available and the dev-only still fallback runs instead. private var useVideo: Bool { guard !videoFailed else { return false } - if videoEncoder == nil { videoEncoder = makeVideoEncoder() } + if videoEncoder == nil { videoEncoder = makeVideoEncoder(activeProfile) } return videoEncoder != nil } @@ -170,13 +196,13 @@ final class FrameStreamer { )) } - private static func makeEncoders(config: StreamingConfig) -> [FrameEncoding] { + private static func makeEncoders(config: StreamingConfig, maxLongEdge: CGFloat) -> [FrameEncoding] { config.preferredCodecs.compactMap { codec in switch codec { case .heic: - return HEICFrameEncoder(maxLongEdge: config.maxLongEdge, quality: config.heicQuality) + return HEICFrameEncoder(maxLongEdge: maxLongEdge, quality: config.heicQuality) case .jpeg: - return JPEGFrameEncoder(maxLongEdge: config.maxLongEdge, quality: config.jpegQuality) + return JPEGFrameEncoder(maxLongEdge: maxLongEdge, quality: config.jpegQuality) case .hevc: return nil // video codec: follow-up PR (HEVCFrameEncoder) case .vp9: diff --git a/RemoteCam/FrameStreamingCoordinator.swift b/RemoteCam/FrameStreamingCoordinator.swift index 718d254a..2a94594b 100644 --- a/RemoteCam/FrameStreamingCoordinator.swift +++ b/RemoteCam/FrameStreamingCoordinator.swift @@ -52,15 +52,22 @@ final class FrameStreamingCoordinator: NSObject { private lazy var frameStreamer = FrameStreamer( creditAvailable: frameCreditAvailable, takeKeyframeRequest: takePeerKeyframeRequest, - makeVideoEncoder: { + makeVideoEncoder: { profile in // Prefer hardware HEVC; fall back to software VP9; nil only on an // arch with neither (dev-only), where the stream drops to stills. + // The multicam profile overrides resolution/bitrate/fps; the rest of + // the encoder tuning (keyframe interval, quantizers) stays put. let config = StreamingConfig.default if HEVCSupport.canEncode { - return HEVCFrameEncoder(maxLongEdge: config.maxLongEdge, settings: config.peerHEVC) + let hevc = HEVCSettings(bitrateKbps: profile.bitrateKbps, fps: profile.fps, + keyframeInterval: config.peerHEVC.keyframeInterval) + return HEVCFrameEncoder(maxLongEdge: profile.maxLongEdge, settings: hevc) } if VP9Support.isAvailable { - return VP9FrameEncoder(maxLongEdge: config.maxLongEdge, settings: config.peerVP9) + var vp9 = config.peerVP9 + vp9.bitrateKbps = profile.bitrateKbps + vp9.fps = profile.fps + return VP9FrameEncoder(maxLongEdge: profile.maxLongEdge, settings: vp9) } return nil }, @@ -111,6 +118,15 @@ final class FrameStreamingCoordinator: NSObject { watchPreviewStreamer.acknowledge() } + /// Multicam: retune the phone-monitor preview stream. Hops onto the capture + /// queue the streamer lives on, so the reconfigure is serialized with frame + /// encoding. + func applyStreamProfile(_ profile: StreamProfile) { + engine.dataOutputQueue.async { [weak self] in + self?.frameStreamer.applyProfile(profile) + } + } + /// Wall-clock of the most recent video sample buffer, for the rig's /// first-frame watchdog (a suspended or stalled camera delivers nothing, /// forever). Written per-frame on the data queue, read from the watchdog. diff --git a/RemoteCam/MultiCamChrome.swift b/RemoteCam/MultiCamChrome.swift new file mode 100644 index 00000000..c141d548 --- /dev/null +++ b/RemoteCam/MultiCamChrome.swift @@ -0,0 +1,36 @@ +// +// MultiCamChrome.swift +// RemoteShutter +// +// Copyright © 2026 Security Union LLC. All rights reserved. +// + +import CoreGraphics +import Foundation + +/// How the director screen lays out the cameras. +enum MulticamDisplayMode: Equatable { + /// One focused viewfinder + a thumbnail strip of the others (the default). + case focus + /// An equal grid of every camera — the "monitor wall" for checking angles. + case grid +} + +/// Pure layout policy for the multicam director screen, kept out of the views +/// so it is unit-testable without a host (mirrors `MonitorChrome`). +enum MultiCamChrome { + + /// Columns for the grid: a near-square arrangement — 2 cameras sit 2-up, + /// 3–4 form a 2×2, and it keeps growing by √n for any future larger rig. + /// One camera has no grid (the focus view fills the screen). + static func gridColumnCount(cameraCount: Int) -> Int { + guard cameraCount > 1 else { return 1 } + return max(1, Int(ceil(Double(cameraCount).squareRoot()))) + } + + /// The grid toggle is offered only when there is more than one camera — + /// with a single camera the focus view already is the whole screen. + static func showsGridToggle(cameraCount: Int) -> Bool { + cameraCount > 1 + } +} diff --git a/RemoteCam/MulticamController.swift b/RemoteCam/MulticamController.swift index e01dd128..35c25f69 100644 --- a/RemoteCam/MulticamController.swift +++ b/RemoteCam/MulticamController.swift @@ -264,9 +264,11 @@ public actor MulticamController { if link.status != .failed { link.status = .linked } publishLanes() // A multicam-capable camera gets an immediate clock probe so its - // offset is ready well before the first synced capture (PR4). + // offset is ready well before the first synced capture (PR4), and + // its preview tier (full if focused, else thumbnail). if link.supportsMulticam { sendTo(peer, RemoteCmd.ClockSyncPing(t0Millis: SyncClock.nowMillis())) + pushProfile(to: peer) } case let ack as RemoteCmd.ScheduledCaptureAck: @@ -366,9 +368,32 @@ public actor MulticamController { func setFocusedPeer(_ peer: MCPeerID) { guard links[peer] != nil else { return } focusedPeer = peer + // Retier previews: the newly focused camera goes full-size, the rest + // (including the one that just lost focus) drop to thumbnail. + for p in order { pushProfile(to: p) } publishLanes() } + /// The preview tier a camera should be on: full for the focused lane, + /// thumbnail for the rest. + private func desiredProfile(for peer: MCPeerID) -> StreamProfile { + peer == focusedPeer ? .focused : .thumbnail + } + + /// Push a camera its preview profile, but only when it actually changes — + /// and only to a multicam-capable peer (an old peer would misread the + /// unknown action). + private func pushProfile(to peer: MCPeerID) { + guard let link = links[peer], link.supportsMulticam else { return } + let profile = desiredProfile(for: peer) + guard link.lastSentProfile != profile else { return } + link.lastSentProfile = profile + sendTo(peer, RemoteCmd.SetStreamProfile( + maxLongEdge: Int(profile.maxLongEdge), + bitrateKbps: Int(profile.bitrateKbps), + fps: Int(profile.fps))) + } + /// Logically remove a camera from the rig. The QUIC session has no /// per-peer teardown, so this stops the lane (no more acks/handshakes) and /// drops it from the UI; the peer times out on its side. A true per-peer diff --git a/RemoteCam/MulticamView.swift b/RemoteCam/MulticamView.swift index 1fb03469..e3247392 100644 --- a/RemoteCam/MulticamView.swift +++ b/RemoteCam/MulticamView.swift @@ -31,11 +31,63 @@ struct MulticamView: View { ZStack { Color.black.ignoresSafeArea() - focusedViewfinder - - stripOverlay(dock: dock) + if viewModel.displayMode == .grid { + gridWall + } else { + focusedViewfinder + stripOverlay(dock: dock) + } + // The capture cluster stays in both modes; per-camera controls + // (the strip) are hidden in grid. shutterOverlay(dock: dock) + gridToggle + } + } + } + + /// An equal grid of every camera — the monitor wall. Tap a tile to focus it + /// (and return to focus mode). + private var gridWall: some View { + let count = viewModel.lanes.count + let columns = Array( + repeating: GridItem(.flexible(), spacing: 4), + count: MultiCamChrome.gridColumnCount(cameraCount: count)) + return LazyVGrid(columns: columns, spacing: 4) { + ForEach(viewModel.lanes) { lane in + CameraTileView(lane: lane, isThumbnail: true) + .aspectRatio(9.0 / 16.0, contentMode: .fit) + .onTapGesture { + onFocusLane(lane) + viewModel.displayMode = .focus + } + } + } + .padding(8) + } + + /// Focus/grid switch, only when there's more than one camera. + @ViewBuilder + private var gridToggle: some View { + if MultiCamChrome.showsGridToggle(cameraCount: viewModel.lanes.count) { + VStack { + HStack { + Button { + viewModel.displayMode = viewModel.displayMode == .grid ? .focus : .grid + } label: { + Image(systemName: viewModel.displayMode == .grid + ? "rectangle.inset.filled" : "square.grid.2x2.fill") + .font(.title3) + .foregroundColor(.white) + .frame(width: 44, height: 44) + .background(Color.black.opacity(0.4)) + .clipShape(Circle()) + } + .padding(.leading, 12) + .padding(.top, 12) + Spacer() + } + Spacer() } } } diff --git a/RemoteCam/MulticamViewModel.swift b/RemoteCam/MulticamViewModel.swift index c68d0229..01f8b9a3 100644 --- a/RemoteCam/MulticamViewModel.swift +++ b/RemoteCam/MulticamViewModel.swift @@ -56,6 +56,8 @@ final class MulticamViewModel: ObservableObject { @Published var isRecording: Bool = false /// Photo vs video shutter mode. @Published var mode: MonitorMode = .photo + /// Focus (viewfinder + strip) vs grid (monitor wall) layout. + @Published var displayMode: MulticamDisplayMode = .focus var focusedLane: CameraLane? { lanes.first { $0.isFocused } } var otherLanes: [CameraLane] { lanes.filter { !$0.isFocused } } diff --git a/RemoteCam/RemoteCmdFlatBuffers.swift b/RemoteCam/RemoteCmdFlatBuffers.swift index ce858850..2ff3ba77 100644 --- a/RemoteCam/RemoteCmdFlatBuffers.swift +++ b/RemoteCam/RemoteCmdFlatBuffers.swift @@ -36,6 +36,7 @@ func serializeToFlatBuffer(_ msg: Message) -> Data? { case let m as RemoteCmd.ScheduledStartRecording: return m.toFlatBuffer() case let m as RemoteCmd.ScheduledStopRecording: return m.toFlatBuffer() case let m as RemoteCmd.ScheduledRecordingAck: return m.toFlatBuffer() + case let m as RemoteCmd.SetStreamProfile: 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() @@ -833,6 +834,18 @@ extension RemoteCmd.ScheduledStopRecording { } } +extension RemoteCmd.SetStreamProfile { + func toFlatBuffer() -> Data { + var fbb = FlatBufferBuilder() + let params = RemoteShutter_CommandParameters.createCommandParameters( + &fbb, + streamMaxLongEdge: Int32(maxLongEdge), + streamBitrateKbps: Int32(bitrateKbps), + streamFps: Int32(fps)) + return buildCommand(&fbb, action: .setstreamprofile, parameters: params) + } +} + extension RemoteCmd.ScheduledRecordingAck { func toFlatBuffer() -> Data { var fbb = FlatBufferBuilder() @@ -1236,6 +1249,12 @@ extension RemoteCmd { sessionId: params?.captureSessionId ?? "", cameraIndex: Int(params?.captureCameraIndex ?? 0)) + case .setstreamprofile: + return SetStreamProfile( + maxLongEdge: Int(params?.streamMaxLongEdge ?? 0), + bitrateKbps: Int(params?.streamBitrateKbps ?? 0), + fps: Int(params?.streamFps ?? 0)) + case .toggleflash: return ToggleFlash() diff --git a/RemoteCam/RemoteCmds.swift b/RemoteCam/RemoteCmds.swift index a13d326c..78b1d19b 100644 --- a/RemoteCam/RemoteCmds.swift +++ b/RemoteCam/RemoteCmds.swift @@ -324,6 +324,22 @@ public class RemoteCmd: Message, @unchecked Sendable { } } + /// Director → camera: reconfigure the live preview encoder for tiered + /// multicam previews (the focused lane full-size, the rest thumbnails). + /// Only sent to `supportsMulticam` peers. + public class SetStreamProfile: Message, @unchecked Sendable { + public let maxLongEdge: Int + public let bitrateKbps: Int + public let fps: Int + + public init(maxLongEdge: Int, bitrateKbps: Int, fps: Int, sender: AnyObject? = nil) { + self.maxLongEdge = maxLongEdge + self.bitrateKbps = bitrateKbps + self.fps = fps + super.init(sender: sender) + } + } + public class OnFrame: Message, @unchecked Sendable { public let data: Data public let peerId: MCPeerID diff --git a/RemoteCam/SessionCoordinator.swift b/RemoteCam/SessionCoordinator.swift index 647bd92d..ee9ff87b 100644 --- a/RemoteCam/SessionCoordinator.swift +++ b/RemoteCam/SessionCoordinator.swift @@ -1026,6 +1026,12 @@ public actor SessionCoordinator { case let scheduled as RemoteCmd.ScheduledStartRecording: await handleScheduledStartRecording(scheduled) + case let profile as RemoteCmd.SetStreamProfile: + ctrl.applyStreamProfile(StreamProfile( + maxLongEdge: CGFloat(profile.maxLongEdge), + bitrateKbps: UInt32(max(0, profile.bitrateKbps)), + fps: UInt32(max(0, profile.fps)))) + case let fire as FireScheduledRecordingStart: // The start instant arrived. Stamp the recording with its sync // metadata, then roll exactly as a normal StartRecordingVideo — @@ -1439,6 +1445,14 @@ public actor SessionCoordinator { case let scheduled as RemoteCmd.ScheduledStopRecording: await handleScheduledStopRecording(scheduled) + case let profile as RemoteCmd.SetStreamProfile: + // The preview keeps streaming while recording, so a focus change + // can retune this lane here too. + ctrl.applyStreamProfile(StreamProfile( + maxLongEdge: CGFloat(profile.maxLongEdge), + bitrateKbps: UInt32(max(0, profile.bitrateKbps)), + fps: UInt32(max(0, profile.fps)))) + case is FireScheduledRecordingStop: // The scheduled stop instant arrived. Save locally only (multicam // never auto-transfers in v1) and return to the camera screen. diff --git a/RemoteCam/StreamingConfig.swift b/RemoteCam/StreamingConfig.swift index be7d333d..be0f655f 100644 --- a/RemoteCam/StreamingConfig.swift +++ b/RemoteCam/StreamingConfig.swift @@ -150,3 +150,20 @@ struct StreamingConfig { static let `default` = StreamingConfig() } + +/// A live-adjustable preview profile for one multicam lane. The director sends +/// the focused camera the full profile and the others a smaller thumbnail +/// profile, so N previews fit the aggregate bandwidth + decode budget. Only the +/// three levers that matter for that trade-off are here; the encoders keep +/// their other tuning (quantizers, keyframe interval) from `StreamingConfig`. +struct StreamProfile: Equatable { + var maxLongEdge: CGFloat + var bitrateKbps: UInt32 + var fps: UInt32 + + /// The focused lane: today's full peer preview (unchanged from 1:1). + static let focused = StreamProfile(maxLongEdge: 1200, bitrateKbps: 1200, fps: 30) + /// An unfocused strip/grid tile: smaller and cheaper to decode, so four of + /// them stay within ~2.7 Mbps aggregate and a sane VideoToolbox load. + static let thumbnail = StreamProfile(maxLongEdge: 640, bitrateKbps: 500, fps: 20) +} diff --git a/RemoteCamTests/FrameStreamingTests.swift b/RemoteCamTests/FrameStreamingTests.swift index e3f4fb2d..2f288564 100644 --- a/RemoteCamTests/FrameStreamingTests.swift +++ b/RemoteCamTests/FrameStreamingTests.swift @@ -172,7 +172,7 @@ final class FrameStreamerTests: XCTestCase { encoders: stillEncoders, creditAvailable: creditAvailable, takeKeyframeRequest: takeKeyframeRequest, - makeVideoEncoder: { vp9 } + makeVideoEncoder: { _ in vp9 } ) { [weak self] frame in self?.sentFrames.append(frame) } } @@ -185,6 +185,35 @@ final class FrameStreamerTests: XCTestCase { XCTAssertEqual(sentFrames.map(\.codec), [.vp9]) } + /// Multicam: a profile change rebuilds the video encoder (at the new + /// resolution) — the same drop-and-rebuild path the failure fallback uses, + /// so the next frame comes from a fresh encoder built with the new profile. + func testApplyProfileRebuildsTheVideoEncoder() { + var built: [StreamProfile] = [] + var config = StreamingConfig.default + config.frameDivisor = 1 + let streamer = FrameStreamer( + config: config, + encoders: [FakeEncoder(codec: .heic, result: Data([1]))], + makeVideoEncoder: { profile in built.append(profile); return FakeVideoEncoder() } + ) { [weak self] frame in self?.sentFrames.append(frame) } + + // First frame builds the encoder at the default (focused-equivalent) profile. + streamer.handle(pixelBuffer: makePixelBuffer(), position: .back, orientation: .portrait, fps: 30) + XCTAssertEqual(built.count, 1) + + // Switch to the thumbnail profile → the next frame rebuilds at 640. + streamer.applyProfile(.thumbnail) + streamer.handle(pixelBuffer: makePixelBuffer(), position: .back, orientation: .portrait, fps: 30) + XCTAssertEqual(built.count, 2) + XCTAssertEqual(built.last?.maxLongEdge, StreamProfile.thumbnail.maxLongEdge) + + // Re-applying the same profile does not force another rebuild. + streamer.applyProfile(.thumbnail) + streamer.handle(pixelBuffer: makePixelBuffer(), position: .back, orientation: .portrait, fps: 30) + XCTAssertEqual(built.count, 2) + } + /// Dev-only fallback: when VP9 is unavailable at runtime (factory returns /// nil) the stream stays alive on stills. No per-peer negotiation involved. func testFallsBackToStillsWhenVP9Unavailable() { diff --git a/RemoteCamTests/MultiCamChromeTests.swift b/RemoteCamTests/MultiCamChromeTests.swift new file mode 100644 index 00000000..ca69cba5 --- /dev/null +++ b/RemoteCamTests/MultiCamChromeTests.swift @@ -0,0 +1,36 @@ +// +// MultiCamChromeTests.swift +// RemoteShutterTests +// +// Copyright © 2026 Security Union LLC. All rights reserved. +// + +import XCTest +@testable import RemoteShutter + +final class MultiCamChromeTests: XCTestCase { + + func testGridColumnsAreNearSquare() { + XCTAssertEqual(MultiCamChrome.gridColumnCount(cameraCount: 1), 1) + XCTAssertEqual(MultiCamChrome.gridColumnCount(cameraCount: 2), 2) // 2-up + XCTAssertEqual(MultiCamChrome.gridColumnCount(cameraCount: 3), 2) // 2×2 + XCTAssertEqual(MultiCamChrome.gridColumnCount(cameraCount: 4), 2) // 2×2 + XCTAssertEqual(MultiCamChrome.gridColumnCount(cameraCount: 9), 3) // grows by √n + } + + func testGridToggleOnlyWhenMoreThanOneCamera() { + XCTAssertFalse(MultiCamChrome.showsGridToggle(cameraCount: 1)) + XCTAssertTrue(MultiCamChrome.showsGridToggle(cameraCount: 2)) + XCTAssertTrue(MultiCamChrome.showsGridToggle(cameraCount: 4)) + } + + func testStreamProfilePresets() { + // The focused tier reproduces today's 1:1 peer preview. + XCTAssertEqual(StreamProfile.focused.maxLongEdge, 1200) + XCTAssertEqual(StreamProfile.focused.fps, 30) + // The thumbnail tier is smaller and cheaper on every axis. + XCTAssertLessThan(StreamProfile.thumbnail.maxLongEdge, StreamProfile.focused.maxLongEdge) + XCTAssertLessThan(StreamProfile.thumbnail.bitrateKbps, StreamProfile.focused.bitrateKbps) + XCTAssertLessThan(StreamProfile.thumbnail.fps, StreamProfile.focused.fps) + } +} diff --git a/RemoteCamTests/MulticamControllerTests.swift b/RemoteCamTests/MulticamControllerTests.swift index 422875e4..0ae7a8d6 100644 --- a/RemoteCamTests/MulticamControllerTests.swift +++ b/RemoteCamTests/MulticamControllerTests.swift @@ -277,6 +277,57 @@ final class MulticamControllerTests: XCTestCase { XCTAssertEqual(fallbackState?.remaining, 2) } + // MARK: - Stream profile tiering + + func testFocusedLaneGetsFullProfileOthersGetThumbnail() async { + let (controller, transport, _) = await makeController(peers: [camA, camB]) + transport.sentMessages.removeAll() + + // Both cameras report multicam caps; camA is focused (first-in). + controller.didReceiveMessage(multicamCaps(), from: camA) + controller.didReceiveMessage(multicamCaps(), from: camB) + await controller.waitForIdle() + + let profiles = sent(transport, RemoteCmd.SetStreamProfile.self) + func profile(_ p: MCPeerID) -> RemoteCmd.SetStreamProfile? { + profiles.first { $0.peers == [p] }?.msg as? RemoteCmd.SetStreamProfile + } + XCTAssertEqual(profile(camA)?.maxLongEdge, Int(StreamProfile.focused.maxLongEdge)) + XCTAssertEqual(profile(camB)?.maxLongEdge, Int(StreamProfile.thumbnail.maxLongEdge)) + } + + func testFocusSwitchRetiersBothLanes() async { + let (controller, transport, _) = await makeController(peers: [camA, camB]) + controller.didReceiveMessage(multicamCaps(), from: camA) + controller.didReceiveMessage(multicamCaps(), from: camB) + await controller.waitForIdle() + transport.sentMessages.removeAll() + + await controller.setFocusedPeer(camB) + await controller.waitForIdle() + + let profiles = sent(transport, RemoteCmd.SetStreamProfile.self) + func edge(_ p: MCPeerID) -> Int? { + (profiles.first { $0.peers == [p] }?.msg as? RemoteCmd.SetStreamProfile)?.maxLongEdge + } + // camB becomes full, camA drops to thumbnail — no redundant re-sends. + XCTAssertEqual(edge(camB), Int(StreamProfile.focused.maxLongEdge)) + XCTAssertEqual(edge(camA), Int(StreamProfile.thumbnail.maxLongEdge)) + } + + func testProfileNotResentWhenUnchanged() async { + let (controller, transport, _) = await makeController(peers: [camA, camB]) + controller.didReceiveMessage(multicamCaps(), from: camA) + controller.didReceiveMessage(multicamCaps(), from: camB) + await controller.waitForIdle() + transport.sentMessages.removeAll() + + // Focusing the already-focused camera changes no tier → no profile sends. + await controller.setFocusedPeer(camA) + await controller.waitForIdle() + XCTAssertTrue(sent(transport, RemoteCmd.SetStreamProfile.self).isEmpty) + } + // MARK: - Synced video func testStartAndStopAnchorsGiveMatchingClipLengths() async { diff --git a/RemoteCamTests/RemoteCmdSerializationTests.swift b/RemoteCamTests/RemoteCmdSerializationTests.swift index 4e52e4d3..40fd68e5 100644 --- a/RemoteCamTests/RemoteCmdSerializationTests.swift +++ b/RemoteCamTests/RemoteCmdSerializationTests.swift @@ -54,6 +54,7 @@ final class RemoteCmdSerializationTests: XCTestCase { case let m as RemoteCmd.ScheduledStartRecording: return m.toFlatBuffer() case let m as RemoteCmd.ScheduledStopRecording: return m.toFlatBuffer() case let m as RemoteCmd.ScheduledRecordingAck: return m.toFlatBuffer() + case let m as RemoteCmd.SetStreamProfile: 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() @@ -1261,6 +1262,14 @@ extension RemoteCmdSerializationTests { XCTAssertNotNil(nack.error) } + func testSetStreamProfile_roundTrip() { + let result = roundTrip(RemoteCmd.SetStreamProfile( + maxLongEdge: 640, bitrateKbps: 500, fps: 20)) + XCTAssertEqual(result.maxLongEdge, 640) + XCTAssertEqual(result.bitrateKbps, 500) + XCTAssertEqual(result.fps, 20) + } + /// The multicam capability survives the wire, and a peer that predates it /// (absent field) decodes as not-multicam-capable. func testCapabilitiesCarryMulticamSupport() { diff --git a/RemoteCamTests/SessionTestSupport.swift b/RemoteCamTests/SessionTestSupport.swift index 7127e5a0..936964fd 100644 --- a/RemoteCamTests/SessionTestSupport.swift +++ b/RemoteCamTests/SessionTestSupport.swift @@ -135,6 +135,8 @@ class FakeCameraControlling: CameraControlling, @unchecked Sendable { func stopRecordingVideo(_ shouldSendVideo: Bool) { stopRecordingCalls.append(shouldSendVideo) } var videoSyncMetadata: CaptureSyncMetadata? func setVideoSyncMetadata(_ metadata: CaptureSyncMetadata?) { videoSyncMetadata = metadata } + 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) { diff --git a/RemoteShutter.xcodeproj/project.pbxproj b/RemoteShutter.xcodeproj/project.pbxproj index 9de1589e..25779ec2 100644 --- a/RemoteShutter.xcodeproj/project.pbxproj +++ b/RemoteShutter.xcodeproj/project.pbxproj @@ -203,6 +203,8 @@ CAFEBABE0120000000000001 /* MonitorChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0120000000000002 /* MonitorChrome.swift */; }; CAFEBABE0130000000000001 /* CaptureSyncMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */; }; CAFEBABE0132000000000001 /* ClockOffsetEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0132000000000002 /* ClockOffsetEstimator.swift */; }; + CAFEBABE0150000000000001 /* MultiCamChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0150000000000002 /* MultiCamChrome.swift */; }; + CAFEBABE0151000000000002 /* MultiCamChromeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0151000000000001 /* MultiCamChromeTests.swift */; }; CAFEBABE0140000000000001 /* CameraLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0140000000000002 /* CameraLink.swift */; }; CAFEBABE0141000000000001 /* MulticamController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0141000000000002 /* MulticamController.swift */; }; CAFEBABE0142000000000001 /* MulticamViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0142000000000002 /* MulticamViewModel.swift */; }; @@ -455,6 +457,8 @@ CAFEBABE0120000000000002 /* MonitorChrome.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MonitorChrome.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 = ""; }; + CAFEBABE0150000000000002 /* MultiCamChrome.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MultiCamChrome.swift; sourceTree = ""; }; + CAFEBABE0151000000000001 /* MultiCamChromeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiCamChromeTests.swift; sourceTree = ""; }; CAFEBABE0140000000000002 /* CameraLink.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraLink.swift; sourceTree = ""; }; CAFEBABE0141000000000002 /* MulticamController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MulticamController.swift; sourceTree = ""; }; CAFEBABE0142000000000002 /* MulticamViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MulticamViewModel.swift; sourceTree = ""; }; @@ -615,6 +619,7 @@ CAFEBABE0121000000000001 /* MonitorChromeTests.swift */, CAFEBABE0131000000000001 /* CaptureSyncMetadataTests.swift */, CAFEBABE0133000000000001 /* ClockOffsetEstimatorTests.swift */, + CAFEBABE0151000000000001 /* MultiCamChromeTests.swift */, CAFEBABE0145000000000001 /* MulticamControllerTests.swift */, CAFEBABE0146000000000001 /* MulticamViewModelTests.swift */, CAFEBABE0002000000000001 /* WatchCaptureCountdownTests.swift */, @@ -711,6 +716,7 @@ CAFEBABE0120000000000002 /* MonitorChrome.swift */, CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */, CAFEBABE0132000000000002 /* ClockOffsetEstimator.swift */, + CAFEBABE0150000000000002 /* MultiCamChrome.swift */, CAFEBABE0140000000000002 /* CameraLink.swift */, CAFEBABE0141000000000002 /* MulticamController.swift */, CAFEBABE0142000000000002 /* MulticamViewModel.swift */, @@ -1210,6 +1216,7 @@ CAFEBABE0120000000000001 /* MonitorChrome.swift in Sources */, CAFEBABE0130000000000001 /* CaptureSyncMetadata.swift in Sources */, CAFEBABE0132000000000001 /* ClockOffsetEstimator.swift in Sources */, + CAFEBABE0150000000000001 /* MultiCamChrome.swift in Sources */, CAFEBABE0140000000000001 /* CameraLink.swift in Sources */, CAFEBABE0141000000000001 /* MulticamController.swift in Sources */, CAFEBABE0142000000000001 /* MulticamViewModel.swift in Sources */, @@ -1289,6 +1296,7 @@ CAFEBABE0121000000000002 /* MonitorChromeTests.swift in Sources */, CAFEBABE0131000000000002 /* CaptureSyncMetadataTests.swift in Sources */, CAFEBABE0133000000000002 /* ClockOffsetEstimatorTests.swift in Sources */, + CAFEBABE0151000000000002 /* MultiCamChromeTests.swift in Sources */, CAFEBABE0145000000000002 /* MulticamControllerTests.swift in Sources */, CAFEBABE0146000000000002 /* MulticamViewModelTests.swift in Sources */, CAFEBABE0002000000000002 /* WatchCaptureCountdownTests.swift in Sources */, From 6b676d7b2c3d4f2d4764521ef6700594751a9a05 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Tue, 11 Aug 2026 00:13:16 -0700 Subject: [PATCH 08/17] Multicam commit B: add-camera flow + free-2 / Pro-4 gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StoreManager.maxCameras() { hasFullAccess() ? 4 : 2 } beside the other gates, with StoreManagerTests coverage. The director's own entitlement is what counts (checked locally, like every existing gate). Enforced at invite time in BOTH paths: - Scanner multi-select: a connect past the cap routes to the paywall instead of inviting (only when collecting; the count stays 0 otherwise, so single-cam is untouched). - In-session AddCameraSheet: the director keeps browsing; discovered-but-not- joined cameras are surfaced (MulticamController tracks an available set and publishes it) and listed in a sheet reached from an "Add camera" tile at the end of the strip. Tapping the tile at the cap opens the paywall; below the cap it opens the sheet. inviteCamera() invites a chosen peer. The paywall is the existing SettingsView sheet in both places — no bespoke multicam paywall. Its Pro footer gains "Direct up to 4 cameras at once" (only when the flag is on). Role picker reframes the monitor role as "Director" / "Control one or more iPhone cameras" under the flag; single-cam keeps "Remote" / "Control the shutter". Tests: maxCameras 2 free / 4 pro (mode + subscription); a discovered peer becomes available without auto-joining; inviteCamera invites it (and ignores an undiscovered one); an available peer clears once it joins the rig. Full suite green (700). Co-Authored-By: Claude Fable 5 --- RemoteCam/DeviceScannerViewController.swift | 15 +++ RemoteCam/MulticamController.swift | 51 ++++++++- RemoteCam/MulticamView.swift | 108 +++++++++++++++---- RemoteCam/MulticamViewController.swift | 32 ++++++ RemoteCam/MulticamViewModel.swift | 4 + RemoteCam/RolePickerView.swift | 10 +- RemoteCam/SettingsView.swift | 7 +- RemoteCam/StoreManager.swift | 7 ++ RemoteCamTests/MulticamControllerTests.swift | 55 ++++++++++ RemoteCamTests/StoreManagerTests.swift | 16 +++ 10 files changed, 276 insertions(+), 29 deletions(-) diff --git a/RemoteCam/DeviceScannerViewController.swift b/RemoteCam/DeviceScannerViewController.swift index f5f6d36e..44045395 100644 --- a/RemoteCam/DeviceScannerViewController.swift +++ b/RemoteCam/DeviceScannerViewController.swift @@ -179,6 +179,14 @@ public class DeviceScannerViewController: UIViewController { }, onSelectPeer: { [weak self] peer in guard let self = self else { return } + // Multicam collecting: block a connection past the tier cap and + // route to the paywall instead. (Non-multicam is unaffected — + // the count stays 0.) + if FeatureFlags.ENABLE_MULTICAM && self.role == .monitor + && self.scannerViewModel.multicamCollectedCount >= StoreManager.shared.maxCameras() { + self.presentMulticamPaywall() + return + } self.remoteCamSession ! ConnectToDevice(peer: peer, sender: nil) self.scannerViewModel.connectingToPeer() }, @@ -362,6 +370,13 @@ public class DeviceScannerViewController: UIViewController { } } + /// The shared Settings/paywall sheet — the same one the monitor gates use. + private func presentMulticamPaywall() { + let ctrl = UIHostingController(rootView: SettingsView()) + ctrl.modalPresentationStyle = .pageSheet + present(ctrl, animated: true) + } + func goToAppSettings() { #if targetEnvironment(macCatalyst) // Local-network permission lives in System Settings on the Mac. diff --git a/RemoteCam/MulticamController.swift b/RemoteCam/MulticamController.swift index 35c25f69..23591d4b 100644 --- a/RemoteCam/MulticamController.swift +++ b/RemoteCam/MulticamController.swift @@ -60,6 +60,9 @@ protocol MulticamDisplay: AnyObject { /// Aggregate shutter state: `capturing` = a synced photo is in flight /// (activity ring); `recording` = the rig is rolling (record/stop button). func applyShutterState(capturing: Bool, recording: Bool) + /// Cameras the browser has found that aren't in the rig — the add-camera + /// sheet's list. + func applyAvailablePeers(_ peers: [MCPeerID]) func receiveFrame(_ frame: RemoteCmd.OnFrame) func exitMulticam() } @@ -131,6 +134,11 @@ public actor MulticamController { private var links: [MCPeerID: CameraLink] = [:] private var focusedPeer: MCPeerID? + /// Cameras the browser has found that are not in the rig yet — the source + /// list for the in-session "add camera" sheet. Insertion-ordered. + private var availableOrder: [MCPeerID] = [] + private var available: Set = [] + /// One id per director session, part of every capture's filename group. private let sessionID = UUID().uuidString /// Cameras that have yet to answer the in-flight synced capture. Empty @@ -225,6 +233,12 @@ public actor MulticamController { case let found as UICmd.BrowserFoundPeer: handleBrowserFound(found.peer) + case let lost as UICmd.BrowserLostPeer: + if available.remove(lost.peer) != nil { + availableOrder.removeAll { $0 == lost.peer } + publishAvailable() + } + case let routed as RoutedMessage: await handleRouted(routed.message, from: routed.peer) @@ -306,11 +320,22 @@ public actor MulticamController { order.append(peer) links[peer] = CameraLink(peerID: peer) } + // It's in the rig now, so it's no longer an "available" candidate. + if available.remove(peer) != nil { + availableOrder.removeAll { $0 == peer } + publishAvailable() + } focusedPeer = focusedPeer ?? peer beginHandshake(with: peer) publishLanes() } + private func publishAvailable() { + let peers = availableOrder + let display = display + OperationQueue.main.addOperation { display?.applyAvailablePeers(peers) } + } + private func handlePeerDisconnected(_ peer: MCPeerID) { guard let link = links[peer] else { return } // Degrade the tile, keep the rest of the rig recording/monitoring. The @@ -321,12 +346,30 @@ public actor MulticamController { } private func handleBrowserFound(_ peer: MCPeerID) { - // Re-invite only a camera we are actively missing; a fresh peer is a - // job for the add-camera flow (PR7), not an auto-join. - guard let link = links[peer], link.status == .reconnecting else { return } + // A camera we are actively missing is auto-re-invited. + if let link = links[peer], link.status == .reconnecting { + multipeerService?.invitePeer(peer, timeout: reconnectInviteTimeout) + return + } + // An unrelated fresh peer becomes a candidate for the "add camera" + // sheet — it never auto-joins. + guard links[peer] == nil, !available.contains(peer) else { return } + available.insert(peer) + availableOrder.append(peer) + publishAvailable() + } + + /// Invite a discovered camera into the rig (the "add camera" flow). The + /// tier cap is enforced by the UI before this is called. + func inviteCamera(_ peer: MCPeerID) { + guard available.contains(peer) else { return } multipeerService?.invitePeer(peer, timeout: reconnectInviteTimeout) } + /// The current number of cameras in the rig — the UI checks this against + /// `StoreManager.maxCameras()` before offering to add another. + func cameraCount() -> Int { order.count } + private func armReconnect(_ peer: MCPeerID) { let delay = reconnectRetryDelay Task { [weak self] in @@ -428,6 +471,8 @@ public actor MulticamController { } func captureOutcomeForTesting(_ peer: MCPeerID) -> CaptureOutcome? { links[peer]?.captureOutcome } func isRecordingForTesting(_ peer: MCPeerID) -> Bool { links[peer]?.isRecording ?? false } + func availablePeersForTesting() -> [MCPeerID] { availableOrder } + func cameraCountForTesting() -> Int { order.count } /// Test seam: put a lane in the state a real handshake would — linked, with /// known multicam capability and (optionally) a known clock offset — so diff --git a/RemoteCam/MulticamView.swift b/RemoteCam/MulticamView.swift index e3247392..9311e1de 100644 --- a/RemoteCam/MulticamView.swift +++ b/RemoteCam/MulticamView.swift @@ -5,6 +5,7 @@ // Copyright © 2026 Security Union LLC. All rights reserved. // +import MPCCompat import SwiftUI /// The director screen in focus mode: the selected camera fills the viewfinder @@ -20,6 +21,10 @@ struct MulticamView: View { let onShutter: () -> Void /// Toggle the shutter between photo and video mode. let onToggleMode: () -> Void + /// "Add camera" tapped — the host decides paywall vs. the sheet. + let onAddCamera: () -> Void + /// Invite a discovered camera into the rig. + let onInviteCamera: (MCPeerID) -> Void var body: some View { GeometryReader { geo in @@ -43,6 +48,9 @@ struct MulticamView: View { shutterOverlay(dock: dock) gridToggle } + .sheet(isPresented: $viewModel.showingAddCamera) { + AddCameraSheet(peers: viewModel.availablePeers, onInvite: onInviteCamera) + } } } @@ -169,36 +177,52 @@ struct MulticamView: View { @ViewBuilder private func stripOverlay(dock: MonitorChromeDock) -> some View { let others = viewModel.otherLanes - if !others.isEmpty { - switch dock { - case .bottom: - VStack { - Spacer() - HStack(spacing: 8) { - ForEach(others) { lane in - CameraTileView(lane: lane, isThumbnail: true) - .frame(width: 96, height: 128) - .onTapGesture { onFocusLane(lane) } - } + switch dock { + case .bottom: + VStack { + Spacer() + HStack(spacing: 8) { + ForEach(others) { lane in + CameraTileView(lane: lane, isThumbnail: true) + .frame(width: 96, height: 128) + .onTapGesture { onFocusLane(lane) } } - .padding(.bottom, 96) + addCameraTile.frame(width: 96, height: 128) } - case .leading, .trailing: - HStack { - if dock == .trailing { Spacer() } - VStack(spacing: 8) { - ForEach(others) { lane in - CameraTileView(lane: lane, isThumbnail: true) - .frame(width: 128, height: 96) - .onTapGesture { onFocusLane(lane) } - } + .padding(.bottom, 96) + } + case .leading, .trailing: + HStack { + if dock == .trailing { Spacer() } + VStack(spacing: 8) { + ForEach(others) { lane in + CameraTileView(lane: lane, isThumbnail: true) + .frame(width: 128, height: 96) + .onTapGesture { onFocusLane(lane) } } - .padding(dock == .leading ? .leading : .trailing, 12) - if dock == .leading { Spacer() } + addCameraTile.frame(width: 128, height: 96) } + .padding(dock == .leading ? .leading : .trailing, 12) + if dock == .leading { Spacer() } } } } + + /// The "add camera" affordance at the end of the strip. The host decides + /// whether tapping opens the sheet or the paywall (at the tier cap). + private var addCameraTile: some View { + Button(action: onAddCamera) { + VStack(spacing: 6) { + Image(systemName: "plus.circle.fill").font(.title) + Text(NSLocalizedString("Add camera", comment: "add a camera to the multicam rig")) + .font(.caption2) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.white.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } } /// One camera's tile: its isolated live frame, a name chip, a focus ring, and @@ -267,3 +291,41 @@ struct CameraTileView: View { .stroke(lane.isFocused ? AppTheme.accent : .clear, lineWidth: 3)) } } + +/// Lists the cameras the director's browser has discovered but not yet added, +/// so the user can invite them into the rig mid-session. Shown only below the +/// tier cap (the host routes to the paywall at the cap). +struct AddCameraSheet: View { + let peers: [MCPeerID] + let onInvite: (MCPeerID) -> Void + + var body: some View { + NavigationView { + Group { + if peers.isEmpty { + VStack(spacing: 12) { + ProgressView() + Text(NSLocalizedString("Searching for cameras…", + comment: "add-camera sheet, no peers yet")) + .foregroundColor(.secondary) + } + } else { + List(peers, id: \.self) { peer in + Button { onInvite(peer) } label: { + HStack { + Image(systemName: "iphone.radiowaves.left.and.right") + .foregroundColor(AppTheme.accent) + Text(peer.displayName) + Spacer() + Text(NSLocalizedString("Add", comment: "invite this camera")) + .foregroundColor(AppTheme.accent) + } + } + } + } + } + .navigationTitle(NSLocalizedString("Add camera", + comment: "add a camera to the multicam rig")) + } + } +} diff --git a/RemoteCam/MulticamViewController.swift b/RemoteCam/MulticamViewController.swift index 66258fdd..787b5d70 100644 --- a/RemoteCam/MulticamViewController.swift +++ b/RemoteCam/MulticamViewController.swift @@ -6,6 +6,7 @@ // import MPCCompat +import StoreKit import SwiftUI import UIKit @@ -52,6 +53,12 @@ public final class MulticamViewController: UIViewController { onToggleMode: { [weak self] in guard let self, !self.viewModel.isRecording else { return } self.viewModel.mode = self.viewModel.mode == .photo ? .video : .photo + }, + onAddCamera: { [weak self] in self?.handleAddCameraTapped() }, + onInviteCamera: { [weak self] peer in + guard let self else { return } + self.viewModel.showingAddCamera = false + Task { await self.controller.inviteCamera(peer) } }) hosting = embedSwiftUIView(multicamView) @@ -75,6 +82,27 @@ public final class MulticamViewController: UIViewController { navigationController?.setNavigationBarHidden(false, animated: animated) } + /// "Add camera" tapped: at the tier cap, route free users to the paywall + /// (the same Settings sheet every other gate uses); otherwise open the + /// discovered-cameras sheet. + private func handleAddCameraTapped() { + Task { @MainActor in + let count = await controller.cameraCount() + if count >= StoreManager.shared.maxCameras() { + showPaywall() + } else { + viewModel.showingAddCamera = true + } + } + } + + /// Reuse the existing Settings/paywall sheet — no bespoke multicam paywall. + func showPaywall() { + let ctrl = UIHostingController(rootView: SettingsView()) + ctrl.modalPresentationStyle = .pageSheet + present(ctrl, animated: true) + } + /// Route the shutter: a photo, or record start/stop, per the current mode. private func triggerShutter() { let mode = viewModel.mode @@ -132,6 +160,10 @@ extension MulticamViewController: MulticamDisplay { viewModel.isRecording = recording } + func applyAvailablePeers(_ peers: [MCPeerID]) { + viewModel.availablePeers = peers + } + func receiveFrame(_ frame: RemoteCmd.OnFrame) { // Route to exactly the source lane's decoder; a frame for camera B // never touches camera A's tile. diff --git a/RemoteCam/MulticamViewModel.swift b/RemoteCam/MulticamViewModel.swift index 01f8b9a3..fcc88433 100644 --- a/RemoteCam/MulticamViewModel.swift +++ b/RemoteCam/MulticamViewModel.swift @@ -58,6 +58,10 @@ final class MulticamViewModel: ObservableObject { @Published var mode: MonitorMode = .photo /// 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. + @Published var availablePeers: [MCPeerID] = [] + /// Whether the add-camera sheet is showing. + @Published var showingAddCamera: Bool = false var focusedLane: CameraLane? { lanes.first { $0.isFocused } } var otherLanes: [CameraLane] { lanes.filter { !$0.isFocused } } diff --git a/RemoteCam/RolePickerView.swift b/RemoteCam/RolePickerView.swift index 01396218..8e8b3c27 100644 --- a/RemoteCam/RolePickerView.swift +++ b/RemoteCam/RolePickerView.swift @@ -34,8 +34,14 @@ struct RolePickerView: View { Button(action: onRemote) { glassPanel( icon: "antenna.radiowaves.left.and.right", - title: NSLocalizedString("Remote", comment: ""), - subtitle: NSLocalizedString("Control the shutter", comment: ""), + // Multicam reframes this role as the "Director" of one or + // more cameras; single-cam keeps the "Remote" wording. + title: FeatureFlags.ENABLE_MULTICAM + ? NSLocalizedString("Director", comment: "") + : NSLocalizedString("Remote", comment: ""), + subtitle: FeatureFlags.ENABLE_MULTICAM + ? NSLocalizedString("Control one or more iPhone cameras", comment: "") + : NSLocalizedString("Control the shutter", comment: ""), tint: AppTheme.secondary ) } diff --git a/RemoteCam/SettingsView.swift b/RemoteCam/SettingsView.swift index b2c1acd6..090b1735 100644 --- a/RemoteCam/SettingsView.swift +++ b/RemoteCam/SettingsView.swift @@ -57,7 +57,12 @@ struct SettingsView: View { } header: { Text(NSLocalizedString("Pro — Unlock Everything", comment: "")) } footer: { - Text(NSLocalizedString("Pro unlocks every feature, including future ones.", comment: "")) + VStack(alignment: .leading, spacing: 4) { + Text(NSLocalizedString("Pro unlocks every feature, including future ones.", comment: "")) + if FeatureFlags.ENABLE_MULTICAM { + Text(NSLocalizedString("Direct up to 4 cameras at once", comment: "Pro multicam feature")) + } + } } // À la carte: individual features for a one-time purchase. diff --git a/RemoteCam/StoreManager.swift b/RemoteCam/StoreManager.swift index 5fece1fc..1181ae1f 100644 --- a/RemoteCam/StoreManager.swift +++ b/RemoteCam/StoreManager.swift @@ -92,6 +92,13 @@ final class StoreManager: ObservableObject { hasFullAccess() || UserDefaults.standard.bool(forKey: PurchaseKey.tapToFocus) } + /// How many cameras a multicam director may connect: a free 2-camera + /// teaser, or up to 4 with full access. The director's entitlement is what + /// counts (matching every other gate — checked locally on this device). + func maxCameras() -> Int { + hasFullAccess() ? 4 : 2 + } + // MARK: - Init private var updateListenerTask: Task? diff --git a/RemoteCamTests/MulticamControllerTests.swift b/RemoteCamTests/MulticamControllerTests.swift index 0ae7a8d6..fceb7c39 100644 --- a/RemoteCamTests/MulticamControllerTests.swift +++ b/RemoteCamTests/MulticamControllerTests.swift @@ -15,6 +15,7 @@ private final class FakeMulticamDisplay: MulticamDisplay, @unchecked Sendable { var receivedFrames: [MCPeerID] = [] var capturing = false var recording = false + var availablePeers: [MCPeerID] = [] var didExit = false func applyLanes(_ lanes: [MulticamLaneInfo]) { lastLanes = lanes } @@ -22,6 +23,7 @@ private final class FakeMulticamDisplay: MulticamDisplay, @unchecked Sendable { self.capturing = capturing self.recording = recording } + func applyAvailablePeers(_ peers: [MCPeerID]) { availablePeers = peers } func receiveFrame(_ frame: RemoteCmd.OnFrame) { receivedFrames.append(frame.peerId) } func exitMulticam() { didExit = true } } @@ -277,6 +279,59 @@ final class MulticamControllerTests: XCTestCase { XCTAssertEqual(fallbackState?.remaining, 2) } + // MARK: - Add camera + + func testDiscoveredPeerBecomesAvailableButDoesNotAutoJoin() async { + let camC = MCPeerID(displayName: "CameraC") + let (controller, transport, _) = await makeController(peers: [camA, camB]) + transport.invitedPeers.removeAll() + + controller.browserDidFindPeer(camC) + await controller.waitForIdle() + + let available = await controller.availablePeersForTesting() + XCTAssertEqual(available, [camC]) + // A fresh peer is a candidate only — it is never auto-invited. + XCTAssertTrue(transport.invitedPeers.isEmpty) + // And it is not in the rig. + let count = await controller.cameraCountForTesting() + XCTAssertEqual(count, 2) + } + + func testInviteCameraInvitesADiscoveredPeer() async { + let camC = MCPeerID(displayName: "CameraC") + let (controller, transport, _) = await makeController(peers: [camA, camB]) + controller.browserDidFindPeer(camC) + await controller.waitForIdle() + transport.invitedPeers.removeAll() + + await controller.inviteCamera(camC) + await controller.waitForIdle() + XCTAssertEqual(transport.invitedPeers.map(\.peer), [camC]) + + // Inviting a peer that was never discovered does nothing. + transport.invitedPeers.removeAll() + await controller.inviteCamera(MCPeerID(displayName: "Ghost")) + await controller.waitForIdle() + XCTAssertTrue(transport.invitedPeers.isEmpty) + } + + func testAvailablePeerClearsOnceItJoins() async { + let camC = MCPeerID(displayName: "CameraC") + let (controller, _, _) = await makeController(peers: [camA, camB]) + controller.browserDidFindPeer(camC) + await controller.waitForIdle() + let avail = await controller.availablePeersForTesting() + XCTAssertEqual(avail, [camC]) + + controller.peerDidConnect(camC) + await controller.waitForIdle() + let availAfter = await controller.availablePeersForTesting() + let countAfter = await controller.cameraCountForTesting() + XCTAssertTrue(availAfter.isEmpty) + XCTAssertEqual(countAfter, 3) + } + // MARK: - Stream profile tiering func testFocusedLaneGetsFullProfileOthersGetThumbnail() async { diff --git a/RemoteCamTests/StoreManagerTests.swift b/RemoteCamTests/StoreManagerTests.swift index 9aa6f0c6..cb3d103f 100644 --- a/RemoteCamTests/StoreManagerTests.swift +++ b/RemoteCamTests/StoreManagerTests.swift @@ -46,6 +46,22 @@ final class StoreManagerTests: XCTestCase { XCTAssertFalse(store.hasTapToFocusFeature()) } + // MARK: - Multicam camera cap + + func testFreeTierGetsTwoCameras() { + XCTAssertEqual(StoreManager.shared.maxCameras(), 2) + } + + func testProModeUnlocksFourCameras() { + UserDefaults.standard.set(true, forKey: proModeKey) + XCTAssertEqual(StoreManager.shared.maxCameras(), 4) + } + + func testProSubscriptionUnlocksFourCameras() { + UserDefaults.standard.set(true, forKey: proSubscriptionKey) + XCTAssertEqual(StoreManager.shared.maxCameras(), 4) + } + // MARK: - Individual Feature Flags func testHasAdRemovalWhenPurchased() { From 80fe5303fb9ebce5e04c48a918604b8870eb3892 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Tue, 11 Aug 2026 00:17:41 -0700 Subject: [PATCH 09/17] Multicam commit C: localization fan-out (15 locales) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every multicam UI string translated across all 15 shipped locales (da, de-DE, en, es-MX, fr-FR, hi, it, ja, ko, ms, pt-BR, ru, tr, vi, zh-Hans), faithful to each file's existing register: Start (%d), Add camera, Add, Searching for cameras…, Director, Control one or more iPhone cameras, Direct up to 4 cameras at once, RECONNECTING RECONNECTING was previously unlocalized (the key fell back to itself in every language, including the 1:1 monitor's reconnect chip) — now properly translated everywhere. Start (%d) existed only in en; filled in the other 14. All 15 files lint clean (plutil). Deviation from the brief's suggested string list: "%d of %d cameras", "Connected to director", and "Unlock 4 cameras with Pro" are NOT added — the UI landed without them (the count lives in "Start (%d)"; the paywall is the existing Settings sheet, not a bespoke CTA; there is no camera-side director badge in v1). Only the eight strings actually referenced in code are shipped, so there are no dead keys. Full suite green (700). Co-Authored-By: Claude Fable 5 --- RemoteCam/da.lproj/Localizable.strings | 10 ++++++++++ RemoteCam/de-DE.lproj/Localizable.strings | 10 ++++++++++ RemoteCam/en.lproj/Localizable.strings | 9 +++++++++ RemoteCam/es-MX.lproj/Localizable.strings | 10 ++++++++++ RemoteCam/fr-FR.lproj/Localizable.strings | 10 ++++++++++ RemoteCam/hi.lproj/Localizable.strings | 10 ++++++++++ RemoteCam/it.lproj/Localizable.strings | 10 ++++++++++ RemoteCam/ja.lproj/Localizable.strings | 10 ++++++++++ RemoteCam/ko.lproj/Localizable.strings | 10 ++++++++++ RemoteCam/ms.lproj/Localizable.strings | 10 ++++++++++ RemoteCam/pt-BR.lproj/Localizable.strings | 10 ++++++++++ RemoteCam/ru.lproj/Localizable.strings | 10 ++++++++++ RemoteCam/tr.lproj/Localizable.strings | 10 ++++++++++ RemoteCam/vi.lproj/Localizable.strings | 10 ++++++++++ RemoteCam/zh-Hans.lproj/Localizable.strings | 10 ++++++++++ 15 files changed, 149 insertions(+) diff --git a/RemoteCam/da.lproj/Localizable.strings b/RemoteCam/da.lproj/Localizable.strings index 70827b3e..1f6e5b11 100644 --- a/RemoteCam/da.lproj/Localizable.strings +++ b/RemoteCam/da.lproj/Localizable.strings @@ -238,3 +238,13 @@ "IncompatibleBothTitle" = "Appen er ikke opdateret"; "IncompatibleBothBody" = "Opdater Remote Shutter på begge enheder."; "IncompatibleUpdateButton" = "Opdater"; + +// Multicam director (behind ENABLE_MULTICAM) +"Start (%d)" = "Start (%d)"; +"Add camera" = "Tilføj kamera"; +"Add" = "Tilføj"; +"Searching for cameras…" = "Søger efter kameraer …"; +"Director" = "Instruktør"; +"Control one or more iPhone cameras" = "Styr et eller flere iPhone-kameraer"; +"Direct up to 4 cameras at once" = "Instruér op til 4 kameraer på én gang"; +"RECONNECTING" = "GENOPRETTER FORBINDELSE"; diff --git a/RemoteCam/de-DE.lproj/Localizable.strings b/RemoteCam/de-DE.lproj/Localizable.strings index 4fbae8eb..8274fd8c 100644 --- a/RemoteCam/de-DE.lproj/Localizable.strings +++ b/RemoteCam/de-DE.lproj/Localizable.strings @@ -359,3 +359,13 @@ "IncompatibleBothTitle" = "Die App ist nicht aktuell"; "IncompatibleBothBody" = "Bitte aktualisieren Sie Remote Shutter auf beiden Geräten."; "IncompatibleUpdateButton" = "Aktualisieren"; + +// Multicam director (behind ENABLE_MULTICAM) +"Start (%d)" = "Starten (%d)"; +"Add camera" = "Kamera hinzufügen"; +"Add" = "Hinzufügen"; +"Searching for cameras…" = "Suche nach Kameras …"; +"Director" = "Regie"; +"Control one or more iPhone cameras" = "Steuere eine oder mehrere iPhone-Kameras"; +"Direct up to 4 cameras at once" = "Bis zu 4 Kameras gleichzeitig steuern"; +"RECONNECTING" = "VERBINDUNG WIRD WIEDERHERGESTELLT"; diff --git a/RemoteCam/en.lproj/Localizable.strings b/RemoteCam/en.lproj/Localizable.strings index be15bbe8..34e801cb 100644 --- a/RemoteCam/en.lproj/Localizable.strings +++ b/RemoteCam/en.lproj/Localizable.strings @@ -362,3 +362,12 @@ // Multicam director (behind ENABLE_MULTICAM) "Start (%d)" = "Start (%d)"; + +// Multicam director (behind ENABLE_MULTICAM) +"Add camera" = "Add camera"; +"Add" = "Add"; +"Searching for cameras…" = "Searching for cameras…"; +"Director" = "Director"; +"Control one or more iPhone cameras" = "Control one or more iPhone cameras"; +"Direct up to 4 cameras at once" = "Direct up to 4 cameras at once"; +"RECONNECTING" = "RECONNECTING"; diff --git a/RemoteCam/es-MX.lproj/Localizable.strings b/RemoteCam/es-MX.lproj/Localizable.strings index 8367704b..271b305e 100644 --- a/RemoteCam/es-MX.lproj/Localizable.strings +++ b/RemoteCam/es-MX.lproj/Localizable.strings @@ -253,3 +253,13 @@ "IncompatibleBothTitle" = "La app está desactualizada"; "IncompatibleBothBody" = "Actualiza Remote Shutter en ambos dispositivos."; "IncompatibleUpdateButton" = "Actualizar"; + +// Multicam director (behind ENABLE_MULTICAM) +"Start (%d)" = "Iniciar (%d)"; +"Add camera" = "Agregar cámara"; +"Add" = "Agregar"; +"Searching for cameras…" = "Buscando cámaras…"; +"Director" = "Director"; +"Control one or more iPhone cameras" = "Controla una o más cámaras de iPhone"; +"Direct up to 4 cameras at once" = "Dirige hasta 4 cámaras a la vez"; +"RECONNECTING" = "RECONECTANDO"; diff --git a/RemoteCam/fr-FR.lproj/Localizable.strings b/RemoteCam/fr-FR.lproj/Localizable.strings index bfdbe936..58101c94 100644 --- a/RemoteCam/fr-FR.lproj/Localizable.strings +++ b/RemoteCam/fr-FR.lproj/Localizable.strings @@ -253,3 +253,13 @@ "IncompatibleBothTitle" = "L'app n'est pas à jour"; "IncompatibleBothBody" = "Mettez Remote Shutter à jour sur les deux appareils."; "IncompatibleUpdateButton" = "Mettre à jour"; + +// Multicam director (behind ENABLE_MULTICAM) +"Start (%d)" = "Démarrer (%d)"; +"Add camera" = "Ajouter une caméra"; +"Add" = "Ajouter"; +"Searching for cameras…" = "Recherche de caméras…"; +"Director" = "Réalisateur"; +"Control one or more iPhone cameras" = "Contrôlez une ou plusieurs caméras iPhone"; +"Direct up to 4 cameras at once" = "Dirigez jusqu'à 4 caméras à la fois"; +"RECONNECTING" = "RECONNEXION"; diff --git a/RemoteCam/hi.lproj/Localizable.strings b/RemoteCam/hi.lproj/Localizable.strings index 7202bcc8..15f26fdc 100644 --- a/RemoteCam/hi.lproj/Localizable.strings +++ b/RemoteCam/hi.lproj/Localizable.strings @@ -359,3 +359,13 @@ "IncompatibleBothTitle" = "ऐप पुराना है"; "IncompatibleBothBody" = "दोनों डिवाइस पर Remote Shutter अपडेट करें।"; "IncompatibleUpdateButton" = "अपडेट करें"; + +// Multicam director (behind ENABLE_MULTICAM) +"Start (%d)" = "शुरू करें (%d)"; +"Add camera" = "कैमरा जोड़ें"; +"Add" = "जोड़ें"; +"Searching for cameras…" = "कैमरे खोजे जा रहे हैं…"; +"Director" = "डायरेक्टर"; +"Control one or more iPhone cameras" = "एक या अधिक iPhone कैमरों को नियंत्रित करें"; +"Direct up to 4 cameras at once" = "एक साथ 4 कैमरों तक निर्देशित करें"; +"RECONNECTING" = "फिर से कनेक्ट हो रहा है"; diff --git a/RemoteCam/it.lproj/Localizable.strings b/RemoteCam/it.lproj/Localizable.strings index 86d24496..92167e2c 100644 --- a/RemoteCam/it.lproj/Localizable.strings +++ b/RemoteCam/it.lproj/Localizable.strings @@ -238,3 +238,13 @@ "IncompatibleBothTitle" = "L'app non è aggiornata"; "IncompatibleBothBody" = "Aggiorna Remote Shutter su entrambi i dispositivi."; "IncompatibleUpdateButton" = "Aggiorna"; + +// Multicam director (behind ENABLE_MULTICAM) +"Start (%d)" = "Avvia (%d)"; +"Add camera" = "Aggiungi fotocamera"; +"Add" = "Aggiungi"; +"Searching for cameras…" = "Ricerca fotocamere…"; +"Director" = "Regista"; +"Control one or more iPhone cameras" = "Controlla una o più fotocamere iPhone"; +"Direct up to 4 cameras at once" = "Dirigi fino a 4 fotocamere contemporaneamente"; +"RECONNECTING" = "RICONNESSIONE"; diff --git a/RemoteCam/ja.lproj/Localizable.strings b/RemoteCam/ja.lproj/Localizable.strings index 37f53d83..de85a2e0 100644 --- a/RemoteCam/ja.lproj/Localizable.strings +++ b/RemoteCam/ja.lproj/Localizable.strings @@ -359,3 +359,13 @@ "IncompatibleBothTitle" = "アプリが最新ではありません"; "IncompatibleBothBody" = "両方のデバイスでRemote Shutterを更新してください。"; "IncompatibleUpdateButton" = "更新"; + +// Multicam director (behind ENABLE_MULTICAM) +"Start (%d)" = "開始 (%d)"; +"Add camera" = "カメラを追加"; +"Add" = "追加"; +"Searching for cameras…" = "カメラを検索中…"; +"Director" = "ディレクター"; +"Control one or more iPhone cameras" = "1台以上のiPhoneカメラを操作"; +"Direct up to 4 cameras at once" = "最大4台のカメラを同時に操作"; +"RECONNECTING" = "再接続中"; diff --git a/RemoteCam/ko.lproj/Localizable.strings b/RemoteCam/ko.lproj/Localizable.strings index 2be276f6..d5749306 100644 --- a/RemoteCam/ko.lproj/Localizable.strings +++ b/RemoteCam/ko.lproj/Localizable.strings @@ -359,3 +359,13 @@ "IncompatibleBothTitle" = "앱이 최신 버전이 아닙니다"; "IncompatibleBothBody" = "두 기기 모두 Remote Shutter를 업데이트하세요."; "IncompatibleUpdateButton" = "업데이트"; + +// Multicam director (behind ENABLE_MULTICAM) +"Start (%d)" = "시작 (%d)"; +"Add camera" = "카메라 추가"; +"Add" = "추가"; +"Searching for cameras…" = "카메라 검색 중…"; +"Director" = "디렉터"; +"Control one or more iPhone cameras" = "하나 이상의 iPhone 카메라 제어"; +"Direct up to 4 cameras at once" = "최대 4대의 카메라를 동시에 제어"; +"RECONNECTING" = "다시 연결 중"; diff --git a/RemoteCam/ms.lproj/Localizable.strings b/RemoteCam/ms.lproj/Localizable.strings index 658638a8..860d1b7e 100644 --- a/RemoteCam/ms.lproj/Localizable.strings +++ b/RemoteCam/ms.lproj/Localizable.strings @@ -359,3 +359,13 @@ "IncompatibleBothTitle" = "Apl bukan versi terkini"; "IncompatibleBothBody" = "Sila kemas kini Remote Shutter pada kedua-dua peranti."; "IncompatibleUpdateButton" = "Kemas kini"; + +// Multicam director (behind ENABLE_MULTICAM) +"Start (%d)" = "Mula (%d)"; +"Add camera" = "Tambah kamera"; +"Add" = "Tambah"; +"Searching for cameras…" = "Mencari kamera…"; +"Director" = "Pengarah"; +"Control one or more iPhone cameras" = "Kawal satu atau lebih kamera iPhone"; +"Direct up to 4 cameras at once" = "Arahkan sehingga 4 kamera serentak"; +"RECONNECTING" = "MENYAMBUNG SEMULA"; diff --git a/RemoteCam/pt-BR.lproj/Localizable.strings b/RemoteCam/pt-BR.lproj/Localizable.strings index c93a9d2f..2cef1e0f 100644 --- a/RemoteCam/pt-BR.lproj/Localizable.strings +++ b/RemoteCam/pt-BR.lproj/Localizable.strings @@ -359,3 +359,13 @@ "IncompatibleBothTitle" = "O app está desatualizado"; "IncompatibleBothBody" = "Atualize o Remote Shutter nos dois aparelhos."; "IncompatibleUpdateButton" = "Atualizar"; + +// Multicam director (behind ENABLE_MULTICAM) +"Start (%d)" = "Iniciar (%d)"; +"Add camera" = "Adicionar câmera"; +"Add" = "Adicionar"; +"Searching for cameras…" = "Procurando câmeras…"; +"Director" = "Diretor"; +"Control one or more iPhone cameras" = "Controle uma ou mais câmeras do iPhone"; +"Direct up to 4 cameras at once" = "Dirija até 4 câmeras ao mesmo tempo"; +"RECONNECTING" = "RECONECTANDO"; diff --git a/RemoteCam/ru.lproj/Localizable.strings b/RemoteCam/ru.lproj/Localizable.strings index eef69c8f..20b5d929 100644 --- a/RemoteCam/ru.lproj/Localizable.strings +++ b/RemoteCam/ru.lproj/Localizable.strings @@ -359,3 +359,13 @@ "IncompatibleBothTitle" = "Приложение устарело"; "IncompatibleBothBody" = "Обновите Remote Shutter на обоих устройствах."; "IncompatibleUpdateButton" = "Обновить"; + +// Multicam director (behind ENABLE_MULTICAM) +"Start (%d)" = "Начать (%d)"; +"Add camera" = "Добавить камеру"; +"Add" = "Добавить"; +"Searching for cameras…" = "Поиск камер…"; +"Director" = "Режиссёр"; +"Control one or more iPhone cameras" = "Управляйте одной или несколькими камерами iPhone"; +"Direct up to 4 cameras at once" = "Управляйте до 4 камерами одновременно"; +"RECONNECTING" = "ПЕРЕПОДКЛЮЧЕНИЕ"; diff --git a/RemoteCam/tr.lproj/Localizable.strings b/RemoteCam/tr.lproj/Localizable.strings index e5648595..45b98125 100644 --- a/RemoteCam/tr.lproj/Localizable.strings +++ b/RemoteCam/tr.lproj/Localizable.strings @@ -359,3 +359,13 @@ "IncompatibleBothTitle" = "Uygulama güncel değil"; "IncompatibleBothBody" = "Remote Shutter'ı iki cihazda da güncelleyin."; "IncompatibleUpdateButton" = "Güncelle"; + +// Multicam director (behind ENABLE_MULTICAM) +"Start (%d)" = "Başlat (%d)"; +"Add camera" = "Kamera ekle"; +"Add" = "Ekle"; +"Searching for cameras…" = "Kameralar aranıyor…"; +"Director" = "Yönetmen"; +"Control one or more iPhone cameras" = "Bir veya daha fazla iPhone kamerasını kontrol edin"; +"Direct up to 4 cameras at once" = "Aynı anda 4 kameraya kadar yönetin"; +"RECONNECTING" = "YENİDEN BAĞLANIYOR"; diff --git a/RemoteCam/vi.lproj/Localizable.strings b/RemoteCam/vi.lproj/Localizable.strings index 783675f7..0036fcc9 100644 --- a/RemoteCam/vi.lproj/Localizable.strings +++ b/RemoteCam/vi.lproj/Localizable.strings @@ -359,3 +359,13 @@ "IncompatibleBothTitle" = "Ứng dụng chưa được cập nhật"; "IncompatibleBothBody" = "Hãy cập nhật Remote Shutter trên cả hai thiết bị."; "IncompatibleUpdateButton" = "Cập nhật"; + +// Multicam director (behind ENABLE_MULTICAM) +"Start (%d)" = "Bắt đầu (%d)"; +"Add camera" = "Thêm camera"; +"Add" = "Thêm"; +"Searching for cameras…" = "Đang tìm camera…"; +"Director" = "Đạo diễn"; +"Control one or more iPhone cameras" = "Điều khiển một hoặc nhiều camera iPhone"; +"Direct up to 4 cameras at once" = "Điều khiển tối đa 4 camera cùng lúc"; +"RECONNECTING" = "ĐANG KẾT NỐI LẠI"; diff --git a/RemoteCam/zh-Hans.lproj/Localizable.strings b/RemoteCam/zh-Hans.lproj/Localizable.strings index ecad6b64..c3985e6f 100644 --- a/RemoteCam/zh-Hans.lproj/Localizable.strings +++ b/RemoteCam/zh-Hans.lproj/Localizable.strings @@ -359,3 +359,13 @@ "IncompatibleBothTitle" = "应用不是最新版本"; "IncompatibleBothBody" = "请在两台设备上都更新 Remote Shutter。"; "IncompatibleUpdateButton" = "更新"; + +// Multicam director (behind ENABLE_MULTICAM) +"Start (%d)" = "开始 (%d)"; +"Add camera" = "添加相机"; +"Add" = "添加"; +"Searching for cameras…" = "正在搜索相机…"; +"Director" = "导演"; +"Control one or more iPhone cameras" = "控制一台或多台 iPhone 相机"; +"Direct up to 4 cameras at once" = "同时导演最多 4 台相机"; +"RECONNECTING" = "正在重新连接"; From 8de95580295984b2ebbf4e2a9f8600bc6aa3535e Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Tue, 11 Aug 2026 01:17:20 -0700 Subject: [PATCH 10/17] Multicam scanner: Apple edit-mode multi-select (device feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dario found the collecting scanner confusing — discovered rows didn't read as selectable. Reworked to the Photos/Mail edit-mode idiom (the shape Final Cut Camera's Add Angles uses). - Each discovered-camera row gets a leading selection circle: `circle` (secondary) unselected → spinner connecting → `checkmark.circle.fill` (accent) selected, with an accent row border when selected. Tapping the row toggles: unselected → invite/connect; selected → deselect. Deselect is logical (the QUIC transport has no per-peer teardown), honored by an effective-peer set the count, handoff and scanner all read; a still-connected camera re-selects instantly without re-inviting. The cap is enforced at selection time — an over-cap unselected row shows a lock and routes to the existing paywall. - "Connect All" row at the top of the list invites every discovered, not-yet-selected camera up to maxCameras(); hidden once all are selected. The over-cap remainder keeps the per-row lock. - Start (N) stays the bottom CTA; its count is the effective selection, so selection state and the count are always consistent. - Single-camera / flag-off scanner is byte-identical (all of this is gated on ENABLE_MULTICAM && monitor role; the coordinator's toggle handler is a no-op unless collecting). - New string "Connect All" fanned out to all 15 locales. Wire: UICmd.ToggleMulticamCamera (in-process). SessionCoordinator gains an effective-peer set (connected − deselected) driving multicamConnectedCount, detach and promote. Tests: VM row-state transitions + Connect-All cap math; coordinator select→connect→deselect→reselect round trip. Full suite green (703). Co-Authored-By: Claude Fable 5 --- RemoteCam/DeviceScannerView.swift | 148 ++++++++++++++---- RemoteCam/DeviceScannerViewController.swift | 54 +++++-- RemoteCam/DeviceScannerViewModel.swift | 36 +++++ RemoteCam/SessionCoordinator.swift | 52 +++++- RemoteCam/UICmds.swift | 13 ++ RemoteCam/da.lproj/Localizable.strings | 1 + RemoteCam/de-DE.lproj/Localizable.strings | 1 + RemoteCam/en.lproj/Localizable.strings | 1 + RemoteCam/es-MX.lproj/Localizable.strings | 1 + RemoteCam/fr-FR.lproj/Localizable.strings | 1 + RemoteCam/hi.lproj/Localizable.strings | 1 + RemoteCam/it.lproj/Localizable.strings | 1 + RemoteCam/ja.lproj/Localizable.strings | 1 + RemoteCam/ko.lproj/Localizable.strings | 1 + RemoteCam/ms.lproj/Localizable.strings | 1 + RemoteCam/pt-BR.lproj/Localizable.strings | 1 + RemoteCam/ru.lproj/Localizable.strings | 1 + RemoteCam/tr.lproj/Localizable.strings | 1 + RemoteCam/vi.lproj/Localizable.strings | 1 + RemoteCam/zh-Hans.lproj/Localizable.strings | 1 + .../DeviceScannerViewModelTests.swift | 43 +++++ RemoteCamTests/RemoteCamSessionTests.swift | 40 +++++ 22 files changed, 357 insertions(+), 44 deletions(-) diff --git a/RemoteCam/DeviceScannerView.swift b/RemoteCam/DeviceScannerView.swift index 570a20cb..1ed353f8 100644 --- a/RemoteCam/DeviceScannerView.swift +++ b/RemoteCam/DeviceScannerView.swift @@ -17,6 +17,13 @@ struct DeviceScannerView: View { /// Multicam only: begin a director session with the cameras collected so /// far. Nil in the single-camera build (flag off), where it never shows. var onStartMulticam: (() -> Void)? = nil + /// Multicam only: select every discovered camera up to the tier cap. + var onConnectAll: (() -> Void)? = nil + + /// The scanner is in multicam edit-mode selection (monitor role, flag on). + private var isMulticamScanner: Bool { + FeatureFlags.ENABLE_MULTICAM && viewModel.role == .monitor + } /// Peer-link state; the reconnect overlay is a function of it. @ObservedObject var peerLink: PeerLinkStatus = .shared @@ -73,37 +80,13 @@ struct DeviceScannerView: View { statusBadge .padding(.top, 8) + if isMulticamScanner { connectAllRow } + ForEach(viewModel.connectedPeers, id: \.self) { peer in Button { onSelectPeer(peer) } label: { - HStack(spacing: 14) { - Image(systemName: "iphone.radiowaves.left.and.right") - .font(.title3) - .foregroundColor(AppTheme.accent) - .frame(width: 40, height: 40) - .background(AppTheme.accentSubtle) - .clipShape(Circle()) - - Text(peer.displayName) - .font(.body) - .fontWeight(.medium) - .foregroundColor(.primary) - - Spacer() - - Text(NSLocalizedString("Connect", comment: "")) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(AppTheme.accent) - } - .padding(14) - .background(.ultraThinMaterial) - .clipShape(RoundedRectangle(cornerRadius: 14)) - .overlay( - RoundedRectangle(cornerRadius: 14) - .strokeBorder(AppTheme.glassBorder, lineWidth: 0.5) - ) + peerRow(peer) } .buttonStyle(GlassPressStyle()) } @@ -112,7 +95,116 @@ struct DeviceScannerView: View { .padding(.top, 8) } .padding(.horizontal, 20) - .padding(.bottom, 40) + .padding(.bottom, isMulticamScanner ? 100 : 40) // room for Start (N) + } + } + + /// One discovered-camera row. In multicam it carries a leading selection + /// circle (Apple edit-mode idiom); otherwise it keeps the classic + /// icon + "Connect" affordance. + private func peerRow(_ peer: MCPeerID) -> some View { + HStack(spacing: 14) { + if isMulticamScanner { + multicamSelectionCircle(peer) + } else { + Image(systemName: "iphone.radiowaves.left.and.right") + .font(.title3) + .foregroundColor(AppTheme.accent) + .frame(width: 40, height: 40) + .background(AppTheme.accentSubtle) + .clipShape(Circle()) + } + + Text(peer.displayName) + .font(.body) + .fontWeight(.medium) + .foregroundColor(.primary) + + Spacer() + + peerRowTrailing(peer) + } + .padding(14) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .overlay( + RoundedRectangle(cornerRadius: 14) + .strokeBorder(rowBorderColor(peer), lineWidth: rowBorderWidth(peer)) + ) + } + + /// The edit-mode leading circle: empty → spinner → filled check. + @ViewBuilder + private func multicamSelectionCircle(_ peer: MCPeerID) -> some View { + switch viewModel.multicamRowState(peer) { + case .selected: + Image(systemName: "checkmark.circle.fill") + .font(.title2) + .foregroundColor(AppTheme.accent) + .frame(width: 40, height: 40) + case .connecting: + ProgressView() + .frame(width: 40, height: 40) + case .unselected: + Image(systemName: "circle") + .font(.title2) + .foregroundColor(.secondary) + .frame(width: 40, height: 40) + } + } + + @ViewBuilder + private func peerRowTrailing(_ peer: MCPeerID) -> some View { + if isMulticamScanner { + // At the cap, an unselected row shows a lock: tapping it opens the + // paywall (handled by the host). + if viewModel.multicamRowState(peer) == .unselected + && viewModel.multicamCollectedCount >= StoreManager.shared.maxCameras() { + Image(systemName: "lock.fill") + .font(.subheadline) + .foregroundColor(.secondary) + } + } else { + Text(NSLocalizedString("Connect", comment: "")) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(AppTheme.accent) + } + } + + private func rowBorderColor(_ peer: MCPeerID) -> Color { + isMulticamScanner && viewModel.multicamRowState(peer) == .selected + ? AppTheme.accent : AppTheme.glassBorder + } + + private func rowBorderWidth(_ peer: MCPeerID) -> CGFloat { + isMulticamScanner && viewModel.multicamRowState(peer) == .selected ? 2 : 0.5 + } + + /// "Connect All" — invites every discovered, not-yet-selected camera up to + /// the cap. Hidden once everything discovered is already selected. + @ViewBuilder + private var connectAllRow: some View { + let unselectedCount = viewModel.connectedPeers.filter { + viewModel.multicamRowState($0) == .unselected + }.count + if unselectedCount > 0 { + Button { + onConnectAll?() + } label: { + HStack(spacing: 10) { + Image(systemName: "checkmark.circle.badge.questionmark") + .font(.title3) + Text(NSLocalizedString("Connect All", comment: "select every discovered camera")) + .fontWeight(.semibold) + Spacer() + } + .foregroundColor(AppTheme.accent) + .padding(14) + .background(AppTheme.accentSubtle) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .buttonStyle(GlassPressStyle()) } } diff --git a/RemoteCam/DeviceScannerViewController.swift b/RemoteCam/DeviceScannerViewController.swift index 44045395..4003275c 100644 --- a/RemoteCam/DeviceScannerViewController.swift +++ b/RemoteCam/DeviceScannerViewController.swift @@ -179,16 +179,12 @@ public class DeviceScannerViewController: UIViewController { }, onSelectPeer: { [weak self] peer in guard let self = self else { return } - // Multicam collecting: block a connection past the tier cap and - // route to the paywall instead. (Non-multicam is unaffected — - // the count stays 0.) - if FeatureFlags.ENABLE_MULTICAM && self.role == .monitor - && self.scannerViewModel.multicamCollectedCount >= StoreManager.shared.maxCameras() { - self.presentMulticamPaywall() - return + if FeatureFlags.ENABLE_MULTICAM && self.role == .monitor { + self.handleMulticamRowTap(peer) + } else { + self.remoteCamSession ! ConnectToDevice(peer: peer, sender: nil) + self.scannerViewModel.connectingToPeer() } - self.remoteCamSession ! ConnectToDevice(peer: peer, sender: nil) - self.scannerViewModel.connectingToPeer() }, onCancelConnect: { [weak self] in self?.remoteCamSession ! UICmd.CancelConnect(sender: nil) @@ -204,6 +200,9 @@ public class DeviceScannerViewController: UIViewController { }, onStartMulticam: (FeatureFlags.ENABLE_MULTICAM && role == .monitor) ? { [weak self] in self?.startMulticamSession() } + : nil, + onConnectAll: (FeatureFlags.ENABLE_MULTICAM && role == .monitor) + ? { [weak self] in self?.handleConnectAll() } : nil ) @@ -370,6 +369,38 @@ public class DeviceScannerViewController: UIViewController { } } + /// A tap on a discovered-camera row in multicam collecting mode. Toggling + /// off (deselect) is always allowed; toggling on is blocked past the tier + /// cap and routed to the paywall. + private func handleMulticamRowTap(_ peer: MCPeerID) { + let vm = scannerViewModel + switch vm.multicamRowState(peer) { + case .selected: + remoteCamSession ! UICmd.ToggleMulticamCamera(peer: peer) + case .connecting: + break // already in flight; ignore repeat taps + case .unselected: + guard vm.multicamCollectedCount + vm.multicamConnectingPeers.count + < StoreManager.shared.maxCameras() else { + presentMulticamPaywall() + return + } + vm.multicamConnectingPeers.insert(peer) + remoteCamSession ! UICmd.ToggleMulticamCamera(peer: peer) + } + } + + /// "Connect All": select every discovered, not-yet-selected camera up to + /// the cap. Over-cap rows keep their lock affordance (handled by per-row + /// taps routing to the paywall). + private func handleConnectAll() { + let vm = scannerViewModel + for peer in vm.peersToConnectAll(maxCameras: StoreManager.shared.maxCameras()) { + vm.multicamConnectingPeers.insert(peer) + remoteCamSession ! UICmd.ToggleMulticamCamera(peer: peer) + } + } + /// The shared Settings/paywall sheet — the same one the monitor gates use. private func presentMulticamPaywall() { let ctrl = UIHostingController(rootView: SettingsView()) @@ -423,9 +454,10 @@ extension DeviceScannerViewController: ScannerLobby { navigationController?.popToViewController(self, animated: true) } - /// Multicam collecting: surface the running count so "Start (N)" appears. + /// Multicam collecting: reconcile the per-row selection + the "Start (N)" + /// count from the coordinator's effective set. func didCollectMulticamCameras(_ peers: [MCPeerID]) { - scannerViewModel.multicamCollectedCount = peers.count + scannerViewModel.updateMulticamSelection(peers) } func presentScanningError() { diff --git a/RemoteCam/DeviceScannerViewModel.swift b/RemoteCam/DeviceScannerViewModel.swift index 29e494a4..35621da5 100644 --- a/RemoteCam/DeviceScannerViewModel.swift +++ b/RemoteCam/DeviceScannerViewModel.swift @@ -56,6 +56,42 @@ final class DeviceScannerViewModel: ObservableObject { /// Multicam director collecting: how many cameras are connected so far. /// Drives the "Start (N)" affordance; stays 0 in the single-camera build. @Published var multicamCollectedCount: Int = 0 + /// The cameras currently in the rig (selected), for the per-row checkmark. + @Published var multicamSelectedPeers: Set = [] + /// Cameras whose invite is in flight, for the per-row spinner (a set so + /// "Connect All" can show several at once). + @Published var multicamConnectingPeers: Set = [] + + /// Reconcile the multicam selection from the coordinator's effective set: + /// updates the checkmarks and count, and clears the spinner for any camera + /// that has now joined. + func updateMulticamSelection(_ peers: [MCPeerID]) { + multicamSelectedPeers = Set(peers) + multicamCollectedCount = peers.count + multicamConnectingPeers.subtract(multicamSelectedPeers) + } + + /// Row selection state for the edit-mode circle. + enum MulticamRowState { case unselected, connecting, selected } + func multicamRowState(_ peer: MCPeerID) -> MulticamRowState { + if multicamSelectedPeers.contains(peer) { return .selected } + if multicamConnectingPeers.contains(peer) { return .connecting } + return .unselected + } + + /// The discovered cameras "Connect All" should invite: every unselected + /// one, in list order, up to the remaining room under `maxCameras` (already + /// selected and in-flight cameras count against it). + func peersToConnectAll(maxCameras: Int) -> [MCPeerID] { + var pending = multicamSelectedPeers.count + multicamConnectingPeers.count + var result: [MCPeerID] = [] + for peer in connectedPeers where multicamRowState(peer) == .unselected { + guard pending < maxCameras else { break } + result.append(peer) + pending += 1 + } + return result + } /// When the current scan began. Not @Published: the view samples it on a /// TimelineView clock, so publishing would only cause redundant redraws. diff --git a/RemoteCam/SessionCoordinator.swift b/RemoteCam/SessionCoordinator.swift index ee9ff87b..f2fdcea1 100644 --- a/RemoteCam/SessionCoordinator.swift +++ b/RemoteCam/SessionCoordinator.swift @@ -273,10 +273,24 @@ public actor SessionCoordinator { /// monitor; the scanner reads `multicamCollectedPeers` on "Start". private var multicamCollecting = false private var multicamCollectedPeers: [MCPeerID] = [] + /// Cameras the user has tapped to remove from the rig while collecting. + /// Logical only — the transport keeps the connection (no per-peer teardown) + /// — so a deselected peer is simply excluded from the effective set the + /// count, handoff and scanner all read. + private var multicamDeselectedPeers: Set = [] + + /// The cameras actually in the rig: connected, tracked, and not deselected. + private func effectiveMulticamPeers() -> [MCPeerID] { + let connected = Set(connectedPeers) + return multicamCollectedPeers.filter { + connected.contains($0) && !multicamDeselectedPeers.contains($0) + } + } /// Test support. func multicamCollectingForTesting() -> Bool { multicamCollecting } func multicamCollectedPeersForTesting() -> [MCPeerID] { multicamCollectedPeers } + func effectiveMulticamPeersForTesting() -> [MCPeerID] { effectiveMulticamPeers() } /// Test support. func monitorReceivedVP9FrameForTesting() -> Bool { monitorReceivedVP9Frame } @@ -353,7 +367,7 @@ public actor SessionCoordinator { /// How many cameras the collecting scanner has connected. The scanner /// reads this on "Start" to choose the single-camera vs director path. - func multicamConnectedCount() -> Int { multicamCollecting ? connectedPeers.count : 0 } + func multicamConnectedCount() -> Int { multicamCollecting ? effectiveMulticamPeers().count : 0 } /// Hand the live transport (and the ≥2 cameras it is connected to) to a /// `MulticamController`. Detaches this coordinator from the transport — @@ -364,12 +378,13 @@ public actor SessionCoordinator { /// camera case stays on the classic monitor — see below). func detachTransportForMulticam() -> (transport: any MultipeerServiceProtocol, peers: [MCPeerID])? { guard multicamCollecting, let transport = multipeerService else { return nil } - let peers = transport.connectedPeers + let peers = effectiveMulticamPeers() guard peers.count >= 2 else { return nil } multipeerService = nil transportShared.value = nil multicamCollecting = false multicamCollectedPeers = [] + multicamDeselectedPeers = [] return (transport, peers) } @@ -378,10 +393,12 @@ public actor SessionCoordinator { /// path runs exactly as it does without the flag. Returns false (leaving /// the scanner as-is) unless collecting with exactly one camera. func promoteSingleCollectedToConnected() async -> Bool { - guard multicamCollecting, connectedPeers.count == 1, - let peer = connectedPeers.first, let liveLobby = lobby?.value else { return false } + let effective = effectiveMulticamPeers() + guard multicamCollecting, effective.count == 1, + let peer = effective.first, let liveLobby = lobby?.value else { return false } multicamCollecting = false multicamCollectedPeers = [] + multicamDeselectedPeers = [] link = .linked(peer) OperationQueue.main.addOperation { liveLobby.scannerViewModel.connectedToPeer() @@ -791,6 +808,30 @@ public actor SessionCoordinator { // The machine popped its own way back here; nothing to end. break + case let toggle as UICmd.ToggleMulticamCamera: + guard multicamCollecting else { break } + let peer = toggle.peer + if effectiveMulticamPeers().contains(peer) { + // Selected → deselect (logical: the transport stays connected). + multicamDeselectedPeers.insert(peer) + } else if connectedPeers.contains(peer) { + // A deselected-but-still-connected camera → re-select instantly. + multicamDeselectedPeers.remove(peer) + if !multicamCollectedPeers.contains(peer) { multicamCollectedPeers.append(peer) } + } else { + // A fresh camera → invite it, exactly like a single-select tap. + multicamDeselectedPeers.remove(peer) + link = .inviting(peer, attempt: 1) + multipeerService?.invitePeer(peer, timeout: inviteTimeout) + OperationQueue.main.addOperation { + liveLobby.scannerViewModel.connectingToPeer() + } + } + let toggled = effectiveMulticamPeers() + OperationQueue.main.addOperation { + liveLobby.didCollectMulticamCameras(toggled) + } + case let connected as OnConnectToDevice: if multicamCollecting { // Accumulate and stay scanning: the director wants several @@ -801,8 +842,9 @@ public actor SessionCoordinator { if !multicamCollectedPeers.contains(connected.peer) { multicamCollectedPeers.append(connected.peer) } + multicamDeselectedPeers.remove(connected.peer) // a (re)connect selects link = .none // free the invite slot so the next camera can dial - let peers = multicamCollectedPeers + let peers = effectiveMulticamPeers() OperationQueue.main.addOperation { liveLobby.didCollectMulticamCameras(peers) } diff --git a/RemoteCam/UICmds.swift b/RemoteCam/UICmds.swift index 182093e8..68e24839 100644 --- a/RemoteCam/UICmds.swift +++ b/RemoteCam/UICmds.swift @@ -577,4 +577,17 @@ extension UICmd { super.init(sender: nil) } } + + /// Multicam collecting: the user tapped a discovered-camera row. The + /// coordinator toggles that peer's membership in the rig — invite a fresh + /// peer, re-select a deselected-but-connected one, or deselect a selected + /// one (logical, since the QUIC session has no per-peer teardown). The cap + /// is enforced by the scanner before an increasing toggle is sent. + public class ToggleMulticamCamera: Message, @unchecked Sendable { + let peer: MCPeerID + init(peer: MCPeerID) { + self.peer = peer + super.init(sender: nil) + } + } } diff --git a/RemoteCam/da.lproj/Localizable.strings b/RemoteCam/da.lproj/Localizable.strings index 1f6e5b11..d7f839f2 100644 --- a/RemoteCam/da.lproj/Localizable.strings +++ b/RemoteCam/da.lproj/Localizable.strings @@ -248,3 +248,4 @@ "Control one or more iPhone cameras" = "Styr et eller flere iPhone-kameraer"; "Direct up to 4 cameras at once" = "Instruér op til 4 kameraer på én gang"; "RECONNECTING" = "GENOPRETTER FORBINDELSE"; +"Connect All" = "Forbind alle"; diff --git a/RemoteCam/de-DE.lproj/Localizable.strings b/RemoteCam/de-DE.lproj/Localizable.strings index 8274fd8c..b6bbbdd0 100644 --- a/RemoteCam/de-DE.lproj/Localizable.strings +++ b/RemoteCam/de-DE.lproj/Localizable.strings @@ -369,3 +369,4 @@ "Control one or more iPhone cameras" = "Steuere eine oder mehrere iPhone-Kameras"; "Direct up to 4 cameras at once" = "Bis zu 4 Kameras gleichzeitig steuern"; "RECONNECTING" = "VERBINDUNG WIRD WIEDERHERGESTELLT"; +"Connect All" = "Alle verbinden"; diff --git a/RemoteCam/en.lproj/Localizable.strings b/RemoteCam/en.lproj/Localizable.strings index 34e801cb..a9ad798e 100644 --- a/RemoteCam/en.lproj/Localizable.strings +++ b/RemoteCam/en.lproj/Localizable.strings @@ -371,3 +371,4 @@ "Control one or more iPhone cameras" = "Control one or more iPhone cameras"; "Direct up to 4 cameras at once" = "Direct up to 4 cameras at once"; "RECONNECTING" = "RECONNECTING"; +"Connect All" = "Connect All"; diff --git a/RemoteCam/es-MX.lproj/Localizable.strings b/RemoteCam/es-MX.lproj/Localizable.strings index 271b305e..04745e68 100644 --- a/RemoteCam/es-MX.lproj/Localizable.strings +++ b/RemoteCam/es-MX.lproj/Localizable.strings @@ -263,3 +263,4 @@ "Control one or more iPhone cameras" = "Controla una o más cámaras de iPhone"; "Direct up to 4 cameras at once" = "Dirige hasta 4 cámaras a la vez"; "RECONNECTING" = "RECONECTANDO"; +"Connect All" = "Conectar todas"; diff --git a/RemoteCam/fr-FR.lproj/Localizable.strings b/RemoteCam/fr-FR.lproj/Localizable.strings index 58101c94..f24efecb 100644 --- a/RemoteCam/fr-FR.lproj/Localizable.strings +++ b/RemoteCam/fr-FR.lproj/Localizable.strings @@ -263,3 +263,4 @@ "Control one or more iPhone cameras" = "Contrôlez une ou plusieurs caméras iPhone"; "Direct up to 4 cameras at once" = "Dirigez jusqu'à 4 caméras à la fois"; "RECONNECTING" = "RECONNEXION"; +"Connect All" = "Tout connecter"; diff --git a/RemoteCam/hi.lproj/Localizable.strings b/RemoteCam/hi.lproj/Localizable.strings index 15f26fdc..159d0f3a 100644 --- a/RemoteCam/hi.lproj/Localizable.strings +++ b/RemoteCam/hi.lproj/Localizable.strings @@ -369,3 +369,4 @@ "Control one or more iPhone cameras" = "एक या अधिक iPhone कैमरों को नियंत्रित करें"; "Direct up to 4 cameras at once" = "एक साथ 4 कैमरों तक निर्देशित करें"; "RECONNECTING" = "फिर से कनेक्ट हो रहा है"; +"Connect All" = "सभी कनेक्ट करें"; diff --git a/RemoteCam/it.lproj/Localizable.strings b/RemoteCam/it.lproj/Localizable.strings index 92167e2c..cb387898 100644 --- a/RemoteCam/it.lproj/Localizable.strings +++ b/RemoteCam/it.lproj/Localizable.strings @@ -248,3 +248,4 @@ "Control one or more iPhone cameras" = "Controlla una o più fotocamere iPhone"; "Direct up to 4 cameras at once" = "Dirigi fino a 4 fotocamere contemporaneamente"; "RECONNECTING" = "RICONNESSIONE"; +"Connect All" = "Connetti tutte"; diff --git a/RemoteCam/ja.lproj/Localizable.strings b/RemoteCam/ja.lproj/Localizable.strings index de85a2e0..67af27f5 100644 --- a/RemoteCam/ja.lproj/Localizable.strings +++ b/RemoteCam/ja.lproj/Localizable.strings @@ -369,3 +369,4 @@ "Control one or more iPhone cameras" = "1台以上のiPhoneカメラを操作"; "Direct up to 4 cameras at once" = "最大4台のカメラを同時に操作"; "RECONNECTING" = "再接続中"; +"Connect All" = "すべて接続"; diff --git a/RemoteCam/ko.lproj/Localizable.strings b/RemoteCam/ko.lproj/Localizable.strings index d5749306..7ceae760 100644 --- a/RemoteCam/ko.lproj/Localizable.strings +++ b/RemoteCam/ko.lproj/Localizable.strings @@ -369,3 +369,4 @@ "Control one or more iPhone cameras" = "하나 이상의 iPhone 카메라 제어"; "Direct up to 4 cameras at once" = "최대 4대의 카메라를 동시에 제어"; "RECONNECTING" = "다시 연결 중"; +"Connect All" = "모두 연결"; diff --git a/RemoteCam/ms.lproj/Localizable.strings b/RemoteCam/ms.lproj/Localizable.strings index 860d1b7e..9fdfdfa0 100644 --- a/RemoteCam/ms.lproj/Localizable.strings +++ b/RemoteCam/ms.lproj/Localizable.strings @@ -369,3 +369,4 @@ "Control one or more iPhone cameras" = "Kawal satu atau lebih kamera iPhone"; "Direct up to 4 cameras at once" = "Arahkan sehingga 4 kamera serentak"; "RECONNECTING" = "MENYAMBUNG SEMULA"; +"Connect All" = "Sambung semua"; diff --git a/RemoteCam/pt-BR.lproj/Localizable.strings b/RemoteCam/pt-BR.lproj/Localizable.strings index 2cef1e0f..2bb3652e 100644 --- a/RemoteCam/pt-BR.lproj/Localizable.strings +++ b/RemoteCam/pt-BR.lproj/Localizable.strings @@ -369,3 +369,4 @@ "Control one or more iPhone cameras" = "Controle uma ou mais câmeras do iPhone"; "Direct up to 4 cameras at once" = "Dirija até 4 câmeras ao mesmo tempo"; "RECONNECTING" = "RECONECTANDO"; +"Connect All" = "Conectar todas"; diff --git a/RemoteCam/ru.lproj/Localizable.strings b/RemoteCam/ru.lproj/Localizable.strings index 20b5d929..455ea739 100644 --- a/RemoteCam/ru.lproj/Localizable.strings +++ b/RemoteCam/ru.lproj/Localizable.strings @@ -369,3 +369,4 @@ "Control one or more iPhone cameras" = "Управляйте одной или несколькими камерами iPhone"; "Direct up to 4 cameras at once" = "Управляйте до 4 камерами одновременно"; "RECONNECTING" = "ПЕРЕПОДКЛЮЧЕНИЕ"; +"Connect All" = "Подключить все"; diff --git a/RemoteCam/tr.lproj/Localizable.strings b/RemoteCam/tr.lproj/Localizable.strings index 45b98125..8ec1dda3 100644 --- a/RemoteCam/tr.lproj/Localizable.strings +++ b/RemoteCam/tr.lproj/Localizable.strings @@ -369,3 +369,4 @@ "Control one or more iPhone cameras" = "Bir veya daha fazla iPhone kamerasını kontrol edin"; "Direct up to 4 cameras at once" = "Aynı anda 4 kameraya kadar yönetin"; "RECONNECTING" = "YENİDEN BAĞLANIYOR"; +"Connect All" = "Tümünü bağla"; diff --git a/RemoteCam/vi.lproj/Localizable.strings b/RemoteCam/vi.lproj/Localizable.strings index 0036fcc9..29062044 100644 --- a/RemoteCam/vi.lproj/Localizable.strings +++ b/RemoteCam/vi.lproj/Localizable.strings @@ -369,3 +369,4 @@ "Control one or more iPhone cameras" = "Điều khiển một hoặc nhiều camera iPhone"; "Direct up to 4 cameras at once" = "Điều khiển tối đa 4 camera cùng lúc"; "RECONNECTING" = "ĐANG KẾT NỐI LẠI"; +"Connect All" = "Kết nối tất cả"; diff --git a/RemoteCam/zh-Hans.lproj/Localizable.strings b/RemoteCam/zh-Hans.lproj/Localizable.strings index c3985e6f..a572b89d 100644 --- a/RemoteCam/zh-Hans.lproj/Localizable.strings +++ b/RemoteCam/zh-Hans.lproj/Localizable.strings @@ -369,3 +369,4 @@ "Control one or more iPhone cameras" = "控制一台或多台 iPhone 相机"; "Direct up to 4 cameras at once" = "同时导演最多 4 台相机"; "RECONNECTING" = "正在重新连接"; +"Connect All" = "全部连接"; diff --git a/RemoteCamTests/DeviceScannerViewModelTests.swift b/RemoteCamTests/DeviceScannerViewModelTests.swift index 266c615f..8054aa91 100644 --- a/RemoteCamTests/DeviceScannerViewModelTests.swift +++ b/RemoteCamTests/DeviceScannerViewModelTests.swift @@ -510,6 +510,49 @@ final class LocalNetworkProbeTests: XCTestCase { XCTAssertEqual(LocalNetworkProbe.verdict(for: .ready), .proceed) } + // MARK: - Multicam edit-mode selection + + func testMulticamRowStateTransitions() { + let vm = DeviceScannerViewModel() + let a = MCPeerID(displayName: "A") + XCTAssertEqual(vm.multicamRowState(a), .unselected) + + vm.multicamConnectingPeers.insert(a) + XCTAssertEqual(vm.multicamRowState(a), .connecting) + + // The coordinator reports A joined → checkmark, spinner cleared. + vm.updateMulticamSelection([a]) + XCTAssertEqual(vm.multicamRowState(a), .selected) + XCTAssertTrue(vm.multicamConnectingPeers.isEmpty) + XCTAssertEqual(vm.multicamCollectedCount, 1) + + // Deselect round-trip: the coordinator reports the effective set shrank. + vm.updateMulticamSelection([]) + XCTAssertEqual(vm.multicamRowState(a), .unselected) + XCTAssertEqual(vm.multicamCollectedCount, 0) + } + + func testConnectAllRespectsTheCap() { + let vm = DeviceScannerViewModel() + let peers = (1...5).map { MCPeerID(displayName: "Cam\($0)") } + peers.forEach { vm.addPeer($0) } + + // Free tier (cap 2), nothing selected yet → invite the first two only. + XCTAssertEqual(vm.peersToConnectAll(maxCameras: 2), Array(peers.prefix(2))) + + // One already selected → only one more slot. + vm.updateMulticamSelection([peers[0]]) + XCTAssertEqual(vm.peersToConnectAll(maxCameras: 2), [peers[1]]) + + // Pro tier (cap 4) with one selected → three more. + XCTAssertEqual(vm.peersToConnectAll(maxCameras: 4), Array(peers[1...3])) + + // In-flight cameras also count against the cap. + vm.multicamConnectingPeers.insert(peers[1]) + XCTAssertEqual(vm.peersToConnectAll(maxCameras: 2), [], + "one selected + one connecting fills the free cap") + } + func testStatesWithNoEvidenceKeepWaiting() { XCTAssertEqual(LocalNetworkProbe.verdict(for: .setup), .keepWaiting) XCTAssertEqual(LocalNetworkProbe.verdict(for: .cancelled), .keepWaiting) diff --git a/RemoteCamTests/RemoteCamSessionTests.swift b/RemoteCamTests/RemoteCamSessionTests.swift index 31f97adc..ac3b672f 100644 --- a/RemoteCamTests/RemoteCamSessionTests.swift +++ b/RemoteCamTests/RemoteCamSessionTests.swift @@ -117,6 +117,46 @@ class SessionCoordinatorTests: XCTestCase { await harness.coordinator.seed(state: .scanning, lobby: harness.lobbyWrapper) } + // MARK: - Multicam collecting: select / deselect / reselect + + func testMulticamToggleSelectConnectDeselectReselect() async { + await seedScanning() + await harness.deliver(UICmd.SetMulticamCollecting(on: true)) + harness.fakeMP.connectedPeers = [] + let camA = MCPeerID(displayName: "CamA") + + // Tap a fresh camera → it is invited (nothing selected yet). + await harness.deliver(UICmd.ToggleMulticamCamera(peer: camA)) + XCTAssertEqual(harness.fakeMP.invitedPeers.map(\.peer), [camA]) + var effective = await harness.coordinator.effectiveMulticamPeersForTesting() + XCTAssertTrue(effective.isEmpty, "not selected until it actually connects") + + // It connects → now selected. + harness.fakeMP.connectedPeers = [camA] + await harness.deliver(OnConnectToDevice(peer: camA, sender: nil)) + effective = await harness.coordinator.effectiveMulticamPeersForTesting() + XCTAssertEqual(effective, [camA]) + var count = await harness.coordinator.multicamConnectedCount() + XCTAssertEqual(count, 1) + + // Tap again → deselect (logical; the transport stays connected). + await harness.deliver(UICmd.ToggleMulticamCamera(peer: camA)) + effective = await harness.coordinator.effectiveMulticamPeersForTesting() + XCTAssertTrue(effective.isEmpty) + count = await harness.coordinator.multicamConnectedCount() + XCTAssertEqual(count, 0) + XCTAssertTrue(harness.fakeMP.connectedPeers.contains(camA), + "deselect is logical — no per-peer transport teardown") + + // Tap once more → re-select the still-connected camera, no re-invite. + harness.fakeMP.invitedPeers.removeAll() + await harness.deliver(UICmd.ToggleMulticamCamera(peer: camA)) + effective = await harness.coordinator.effectiveMulticamPeersForTesting() + XCTAssertEqual(effective, [camA]) + XCTAssertTrue(harness.fakeMP.invitedPeers.isEmpty, + "a still-connected camera is re-selected without re-inviting") + } + func testConnectInvitesWithLongTimeout() async { await seedScanning() await harness.deliver(ConnectToDevice(peer: harness.peer, sender: nil)) From 9a86fdb839d289dc66e0e655d09d91b13b143fdc Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Tue, 11 Aug 2026 01:46:48 -0700 Subject: [PATCH 11/17] Multicam scanner: select-then-connect (device feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dario's device pass found tapping a row connected immediately. Reworked to true two-phase selection: tap picks (zero network), a bottom CTA connects the chosen set. Semantics (monitor role, flag on): - Tap = pure selection. toggleMulticamSelection flips a checkmark and fires NO invite; the view model is entirely network-free. Spinner/connected/failed states appear only during the connect phase. - "Select All" (renamed from "Connect All") picks up to maxCameras(), pure. - Bottom CTA is "Connect (N)" (renamed from "Start (N)"): disabled at N=0, fires one invite per selected camera, shows per-row spinners, and hands off once every invite settles — 1 connected → classic monitor, ≥2 → director. A row that fails after the retry is marked; the rig proceeds with whoever connected; all-fail returns to the scanner with the existing error alert. - Per-peer connect retry/timeout reuses the 1:1 20s-invite / retry-once pattern, tracked per camera (multicamInviteAttempts) instead of the single `link`. - Single-cam / flag-off scanner byte-identical. Dead code removed (the logical-removal model only applied post-connect, which no longer happens in the scanner): UICmd.ToggleMulticamCamera, the coordinator deselected-set + effectiveMulticamPeers, and the VM's updateMulticamSelection / peersToConnectAll / multicamCollectedCount. Handoff decision extracted to a pure MulticamHandoff.decide. Strings: dropped "Start (%d)" and "Connect All", added "Connect (%d)" and "Select All" across all 15 locales. Parametrized screen tests (the point): a 0…10-discovered × {cap 2,4} × k sweep asserts selecting k rows yields zero invites, the CTA is disabled iff k==0 with label k, and Connect invites exactly the k selected; plus connecting→connected/failed transitions plus handoff destination, cap locking beyond max, empty state, and a 3-selected/1-fails partial. A coordinator-level guard asserts selection sends zero invitePeer on the fake transport (the regression). Full suite green (709). Co-Authored-By: Claude Fable 5 --- RemoteCam/DeviceScannerView.swift | 122 +++++++------- RemoteCam/DeviceScannerViewController.swift | 107 ++++++------ RemoteCam/DeviceScannerViewModel.swift | 141 ++++++++++++---- RemoteCam/ScannerLobby.swift | 11 +- RemoteCam/SessionCoordinator.swift | 93 +++++------ RemoteCam/UICmds.swift | 12 -- RemoteCam/da.lproj/Localizable.strings | 4 +- RemoteCam/de-DE.lproj/Localizable.strings | 4 +- RemoteCam/en.lproj/Localizable.strings | 4 +- RemoteCam/es-MX.lproj/Localizable.strings | 4 +- RemoteCam/fr-FR.lproj/Localizable.strings | 4 +- RemoteCam/hi.lproj/Localizable.strings | 4 +- RemoteCam/it.lproj/Localizable.strings | 4 +- RemoteCam/ja.lproj/Localizable.strings | 4 +- RemoteCam/ko.lproj/Localizable.strings | 4 +- RemoteCam/ms.lproj/Localizable.strings | 4 +- RemoteCam/pt-BR.lproj/Localizable.strings | 4 +- RemoteCam/ru.lproj/Localizable.strings | 4 +- RemoteCam/tr.lproj/Localizable.strings | 4 +- RemoteCam/vi.lproj/Localizable.strings | 4 +- RemoteCam/zh-Hans.lproj/Localizable.strings | 4 +- .../DeviceScannerViewModelTests.swift | 154 ++++++++++++++---- RemoteCamTests/RemoteCamSessionTests.swift | 76 +++++---- RemoteCamTests/SessionTestSupport.swift | 4 + 24 files changed, 489 insertions(+), 291 deletions(-) diff --git a/RemoteCam/DeviceScannerView.swift b/RemoteCam/DeviceScannerView.swift index 1ed353f8..cf4532e2 100644 --- a/RemoteCam/DeviceScannerView.swift +++ b/RemoteCam/DeviceScannerView.swift @@ -14,11 +14,10 @@ struct DeviceScannerView: View { let onShareApp: () -> Void let onOpenSettings: () -> Void let onHelp: () -> Void - /// Multicam only: begin a director session with the cameras collected so - /// far. Nil in the single-camera build (flag off), where it never shows. - var onStartMulticam: (() -> Void)? = nil - /// Multicam only: select every discovered camera up to the tier cap. - var onConnectAll: (() -> Void)? = nil + /// Multicam only: select every discovered camera up to the tier cap (pure). + var onSelectAll: (() -> Void)? = nil + /// Multicam only: connect the selected cameras (the bottom CTA). + var onConnectSelected: (() -> Void)? = nil /// The scanner is in multicam edit-mode selection (monitor role, flag on). private var isMulticamScanner: Bool { @@ -44,31 +43,35 @@ struct DeviceScannerView: View { connectingOverlay } - if viewModel.multicamCollectedCount >= 1, let onStartMulticam { - startMulticamButton(onStartMulticam) + if isMulticamScanner, let onConnectSelected { + connectSelectedButton(onConnectSelected) } PeerLinkOverlay(status: peerLink) } } - /// Floating "Start (N)" for the multicam collecting flow. One camera starts - /// the classic monitor; two or more starts the director grid. - private func startMulticamButton(_ action: @escaping () -> Void) -> some View { - VStack { - Spacer() - Button(action: action) { - Text(String(format: NSLocalizedString("Start (%d)", comment: "start multicam with N cameras"), - viewModel.multicamCollectedCount)) - .font(.headline) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding() - .background(AppTheme.accent) - .clipShape(RoundedRectangle(cornerRadius: 14)) + /// The bottom "Connect (N)" CTA: fires the invites for the selected set. + /// Disabled at N = 0; hidden entirely during the connecting phase. + @ViewBuilder + private func connectSelectedButton(_ action: @escaping () -> Void) -> some View { + if viewModel.multicamPhase == .selecting { + VStack { + Spacer() + Button(action: action) { + Text(String(format: NSLocalizedString("Connect (%d)", comment: "connect N selected cameras"), + viewModel.multicamSelectionCount)) + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(viewModel.canConnectMulticam ? AppTheme.accent : Color.gray) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .disabled(!viewModel.canConnectMulticam) + .padding(.horizontal, 24) + .padding(.bottom, 24) } - .padding(.horizontal, 24) - .padding(.bottom, 24) } } @@ -80,7 +83,7 @@ struct DeviceScannerView: View { statusBadge .padding(.top, 8) - if isMulticamScanner { connectAllRow } + if isMulticamScanner && viewModel.showsMulticamSelectAll { selectAllRow } ForEach(viewModel.connectedPeers, id: \.self) { peer in Button { @@ -95,7 +98,7 @@ struct DeviceScannerView: View { .padding(.top, 8) } .padding(.horizontal, 20) - .padding(.bottom, isMulticamScanner ? 100 : 40) // room for Start (N) + .padding(.bottom, isMulticamScanner ? 100 : 40) // room for Connect (N) } } @@ -133,11 +136,12 @@ struct DeviceScannerView: View { ) } - /// The edit-mode leading circle: empty → spinner → filled check. + /// The edit-mode leading circle. Selecting: empty ↔ filled check. Connecting + /// phase adds spinner (in flight), filled check (connected), warning (failed). @ViewBuilder private func multicamSelectionCircle(_ peer: MCPeerID) -> some View { switch viewModel.multicamRowState(peer) { - case .selected: + case .selected, .connected: Image(systemName: "checkmark.circle.fill") .font(.title2) .foregroundColor(AppTheme.accent) @@ -145,6 +149,11 @@ struct DeviceScannerView: View { case .connecting: ProgressView() .frame(width: 40, height: 40) + case .failed: + Image(systemName: "exclamationmark.circle.fill") + .font(.title2) + .foregroundColor(.orange) + .frame(width: 40, height: 40) case .unselected: Image(systemName: "circle") .font(.title2) @@ -156,10 +165,9 @@ struct DeviceScannerView: View { @ViewBuilder private func peerRowTrailing(_ peer: MCPeerID) -> some View { if isMulticamScanner { - // At the cap, an unselected row shows a lock: tapping it opens the - // paywall (handled by the host). - if viewModel.multicamRowState(peer) == .unselected - && viewModel.multicamCollectedCount >= StoreManager.shared.maxCameras() { + // While selecting, an over-cap unselected row shows a lock; tapping + // it opens the paywall (handled by the host). + if viewModel.multicamRowLocked(peer, maxCameras: StoreManager.shared.maxCameras()) { Image(systemName: "lock.fill") .font(.subheadline) .foregroundColor(.secondary) @@ -173,39 +181,41 @@ struct DeviceScannerView: View { } private func rowBorderColor(_ peer: MCPeerID) -> Color { - isMulticamScanner && viewModel.multicamRowState(peer) == .selected - ? AppTheme.accent : AppTheme.glassBorder + guard isMulticamScanner else { return AppTheme.glassBorder } + switch viewModel.multicamRowState(peer) { + case .selected, .connected, .connecting: return AppTheme.accent + default: return AppTheme.glassBorder + } } private func rowBorderWidth(_ peer: MCPeerID) -> CGFloat { - isMulticamScanner && viewModel.multicamRowState(peer) == .selected ? 2 : 0.5 + guard isMulticamScanner else { return 0.5 } + switch viewModel.multicamRowState(peer) { + case .selected, .connected, .connecting: return 2 + default: return 0.5 + } } - /// "Connect All" — invites every discovered, not-yet-selected camera up to - /// the cap. Hidden once everything discovered is already selected. + /// "Select All" — picks every discovered, not-yet-selected camera up to the + /// cap (pure). Offered only while selecting and something is unselected. @ViewBuilder - private var connectAllRow: some View { - let unselectedCount = viewModel.connectedPeers.filter { - viewModel.multicamRowState($0) == .unselected - }.count - if unselectedCount > 0 { - Button { - onConnectAll?() - } label: { - HStack(spacing: 10) { - Image(systemName: "checkmark.circle.badge.questionmark") - .font(.title3) - Text(NSLocalizedString("Connect All", comment: "select every discovered camera")) - .fontWeight(.semibold) - Spacer() - } - .foregroundColor(AppTheme.accent) - .padding(14) - .background(AppTheme.accentSubtle) - .clipShape(RoundedRectangle(cornerRadius: 14)) + private var selectAllRow: some View { + Button { + onSelectAll?() + } label: { + HStack(spacing: 10) { + Image(systemName: "checklist") + .font(.title3) + Text(NSLocalizedString("Select All", comment: "select every discovered camera")) + .fontWeight(.semibold) + Spacer() } - .buttonStyle(GlassPressStyle()) + .foregroundColor(AppTheme.accent) + .padding(14) + .background(AppTheme.accentSubtle) + .clipShape(RoundedRectangle(cornerRadius: 14)) } + .buttonStyle(GlassPressStyle()) } // MARK: - Camera Waiting State diff --git a/RemoteCam/DeviceScannerViewController.swift b/RemoteCam/DeviceScannerViewController.swift index 4003275c..ed69fdcd 100644 --- a/RemoteCam/DeviceScannerViewController.swift +++ b/RemoteCam/DeviceScannerViewController.swift @@ -198,11 +198,11 @@ public class DeviceScannerViewController: UIViewController { onHelp: { [weak self] in self?.showHelpModal() }, - onStartMulticam: (FeatureFlags.ENABLE_MULTICAM && role == .monitor) - ? { [weak self] in self?.startMulticamSession() } + onSelectAll: (FeatureFlags.ENABLE_MULTICAM && role == .monitor) + ? { [weak self] in self?.handleSelectAll() } : nil, - onConnectAll: (FeatureFlags.ENABLE_MULTICAM && role == .monitor) - ? { [weak self] in self?.handleConnectAll() } + onConnectSelected: (FeatureFlags.ENABLE_MULTICAM && role == .monitor) + ? { [weak self] in self?.handleConnectSelected() } : nil ) @@ -349,58 +349,65 @@ public class DeviceScannerViewController: UIViewController { /// Multicam "Start (N)": one camera runs the classic 1:1 monitor /// (unchanged); two or more hands the live transport to a /// `MulticamController` and pushes the director screen. - private func startMulticamSession() { - Task { @MainActor in - // Two or more cameras: hand the live transport to a director. - if let handoff = await remoteCamSession.detachTransportForMulticam() { - let controller = MulticamController() - await controller.install(transport: handoff.transport, - initialPeers: handoff.peers, - mode: .photo) - let directorVC = MulticamViewController(controller: controller) - navigationController?.pushViewController(directorVC, animated: true) - return - } - // Exactly one camera: promote it to a normal session and run the - // classic 1:1 monitor, unchanged. - if await remoteCamSession.promoteSingleCollectedToConnected() { - goToRole() - } + /// A tap on a discovered-camera row while SELECTING. Pure — it toggles the + /// checkmark and fires zero network. An over-cap unselected row is locked + /// and routes to the paywall instead. + private func handleMulticamRowTap(_ peer: MCPeerID) { + let vm = scannerViewModel + if vm.multicamRowLocked(peer, maxCameras: StoreManager.shared.maxCameras()) { + presentMulticamPaywall() + return } + vm.toggleMulticamSelection(peer) } - /// A tap on a discovered-camera row in multicam collecting mode. Toggling - /// off (deselect) is always allowed; toggling on is blocked past the tier - /// cap and routed to the paywall. - private func handleMulticamRowTap(_ peer: MCPeerID) { - let vm = scannerViewModel - switch vm.multicamRowState(peer) { - case .selected: - remoteCamSession ! UICmd.ToggleMulticamCamera(peer: peer) - case .connecting: - break // already in flight; ignore repeat taps - case .unselected: - guard vm.multicamCollectedCount + vm.multicamConnectingPeers.count - < StoreManager.shared.maxCameras() else { - presentMulticamPaywall() - return - } - vm.multicamConnectingPeers.insert(peer) - remoteCamSession ! UICmd.ToggleMulticamCamera(peer: peer) + /// "Select All": pick every discovered camera up to the cap. Pure. + private func handleSelectAll() { + scannerViewModel.selectAllMulticam(maxCameras: StoreManager.shared.maxCameras()) + } + + /// "Connect (N)": leave selecting, fire an invite per selected camera, and + /// hand off once every invite has settled. + private func handleConnectSelected() { + let peers = scannerViewModel.beginMulticamConnecting() + guard !peers.isEmpty else { return } + for peer in peers { + remoteCamSession ! ConnectToDevice(peer: peer, sender: nil) } } - /// "Connect All": select every discovered, not-yet-selected camera up to - /// the cap. Over-cap rows keep their lock affordance (handled by per-row - /// taps routing to the paywall). - private func handleConnectAll() { + /// Every selected camera has connected or failed. Hand off to the right + /// screen, or fall back to the scanner if none connected. + private func finishMulticamConnectIfSettled() { let vm = scannerViewModel - for peer in vm.peersToConnectAll(maxCameras: StoreManager.shared.maxCameras()) { - vm.multicamConnectingPeers.insert(peer) - remoteCamSession ! UICmd.ToggleMulticamCamera(peer: peer) + guard vm.multicamConnectSettled else { return } + let connected = vm.multicamConnectedPeers + switch MulticamHandoff.decide( + connected: connectedPeersInSelectionOrder(connected)) { + case .none: + vm.resetMulticamToSelecting() + presentScanningError() + case .classicMonitor: + Task { @MainActor in + if await remoteCamSession.promoteSingleCollectedToConnected() { goToRole() } + } + case .director: + Task { @MainActor in + guard let handoff = await remoteCamSession.detachTransportForMulticam() else { return } + let controller = MulticamController() + await controller.install(transport: handoff.transport, + initialPeers: handoff.peers, mode: .photo) + let directorVC = MulticamViewController(controller: controller) + navigationController?.pushViewController(directorVC, animated: true) + } } } + /// The connected cameras in discovered-list order (stable handoff order). + private func connectedPeersInSelectionOrder(_ connected: Set) -> [MCPeerID] { + scannerViewModel.connectedPeers.filter { connected.contains($0) } + } + /// The shared Settings/paywall sheet — the same one the monitor gates use. private func presentMulticamPaywall() { let ctrl = UIHostingController(rootView: SettingsView()) @@ -457,7 +464,13 @@ extension DeviceScannerViewController: ScannerLobby { /// Multicam collecting: reconcile the per-row selection + the "Start (N)" /// count from the coordinator's effective set. func didCollectMulticamCameras(_ peers: [MCPeerID]) { - scannerViewModel.updateMulticamSelection(peers) + scannerViewModel.reconcileMulticamConnected(peers) + finishMulticamConnectIfSettled() + } + + func didFailMulticamCamera(_ peer: MCPeerID) { + scannerViewModel.markMulticamFailed(peer) + finishMulticamConnectIfSettled() } func presentScanningError() { diff --git a/RemoteCam/DeviceScannerViewModel.swift b/RemoteCam/DeviceScannerViewModel.swift index 35621da5..d9bd42d4 100644 --- a/RemoteCam/DeviceScannerViewModel.swift +++ b/RemoteCam/DeviceScannerViewModel.swift @@ -4,6 +4,23 @@ import Stormo import Network import dnssd +/// Where a settled multicam connect hands off to, decided purely by how many +/// cameras actually connected: none → stay (error), one → the classic 1:1 +/// monitor (unchanged), two or more → the director screen. +enum MulticamHandoff: Equatable { + case none + case classicMonitor(MCPeerID) + case director([MCPeerID]) + + static func decide(connected: [MCPeerID]) -> MulticamHandoff { + switch connected.count { + case 0: return .none + case 1: return .classicMonitor(connected[0]) + default: return .director(connected) + } + } +} + /// Classifies the pre-scan local-network probe: one browse whose only job is /// to learn whether the user denied Local Network permission, so the app can /// route them to Settings instead of scanning into silence. @@ -53,44 +70,112 @@ final class DeviceScannerViewModel: ObservableObject { @Published var hasScanningError: Bool = false @Published var isConnecting: Bool = false @Published var hasConnectionError: Bool = false - /// Multicam director collecting: how many cameras are connected so far. - /// Drives the "Start (N)" affordance; stays 0 in the single-camera build. - @Published var multicamCollectedCount: Int = 0 - /// The cameras currently in the rig (selected), for the per-row checkmark. + // MARK: - Multicam director: select-then-connect + + /// The multicam scanner is a two-phase edit-mode selector: pick cameras + /// (pure, zero network), THEN connect the chosen set. Selecting must never + /// invite — that was the device-test regression this model fixes. + enum MulticamScanPhase { case selecting, connecting } + @Published var multicamPhase: MulticamScanPhase = .selecting + + /// Cameras the user has picked (client-side only, no transport activity). @Published var multicamSelectedPeers: Set = [] - /// Cameras whose invite is in flight, for the per-row spinner (a set so - /// "Connect All" can show several at once). + /// Selected cameras whose invite is in flight (connecting phase only). @Published var multicamConnectingPeers: Set = [] + /// Cameras whose invite has established. + @Published var multicamConnectedPeers: Set = [] + /// Cameras whose invite failed (timed out after the retry). + @Published var multicamFailedPeers: Set = [] + + /// Pure selection toggle — no invite, no coordinator message. + func toggleMulticamSelection(_ peer: MCPeerID) { + guard multicamPhase == .selecting else { return } + if multicamSelectedPeers.contains(peer) { + multicamSelectedPeers.remove(peer) + } else { + multicamSelectedPeers.insert(peer) + } + } + + /// Select every discovered, not-yet-selected camera up to the cap (pure). + func selectAllMulticam(maxCameras: Int) { + guard multicamPhase == .selecting else { return } + for peer in connectedPeers where !multicamSelectedPeers.contains(peer) { + guard multicamSelectedPeers.count < maxCameras else { break } + multicamSelectedPeers.insert(peer) + } + } + + /// The "Connect (N)" CTA: enabled only while selecting with a non-empty set. + var canConnectMulticam: Bool { + multicamPhase == .selecting && !multicamSelectedPeers.isEmpty + } + var multicamSelectionCount: Int { multicamSelectedPeers.count } + + /// Enter the connecting phase; returns the peers to invite (the caller + /// fires the invites — the view model stays network-free). + func beginMulticamConnecting() -> [MCPeerID] { + guard multicamPhase == .selecting, !multicamSelectedPeers.isEmpty else { return [] } + multicamPhase = .connecting + multicamConnectingPeers = multicamSelectedPeers + multicamConnectedPeers = [] + multicamFailedPeers = [] + return connectedPeers.filter { multicamSelectedPeers.contains($0) } + } + + /// Reconcile the set of established cameras (reported by the coordinator). + func reconcileMulticamConnected(_ peers: [MCPeerID]) { + guard multicamPhase == .connecting else { return } + let connected = Set(peers).intersection(multicamSelectedPeers) + multicamConnectedPeers = connected + multicamConnectingPeers.subtract(connected) + } - /// Reconcile the multicam selection from the coordinator's effective set: - /// updates the checkmarks and count, and clears the spinner for any camera - /// that has now joined. - func updateMulticamSelection(_ peers: [MCPeerID]) { - multicamSelectedPeers = Set(peers) - multicamCollectedCount = peers.count - multicamConnectingPeers.subtract(multicamSelectedPeers) + /// One camera's invite failed for good. + func markMulticamFailed(_ peer: MCPeerID) { + guard multicamPhase == .connecting else { return } + multicamConnectingPeers.remove(peer) + if !multicamConnectedPeers.contains(peer) { multicamFailedPeers.insert(peer) } } - /// Row selection state for the edit-mode circle. - enum MulticamRowState { case unselected, connecting, selected } + /// The connect phase is over once no invite is still outstanding. + var multicamConnectSettled: Bool { + multicamPhase == .connecting && multicamConnectingPeers.isEmpty + } + + /// Back to a clean selecting phase (e.g. after all invites failed). + func resetMulticamToSelecting() { + multicamPhase = .selecting + multicamSelectedPeers = [] + multicamConnectingPeers = [] + multicamConnectedPeers = [] + multicamFailedPeers = [] + } + + /// Row state for the edit-mode circle. Selecting shows only empty/filled; + /// spinner/check/x appear only during the connect phase. + enum MulticamRowState { case unselected, selected, connecting, connected, failed } func multicamRowState(_ peer: MCPeerID) -> MulticamRowState { - if multicamSelectedPeers.contains(peer) { return .selected } + if multicamFailedPeers.contains(peer) { return .failed } + if multicamConnectedPeers.contains(peer) { return .connected } if multicamConnectingPeers.contains(peer) { return .connecting } + if multicamSelectedPeers.contains(peer) { return .selected } return .unselected } - /// The discovered cameras "Connect All" should invite: every unselected - /// one, in list order, up to the remaining room under `maxCameras` (already - /// selected and in-flight cameras count against it). - func peersToConnectAll(maxCameras: Int) -> [MCPeerID] { - var pending = multicamSelectedPeers.count + multicamConnectingPeers.count - var result: [MCPeerID] = [] - for peer in connectedPeers where multicamRowState(peer) == .unselected { - guard pending < maxCameras else { break } - result.append(peer) - pending += 1 - } - return result + /// An unselected row is locked (→ paywall) when the cap is already met + /// while selecting. + func multicamRowLocked(_ peer: MCPeerID, maxCameras: Int) -> Bool { + multicamPhase == .selecting + && multicamRowState(peer) == .unselected + && multicamSelectedPeers.count >= maxCameras + } + + /// Whether "Select All" should be offered — some discovered camera is still + /// unselected, and we are still selecting. + var showsMulticamSelectAll: Bool { + multicamPhase == .selecting + && connectedPeers.contains { !multicamSelectedPeers.contains($0) } } /// When the current scan began. Not @Published: the view samples it on a diff --git a/RemoteCam/ScannerLobby.swift b/RemoteCam/ScannerLobby.swift index dd9ec1e9..74b675c8 100644 --- a/RemoteCam/ScannerLobby.swift +++ b/RemoteCam/ScannerLobby.swift @@ -26,11 +26,15 @@ protocol ScannerLobby: AnyObject, Sendable { /// Navigate to the role picker after a peer connects. func goToRole() - /// Multicam collecting: the set of cameras connected so far grew. Lets the - /// scanner show a "Start (N)" affordance. Default no-op — only the - /// production scanner implements it, and only when `ENABLE_MULTICAM`. + /// Multicam "Connect (N)": the set of connected cameras changed (a selected + /// camera established). Default no-op — only the production scanner + /// implements it, and only when `ENABLE_MULTICAM`. func didCollectMulticamCameras(_ peers: [MCPeerID]) + /// Multicam "Connect (N)": a selected camera's invite failed for good, so + /// the scanner can mark that row and proceed with the ones that connected. + func didFailMulticamCamera(_ peer: MCPeerID) + /// Pop navigation back to the scanner screen (called when scanning restarts). func returnToLobby() @@ -40,6 +44,7 @@ protocol ScannerLobby: AnyObject, Sendable { extension ScannerLobby { func didCollectMulticamCameras(_ peers: [MCPeerID]) {} + func didFailMulticamCamera(_ peer: MCPeerID) {} } /// Binds a `ScannerLobby` to `RemoteCamSession` — the protocol-typed diff --git a/RemoteCam/SessionCoordinator.swift b/RemoteCam/SessionCoordinator.swift index f2fdcea1..604d9eab 100644 --- a/RemoteCam/SessionCoordinator.swift +++ b/RemoteCam/SessionCoordinator.swift @@ -273,24 +273,14 @@ public actor SessionCoordinator { /// monitor; the scanner reads `multicamCollectedPeers` on "Start". private var multicamCollecting = false private var multicamCollectedPeers: [MCPeerID] = [] - /// Cameras the user has tapped to remove from the rig while collecting. - /// Logical only — the transport keeps the connection (no per-peer teardown) - /// — so a deselected peer is simply excluded from the effective set the - /// count, handoff and scanner all read. - private var multicamDeselectedPeers: Set = [] - - /// The cameras actually in the rig: connected, tracked, and not deselected. - private func effectiveMulticamPeers() -> [MCPeerID] { - let connected = Set(connectedPeers) - return multicamCollectedPeers.filter { - connected.contains($0) && !multicamDeselectedPeers.contains($0) - } - } + /// Per-peer invite attempts during a multicam "Connect (N)" — the scanner + /// invites the whole selected set at once, so retry/timeout is tracked per + /// camera rather than through the single-peer `link`. + private var multicamInviteAttempts: [MCPeerID: Int] = [:] /// Test support. func multicamCollectingForTesting() -> Bool { multicamCollecting } func multicamCollectedPeersForTesting() -> [MCPeerID] { multicamCollectedPeers } - func effectiveMulticamPeersForTesting() -> [MCPeerID] { effectiveMulticamPeers() } /// Test support. func monitorReceivedVP9FrameForTesting() -> Bool { monitorReceivedVP9Frame } @@ -367,7 +357,7 @@ public actor SessionCoordinator { /// How many cameras the collecting scanner has connected. The scanner /// reads this on "Start" to choose the single-camera vs director path. - func multicamConnectedCount() -> Int { multicamCollecting ? effectiveMulticamPeers().count : 0 } + func multicamConnectedCount() -> Int { multicamCollecting ? connectedPeers.count : 0 } /// Hand the live transport (and the ≥2 cameras it is connected to) to a /// `MulticamController`. Detaches this coordinator from the transport — @@ -378,13 +368,13 @@ public actor SessionCoordinator { /// camera case stays on the classic monitor — see below). func detachTransportForMulticam() -> (transport: any MultipeerServiceProtocol, peers: [MCPeerID])? { guard multicamCollecting, let transport = multipeerService else { return nil } - let peers = effectiveMulticamPeers() + let peers = transport.connectedPeers guard peers.count >= 2 else { return nil } multipeerService = nil transportShared.value = nil multicamCollecting = false multicamCollectedPeers = [] - multicamDeselectedPeers = [] + multicamInviteAttempts = [:] return (transport, peers) } @@ -393,12 +383,11 @@ public actor SessionCoordinator { /// path runs exactly as it does without the flag. Returns false (leaving /// the scanner as-is) unless collecting with exactly one camera. func promoteSingleCollectedToConnected() async -> Bool { - let effective = effectiveMulticamPeers() - guard multicamCollecting, effective.count == 1, - let peer = effective.first, let liveLobby = lobby?.value else { return false } + guard multicamCollecting, connectedPeers.count == 1, + let peer = connectedPeers.first, let liveLobby = lobby?.value else { return false } multicamCollecting = false multicamCollectedPeers = [] - multicamDeselectedPeers = [] + multicamInviteAttempts = [:] link = .linked(peer) OperationQueue.main.addOperation { liveLobby.scannerViewModel.connectedToPeer() @@ -771,6 +760,14 @@ public actor SessionCoordinator { startScanning(lobby: liveLobby) case let connect as ConnectToDevice: + if multicamCollecting { + // "Connect (N)" fires an invite per selected camera; each is + // tracked independently (retry/timeout per peer) rather than + // through the single-peer `link`. + multicamInviteAttempts[connect.peer] = 1 + multipeerService?.invitePeer(connect.peer, timeout: inviteTimeout) + break + } link = .inviting(connect.peer, attempt: 1) multipeerService?.invitePeer(connect.peer, timeout: inviteTimeout) OperationQueue.main.addOperation { @@ -778,6 +775,22 @@ public actor SessionCoordinator { } case let disconnected as DisconnectPeer: + if multicamCollecting, let peer = disconnected.peer, + let attempt = multicamInviteAttempts[peer] { + // A selected camera's invite dropped/timed out — retry once, + // then report it failed so the scanner can proceed with the + // cameras that did connect. + if attempt < maxConnectAttempts { + multicamInviteAttempts[peer] = attempt + 1 + multipeerService?.invitePeer(peer, timeout: inviteTimeout) + } else { + multicamInviteAttempts[peer] = nil + OperationQueue.main.addOperation { + liveLobby.didFailMulticamCamera(peer) + } + } + break + } guard let invited = link.invited, invited.peer == disconnected.peer else { startScanning(lobby: liveLobby) break @@ -808,43 +821,17 @@ public actor SessionCoordinator { // The machine popped its own way back here; nothing to end. break - case let toggle as UICmd.ToggleMulticamCamera: - guard multicamCollecting else { break } - let peer = toggle.peer - if effectiveMulticamPeers().contains(peer) { - // Selected → deselect (logical: the transport stays connected). - multicamDeselectedPeers.insert(peer) - } else if connectedPeers.contains(peer) { - // A deselected-but-still-connected camera → re-select instantly. - multicamDeselectedPeers.remove(peer) - if !multicamCollectedPeers.contains(peer) { multicamCollectedPeers.append(peer) } - } else { - // A fresh camera → invite it, exactly like a single-select tap. - multicamDeselectedPeers.remove(peer) - link = .inviting(peer, attempt: 1) - multipeerService?.invitePeer(peer, timeout: inviteTimeout) - OperationQueue.main.addOperation { - liveLobby.scannerViewModel.connectingToPeer() - } - } - let toggled = effectiveMulticamPeers() - OperationQueue.main.addOperation { - liveLobby.didCollectMulticamCameras(toggled) - } - case let connected as OnConnectToDevice: if multicamCollecting { - // Accumulate and stay scanning: the director wants several - // cameras, so keep browsing/inviting and let the scanner show a - // growing set. The transport holds every connection; the handoff - // reads them on "Start". No transition to `.connected` (which - // would stop browsing and auto-advance). + // A selected camera established. Accumulate and stay scanning + // (no transition to `.connected`, which would stop browsing); + // report the full connected set so the scanner advances the + // row to "connected" and, once every invite settles, hands off. + multicamInviteAttempts[connected.peer] = nil if !multicamCollectedPeers.contains(connected.peer) { multicamCollectedPeers.append(connected.peer) } - multicamDeselectedPeers.remove(connected.peer) // a (re)connect selects - link = .none // free the invite slot so the next camera can dial - let peers = effectiveMulticamPeers() + let peers = connectedPeers OperationQueue.main.addOperation { liveLobby.didCollectMulticamCameras(peers) } diff --git a/RemoteCam/UICmds.swift b/RemoteCam/UICmds.swift index 68e24839..3810a0c3 100644 --- a/RemoteCam/UICmds.swift +++ b/RemoteCam/UICmds.swift @@ -578,16 +578,4 @@ extension UICmd { } } - /// Multicam collecting: the user tapped a discovered-camera row. The - /// coordinator toggles that peer's membership in the rig — invite a fresh - /// peer, re-select a deselected-but-connected one, or deselect a selected - /// one (logical, since the QUIC session has no per-peer teardown). The cap - /// is enforced by the scanner before an increasing toggle is sent. - public class ToggleMulticamCamera: Message, @unchecked Sendable { - let peer: MCPeerID - init(peer: MCPeerID) { - self.peer = peer - super.init(sender: nil) - } - } } diff --git a/RemoteCam/da.lproj/Localizable.strings b/RemoteCam/da.lproj/Localizable.strings index d7f839f2..67b6bb94 100644 --- a/RemoteCam/da.lproj/Localizable.strings +++ b/RemoteCam/da.lproj/Localizable.strings @@ -240,7 +240,6 @@ "IncompatibleUpdateButton" = "Opdater"; // Multicam director (behind ENABLE_MULTICAM) -"Start (%d)" = "Start (%d)"; "Add camera" = "Tilføj kamera"; "Add" = "Tilføj"; "Searching for cameras…" = "Søger efter kameraer …"; @@ -248,4 +247,5 @@ "Control one or more iPhone cameras" = "Styr et eller flere iPhone-kameraer"; "Direct up to 4 cameras at once" = "Instruér op til 4 kameraer på én gang"; "RECONNECTING" = "GENOPRETTER FORBINDELSE"; -"Connect All" = "Forbind alle"; +"Connect (%d)" = "Forbind (%d)"; +"Select All" = "Vælg alle"; diff --git a/RemoteCam/de-DE.lproj/Localizable.strings b/RemoteCam/de-DE.lproj/Localizable.strings index b6bbbdd0..7461ca4e 100644 --- a/RemoteCam/de-DE.lproj/Localizable.strings +++ b/RemoteCam/de-DE.lproj/Localizable.strings @@ -361,7 +361,6 @@ "IncompatibleUpdateButton" = "Aktualisieren"; // Multicam director (behind ENABLE_MULTICAM) -"Start (%d)" = "Starten (%d)"; "Add camera" = "Kamera hinzufügen"; "Add" = "Hinzufügen"; "Searching for cameras…" = "Suche nach Kameras …"; @@ -369,4 +368,5 @@ "Control one or more iPhone cameras" = "Steuere eine oder mehrere iPhone-Kameras"; "Direct up to 4 cameras at once" = "Bis zu 4 Kameras gleichzeitig steuern"; "RECONNECTING" = "VERBINDUNG WIRD WIEDERHERGESTELLT"; -"Connect All" = "Alle verbinden"; +"Connect (%d)" = "Verbinden (%d)"; +"Select All" = "Alle auswählen"; diff --git a/RemoteCam/en.lproj/Localizable.strings b/RemoteCam/en.lproj/Localizable.strings index a9ad798e..2734eca4 100644 --- a/RemoteCam/en.lproj/Localizable.strings +++ b/RemoteCam/en.lproj/Localizable.strings @@ -361,7 +361,6 @@ "IncompatibleUpdateButton" = "Update"; // Multicam director (behind ENABLE_MULTICAM) -"Start (%d)" = "Start (%d)"; // Multicam director (behind ENABLE_MULTICAM) "Add camera" = "Add camera"; @@ -371,4 +370,5 @@ "Control one or more iPhone cameras" = "Control one or more iPhone cameras"; "Direct up to 4 cameras at once" = "Direct up to 4 cameras at once"; "RECONNECTING" = "RECONNECTING"; -"Connect All" = "Connect All"; +"Connect (%d)" = "Connect (%d)"; +"Select All" = "Select All"; diff --git a/RemoteCam/es-MX.lproj/Localizable.strings b/RemoteCam/es-MX.lproj/Localizable.strings index 04745e68..392364c6 100644 --- a/RemoteCam/es-MX.lproj/Localizable.strings +++ b/RemoteCam/es-MX.lproj/Localizable.strings @@ -255,7 +255,6 @@ "IncompatibleUpdateButton" = "Actualizar"; // Multicam director (behind ENABLE_MULTICAM) -"Start (%d)" = "Iniciar (%d)"; "Add camera" = "Agregar cámara"; "Add" = "Agregar"; "Searching for cameras…" = "Buscando cámaras…"; @@ -263,4 +262,5 @@ "Control one or more iPhone cameras" = "Controla una o más cámaras de iPhone"; "Direct up to 4 cameras at once" = "Dirige hasta 4 cámaras a la vez"; "RECONNECTING" = "RECONECTANDO"; -"Connect All" = "Conectar todas"; +"Connect (%d)" = "Conectar (%d)"; +"Select All" = "Seleccionar todas"; diff --git a/RemoteCam/fr-FR.lproj/Localizable.strings b/RemoteCam/fr-FR.lproj/Localizable.strings index f24efecb..83d3e6a3 100644 --- a/RemoteCam/fr-FR.lproj/Localizable.strings +++ b/RemoteCam/fr-FR.lproj/Localizable.strings @@ -255,7 +255,6 @@ "IncompatibleUpdateButton" = "Mettre à jour"; // Multicam director (behind ENABLE_MULTICAM) -"Start (%d)" = "Démarrer (%d)"; "Add camera" = "Ajouter une caméra"; "Add" = "Ajouter"; "Searching for cameras…" = "Recherche de caméras…"; @@ -263,4 +262,5 @@ "Control one or more iPhone cameras" = "Contrôlez une ou plusieurs caméras iPhone"; "Direct up to 4 cameras at once" = "Dirigez jusqu'à 4 caméras à la fois"; "RECONNECTING" = "RECONNEXION"; -"Connect All" = "Tout connecter"; +"Connect (%d)" = "Connecter (%d)"; +"Select All" = "Tout sélectionner"; diff --git a/RemoteCam/hi.lproj/Localizable.strings b/RemoteCam/hi.lproj/Localizable.strings index 159d0f3a..461b35e2 100644 --- a/RemoteCam/hi.lproj/Localizable.strings +++ b/RemoteCam/hi.lproj/Localizable.strings @@ -361,7 +361,6 @@ "IncompatibleUpdateButton" = "अपडेट करें"; // Multicam director (behind ENABLE_MULTICAM) -"Start (%d)" = "शुरू करें (%d)"; "Add camera" = "कैमरा जोड़ें"; "Add" = "जोड़ें"; "Searching for cameras…" = "कैमरे खोजे जा रहे हैं…"; @@ -369,4 +368,5 @@ "Control one or more iPhone cameras" = "एक या अधिक iPhone कैमरों को नियंत्रित करें"; "Direct up to 4 cameras at once" = "एक साथ 4 कैमरों तक निर्देशित करें"; "RECONNECTING" = "फिर से कनेक्ट हो रहा है"; -"Connect All" = "सभी कनेक्ट करें"; +"Connect (%d)" = "कनेक्ट करें (%d)"; +"Select All" = "सभी चुनें"; diff --git a/RemoteCam/it.lproj/Localizable.strings b/RemoteCam/it.lproj/Localizable.strings index cb387898..a4bbb29f 100644 --- a/RemoteCam/it.lproj/Localizable.strings +++ b/RemoteCam/it.lproj/Localizable.strings @@ -240,7 +240,6 @@ "IncompatibleUpdateButton" = "Aggiorna"; // Multicam director (behind ENABLE_MULTICAM) -"Start (%d)" = "Avvia (%d)"; "Add camera" = "Aggiungi fotocamera"; "Add" = "Aggiungi"; "Searching for cameras…" = "Ricerca fotocamere…"; @@ -248,4 +247,5 @@ "Control one or more iPhone cameras" = "Controlla una o più fotocamere iPhone"; "Direct up to 4 cameras at once" = "Dirigi fino a 4 fotocamere contemporaneamente"; "RECONNECTING" = "RICONNESSIONE"; -"Connect All" = "Connetti tutte"; +"Connect (%d)" = "Connetti (%d)"; +"Select All" = "Seleziona tutte"; diff --git a/RemoteCam/ja.lproj/Localizable.strings b/RemoteCam/ja.lproj/Localizable.strings index 67af27f5..a6622f75 100644 --- a/RemoteCam/ja.lproj/Localizable.strings +++ b/RemoteCam/ja.lproj/Localizable.strings @@ -361,7 +361,6 @@ "IncompatibleUpdateButton" = "更新"; // Multicam director (behind ENABLE_MULTICAM) -"Start (%d)" = "開始 (%d)"; "Add camera" = "カメラを追加"; "Add" = "追加"; "Searching for cameras…" = "カメラを検索中…"; @@ -369,4 +368,5 @@ "Control one or more iPhone cameras" = "1台以上のiPhoneカメラを操作"; "Direct up to 4 cameras at once" = "最大4台のカメラを同時に操作"; "RECONNECTING" = "再接続中"; -"Connect All" = "すべて接続"; +"Connect (%d)" = "接続 (%d)"; +"Select All" = "すべて選択"; diff --git a/RemoteCam/ko.lproj/Localizable.strings b/RemoteCam/ko.lproj/Localizable.strings index 7ceae760..9fefefb8 100644 --- a/RemoteCam/ko.lproj/Localizable.strings +++ b/RemoteCam/ko.lproj/Localizable.strings @@ -361,7 +361,6 @@ "IncompatibleUpdateButton" = "업데이트"; // Multicam director (behind ENABLE_MULTICAM) -"Start (%d)" = "시작 (%d)"; "Add camera" = "카메라 추가"; "Add" = "추가"; "Searching for cameras…" = "카메라 검색 중…"; @@ -369,4 +368,5 @@ "Control one or more iPhone cameras" = "하나 이상의 iPhone 카메라 제어"; "Direct up to 4 cameras at once" = "최대 4대의 카메라를 동시에 제어"; "RECONNECTING" = "다시 연결 중"; -"Connect All" = "모두 연결"; +"Connect (%d)" = "연결 (%d)"; +"Select All" = "모두 선택"; diff --git a/RemoteCam/ms.lproj/Localizable.strings b/RemoteCam/ms.lproj/Localizable.strings index 9fdfdfa0..e8185662 100644 --- a/RemoteCam/ms.lproj/Localizable.strings +++ b/RemoteCam/ms.lproj/Localizable.strings @@ -361,7 +361,6 @@ "IncompatibleUpdateButton" = "Kemas kini"; // Multicam director (behind ENABLE_MULTICAM) -"Start (%d)" = "Mula (%d)"; "Add camera" = "Tambah kamera"; "Add" = "Tambah"; "Searching for cameras…" = "Mencari kamera…"; @@ -369,4 +368,5 @@ "Control one or more iPhone cameras" = "Kawal satu atau lebih kamera iPhone"; "Direct up to 4 cameras at once" = "Arahkan sehingga 4 kamera serentak"; "RECONNECTING" = "MENYAMBUNG SEMULA"; -"Connect All" = "Sambung semua"; +"Connect (%d)" = "Sambung (%d)"; +"Select All" = "Pilih semua"; diff --git a/RemoteCam/pt-BR.lproj/Localizable.strings b/RemoteCam/pt-BR.lproj/Localizable.strings index 2bb3652e..416aad11 100644 --- a/RemoteCam/pt-BR.lproj/Localizable.strings +++ b/RemoteCam/pt-BR.lproj/Localizable.strings @@ -361,7 +361,6 @@ "IncompatibleUpdateButton" = "Atualizar"; // Multicam director (behind ENABLE_MULTICAM) -"Start (%d)" = "Iniciar (%d)"; "Add camera" = "Adicionar câmera"; "Add" = "Adicionar"; "Searching for cameras…" = "Procurando câmeras…"; @@ -369,4 +368,5 @@ "Control one or more iPhone cameras" = "Controle uma ou mais câmeras do iPhone"; "Direct up to 4 cameras at once" = "Dirija até 4 câmeras ao mesmo tempo"; "RECONNECTING" = "RECONECTANDO"; -"Connect All" = "Conectar todas"; +"Connect (%d)" = "Conectar (%d)"; +"Select All" = "Selecionar todas"; diff --git a/RemoteCam/ru.lproj/Localizable.strings b/RemoteCam/ru.lproj/Localizable.strings index 455ea739..2fd3c28e 100644 --- a/RemoteCam/ru.lproj/Localizable.strings +++ b/RemoteCam/ru.lproj/Localizable.strings @@ -361,7 +361,6 @@ "IncompatibleUpdateButton" = "Обновить"; // Multicam director (behind ENABLE_MULTICAM) -"Start (%d)" = "Начать (%d)"; "Add camera" = "Добавить камеру"; "Add" = "Добавить"; "Searching for cameras…" = "Поиск камер…"; @@ -369,4 +368,5 @@ "Control one or more iPhone cameras" = "Управляйте одной или несколькими камерами iPhone"; "Direct up to 4 cameras at once" = "Управляйте до 4 камерами одновременно"; "RECONNECTING" = "ПЕРЕПОДКЛЮЧЕНИЕ"; -"Connect All" = "Подключить все"; +"Connect (%d)" = "Подключить (%d)"; +"Select All" = "Выбрать все"; diff --git a/RemoteCam/tr.lproj/Localizable.strings b/RemoteCam/tr.lproj/Localizable.strings index 8ec1dda3..c2f40c23 100644 --- a/RemoteCam/tr.lproj/Localizable.strings +++ b/RemoteCam/tr.lproj/Localizable.strings @@ -361,7 +361,6 @@ "IncompatibleUpdateButton" = "Güncelle"; // Multicam director (behind ENABLE_MULTICAM) -"Start (%d)" = "Başlat (%d)"; "Add camera" = "Kamera ekle"; "Add" = "Ekle"; "Searching for cameras…" = "Kameralar aranıyor…"; @@ -369,4 +368,5 @@ "Control one or more iPhone cameras" = "Bir veya daha fazla iPhone kamerasını kontrol edin"; "Direct up to 4 cameras at once" = "Aynı anda 4 kameraya kadar yönetin"; "RECONNECTING" = "YENİDEN BAĞLANIYOR"; -"Connect All" = "Tümünü bağla"; +"Connect (%d)" = "Bağlan (%d)"; +"Select All" = "Tümünü seç"; diff --git a/RemoteCam/vi.lproj/Localizable.strings b/RemoteCam/vi.lproj/Localizable.strings index 29062044..986812e8 100644 --- a/RemoteCam/vi.lproj/Localizable.strings +++ b/RemoteCam/vi.lproj/Localizable.strings @@ -361,7 +361,6 @@ "IncompatibleUpdateButton" = "Cập nhật"; // Multicam director (behind ENABLE_MULTICAM) -"Start (%d)" = "Bắt đầu (%d)"; "Add camera" = "Thêm camera"; "Add" = "Thêm"; "Searching for cameras…" = "Đang tìm camera…"; @@ -369,4 +368,5 @@ "Control one or more iPhone cameras" = "Điều khiển một hoặc nhiều camera iPhone"; "Direct up to 4 cameras at once" = "Điều khiển tối đa 4 camera cùng lúc"; "RECONNECTING" = "ĐANG KẾT NỐI LẠI"; -"Connect All" = "Kết nối tất cả"; +"Connect (%d)" = "Kết nối (%d)"; +"Select All" = "Chọn tất cả"; diff --git a/RemoteCam/zh-Hans.lproj/Localizable.strings b/RemoteCam/zh-Hans.lproj/Localizable.strings index a572b89d..a125bc2b 100644 --- a/RemoteCam/zh-Hans.lproj/Localizable.strings +++ b/RemoteCam/zh-Hans.lproj/Localizable.strings @@ -361,7 +361,6 @@ "IncompatibleUpdateButton" = "更新"; // Multicam director (behind ENABLE_MULTICAM) -"Start (%d)" = "开始 (%d)"; "Add camera" = "添加相机"; "Add" = "添加"; "Searching for cameras…" = "正在搜索相机…"; @@ -369,4 +368,5 @@ "Control one or more iPhone cameras" = "控制一台或多台 iPhone 相机"; "Direct up to 4 cameras at once" = "同时导演最多 4 台相机"; "RECONNECTING" = "正在重新连接"; -"Connect All" = "全部连接"; +"Connect (%d)" = "连接 (%d)"; +"Select All" = "全选"; diff --git a/RemoteCamTests/DeviceScannerViewModelTests.swift b/RemoteCamTests/DeviceScannerViewModelTests.swift index 8054aa91..106d352c 100644 --- a/RemoteCamTests/DeviceScannerViewModelTests.swift +++ b/RemoteCamTests/DeviceScannerViewModelTests.swift @@ -510,47 +510,135 @@ final class LocalNetworkProbeTests: XCTestCase { XCTAssertEqual(LocalNetworkProbe.verdict(for: .ready), .proceed) } - // MARK: - Multicam edit-mode selection + // MARK: - Multicam select-then-connect (parametrized screen behavior) - func testMulticamRowStateTransitions() { + private func makeVM(discovered: Int) -> ([MCPeerID], DeviceScannerViewModel) { let vm = DeviceScannerViewModel() - let a = MCPeerID(displayName: "A") - XCTAssertEqual(vm.multicamRowState(a), .unselected) - - vm.multicamConnectingPeers.insert(a) - XCTAssertEqual(vm.multicamRowState(a), .connecting) - - // The coordinator reports A joined → checkmark, spinner cleared. - vm.updateMulticamSelection([a]) - XCTAssertEqual(vm.multicamRowState(a), .selected) - XCTAssertTrue(vm.multicamConnectingPeers.isEmpty) - XCTAssertEqual(vm.multicamCollectedCount, 1) - - // Deselect round-trip: the coordinator reports the effective set shrank. - vm.updateMulticamSelection([]) - XCTAssertEqual(vm.multicamRowState(a), .unselected) - XCTAssertEqual(vm.multicamCollectedCount, 0) + vm.role = .monitor + let peers = (0.. 0) + XCTAssertEqual(vm.multicamSelectionCount, k) + + // (c) tap Connect → exactly the k selected peers to invite. + let toInvite = vm.beginMulticamConnecting() + XCTAssertEqual(Set(toInvite), Set(peers.prefix(k))) + XCTAssertEqual(toInvite.count, k) + if k > 0 { XCTAssertEqual(vm.multicamPhase, .connecting) } + } + } + } } - func testConnectAllRespectsTheCap() { - let vm = DeviceScannerViewModel() - let peers = (1...5).map { MCPeerID(displayName: "Cam\($0)") } - peers.forEach { vm.addPeer($0) } + /// (d) Rows transition selected → connecting → connected, and the handoff + /// decision matches the connected count. + func testConnectingTransitionsAndHandoffDestination() { + for connectCount in 0...4 { + let (peers, vm) = makeVM(discovered: 4) + let selected = Array(peers.prefix(max(connectCount, 1))) + selected.forEach { vm.toggleMulticamSelection($0) } + let toInvite = vm.beginMulticamConnecting() - // Free tier (cap 2), nothing selected yet → invite the first two only. - XCTAssertEqual(vm.peersToConnectAll(maxCameras: 2), Array(peers.prefix(2))) + // All selected are "connecting" right after Connect. + for p in toInvite { XCTAssertEqual(vm.multicamRowState(p), .connecting) } - // One already selected → only one more slot. - vm.updateMulticamSelection([peers[0]]) - XCTAssertEqual(vm.peersToConnectAll(maxCameras: 2), [peers[1]]) + // The first `connectCount` connect; the rest fail. + let connected = Array(selected.prefix(connectCount)) + vm.reconcileMulticamConnected(connected) + for p in selected where !connected.contains(p) { vm.markMulticamFailed(p) } - // Pro tier (cap 4) with one selected → three more. - XCTAssertEqual(vm.peersToConnectAll(maxCameras: 4), Array(peers[1...3])) + for p in connected { XCTAssertEqual(vm.multicamRowState(p), .connected) } + XCTAssertTrue(vm.multicamConnectSettled) - // In-flight cameras also count against the cap. - vm.multicamConnectingPeers.insert(peers[1]) - XCTAssertEqual(vm.peersToConnectAll(maxCameras: 2), [], - "one selected + one connecting fills the free cap") + switch MulticamHandoff.decide(connected: connected) { + case .none: XCTAssertEqual(connectCount, 0) + case .classicMonitor(let p): XCTAssertEqual(connectCount, 1); XCTAssertEqual(p, connected[0]) + case .director(let ps): XCTAssertGreaterThanOrEqual(connectCount, 2); XCTAssertEqual(ps, connected) + } + } + } + + /// Cap: with 10 discovered you can select up to `cap`; the (cap+1)th row + /// locks, and Select All stops at the cap. + func testCapLocksBeyondMaxCameras() { + for cap in [2, 4] { + let (peers, vm) = makeVM(discovered: 10) + for i in 0.. Date: Tue, 11 Aug 2026 02:25:32 -0700 Subject: [PATCH 12/17] Multicam commit A: rig settings tray (timer + quality intersection) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Framing belongs to a camera; the shot belongs to the rig." Focused-camera controls (zoom/focus/flash/lens) are unchanged; a new rig-scope tray adds one self-timer and rig-wide quality, reachable from both focus and grid modes. Quality — the intersection model (Apple's hybrid, decided with Dario): - RigQualityMenu: a pure, unit-tested type that intersects every connected lane's current-camera VideoQualityCapabilities (resolution×fps matrix) and PhotoQualityCapabilities (HEIF/HDR). An option is offered only when every camera supports it; non-intersection options are listed greyed, naming the camera(s) that block them. - Manual selection within the intersection is FIRST-CLASS: tap 1080p30, tap 4K30, each fans SetVideoQuality to every lane. "Automatic" (best-in- intersection: highest res then fps, floor 1080p30) is the reset at the top, not a mode you must leave. HEIF/HDR the same via SetPhotoQuality. - Late joiner / device switch that can't match the running rig setting badges its tile and offers a re-match (re-run Automatic) — the rig is never silently changed. Timer: one director countdown; each tick fans TimerCountdown to every camera (subjects see it) and expiry triggers the existing synced capture/record path. No per-camera timers. The controller tracks the active rig quality, computes the RigSettingsSnapshot (picker options + blockers + active labels) for the tray, and publishes it on caps change. New strings → 15 locales. Tests: RigQualityMenu intersection math (homogeneous/heterogeneous/empty→floor, photo intersection, late-joiner, Automatic order); controller fan-out sends the chosen quality to every lane; manual toggle fans out each time; Automatic picks best-in-intersection; late joiner flagged; timer counts down, fans out, and fires the synced capture. Full suite green (723). Co-Authored-By: Claude Fable 5 --- RemoteCam/CameraLink.swift | 5 + RemoteCam/MulticamController.swift | 176 +++++++++++++++++- RemoteCam/MulticamView.swift | 183 ++++++++++++++++--- RemoteCam/MulticamViewController.swift | 23 +++ RemoteCam/MulticamViewModel.swift | 8 + RemoteCam/RigQualityMenu.swift | 156 ++++++++++++++++ RemoteCam/da.lproj/Localizable.strings | 13 ++ RemoteCam/de-DE.lproj/Localizable.strings | 13 ++ RemoteCam/en.lproj/Localizable.strings | 13 ++ RemoteCam/es-MX.lproj/Localizable.strings | 13 ++ RemoteCam/fr-FR.lproj/Localizable.strings | 13 ++ RemoteCam/hi.lproj/Localizable.strings | 13 ++ RemoteCam/it.lproj/Localizable.strings | 13 ++ RemoteCam/ja.lproj/Localizable.strings | 13 ++ RemoteCam/ko.lproj/Localizable.strings | 13 ++ RemoteCam/ms.lproj/Localizable.strings | 13 ++ RemoteCam/pt-BR.lproj/Localizable.strings | 13 ++ RemoteCam/ru.lproj/Localizable.strings | 13 ++ RemoteCam/tr.lproj/Localizable.strings | 13 ++ RemoteCam/vi.lproj/Localizable.strings | 13 ++ RemoteCam/zh-Hans.lproj/Localizable.strings | 13 ++ RemoteCamTests/MulticamControllerTests.swift | 131 +++++++++++++ RemoteCamTests/MulticamViewModelTests.swift | 2 +- RemoteCamTests/RigQualityMenuTests.swift | 119 ++++++++++++ RemoteShutter.xcodeproj/project.pbxproj | 8 + 25 files changed, 973 insertions(+), 33 deletions(-) create mode 100644 RemoteCam/RigQualityMenu.swift create mode 100644 RemoteCamTests/RigQualityMenuTests.swift diff --git a/RemoteCam/CameraLink.swift b/RemoteCam/CameraLink.swift index a66083d6..9b781090 100644 --- a/RemoteCam/CameraLink.swift +++ b/RemoteCam/CameraLink.swift @@ -63,6 +63,11 @@ final class CameraLink { /// only re-sends `SetStreamProfile` when the tier actually changes. var lastSentProfile: StreamProfile? + /// A late joiner (or a device-switched camera) that cannot honor the + /// running rig video quality — its tile is badged and the tray offers a + /// re-match rather than silently changing the rig. + var needsQualityRematch = false + /// This camera's own preview decoder + stall watchdog. Frames tagged with /// this peer's id are fed here; its `onImage` drives exactly this lane's /// tile, so a frame from another camera never touches it. diff --git a/RemoteCam/MulticamController.swift b/RemoteCam/MulticamController.swift index 23591d4b..46e56795 100644 --- a/RemoteCam/MulticamController.swift +++ b/RemoteCam/MulticamController.swift @@ -48,6 +48,8 @@ struct MulticamLaneInfo: Equatable { let captureOutcome: CaptureOutcome? /// This camera is rolling as part of a synced recording (REC badge). let isRecording: Bool + /// This camera can't match the running rig quality — tile badge + re-match. + let needsQualityRematch: Bool } /// The main-actor bridge from the controller to the multicam screen — the @@ -63,6 +65,8 @@ protocol MulticamDisplay: AnyObject { /// Cameras the browser has found that aren't in the rig — the add-camera /// sheet's list. func applyAvailablePeers(_ peers: [MCPeerID]) + /// The rig-wide settings (timer + quality intersection) for the tray. + func applyRigSettings(_ settings: RigSettingsSnapshot) func receiveFrame(_ frame: RemoteCmd.OnFrame) func exitMulticam() } @@ -115,6 +119,7 @@ public actor MulticamController { public nonisolated func stop() { clockSyncTask.value?.cancel() + timerTask.value?.cancel() transportShared.value?.stopSession() inboxContinuation.value?.finish() } @@ -153,6 +158,20 @@ public actor MulticamController { /// How long a camera has to answer before it is counted as failed. private var captureAckTimeout: TimeInterval = 3 + // MARK: Rig-wide settings ("the shot belongs to the rig") + + /// The rig's active video quality — applied to every lane. Nil until the + /// first pick (or Automatic). Manual selection within the intersection is + /// first-class; Automatic just recomputes best-in-intersection. + private var activeVideoQuality: (resolution: VideoResolution, frameRate: VideoFrameRate)? + private var activePhotoQuality: (format: PhotoFormat, hdr: HDRMode)? + /// One rig self-timer (seconds); 0 = off. Fans out to every camera so + /// subjects see the countdown, and its expiry triggers the synced capture. + private var rigTimerSeconds: Int = 0 + /// The countdown tick interval — injectable so tests don't wait real seconds. + private var timerTickInterval: TimeInterval = 1 + private nonisolated let timerTask = Locked?>(nil) + private weak var display: MulticamDisplay? /// The interval between clock-offset refreshes per camera. @@ -163,8 +182,11 @@ public actor MulticamController { // MARK: Test / wiring seams + func needsRematchForTesting(_ peer: MCPeerID) -> Bool { links[peer]?.needsQualityRematch ?? false } + func setDisplay(_ display: MulticamDisplay) { self.display = display + publishRigSettings() // The display is wired after `install` (the screen is pushed only once // the handoff is done), so replay the current lanes now — otherwise the // first snapshot, emitted during install, reaches no one. @@ -276,7 +298,12 @@ public actor MulticamController { case let caps as RemoteCmd.CameraCapabilitiesResp: link.capabilities = caps 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 + // changing the rig. Also refreshes the intersection menu. + refreshRematchFlags() publishLanes() + publishRigSettings() // A multicam-capable camera gets an immediate clock probe so its // offset is ready well before the first synced capture (PR4), and // its preview tier (full if focused, else thumbnail). @@ -523,10 +550,16 @@ public actor MulticamController { return captureID } - /// Fire a synced photo on every ready multicam camera. Scheduled at a shared - /// instant when every clock offset is known; else a plain `TakePic` fan-out - /// under the same shot id. No-op unless idle with a ready camera. + /// Fire a synced photo on every ready multicam camera. If the rig timer is + /// set, run one director-side countdown first (fanned out so subjects see + /// it), then fire. Scheduled at a shared instant when every clock offset is + /// known; else a plain `TakePic` fan-out. No-op unless idle with a camera. func capturePhoto() { + guard rigTimerSeconds > 0 else { performCapturePhoto(); return } + startCountdown(then: .photo) + } + + private func performCapturePhoto() { guard case .monitoring = state else { return } let ready = readyMulticamLanes() guard !ready.isEmpty else { return } @@ -545,8 +578,13 @@ public actor MulticamController { armAckTimeout(captureID) } - /// Start a synced recording on every ready multicam camera. + /// Start a synced recording on every ready multicam camera (timer-gated). func startRecording() { + guard rigTimerSeconds > 0 else { performStartRecording(); return } + startCountdown(then: .record) + } + + private func performStartRecording() { guard case .monitoring = state else { return } let ready = readyMulticamLanes() guard !ready.isEmpty else { return } @@ -585,6 +623,133 @@ public actor MulticamController { armAckTimeout(captureID) } + // MARK: - Rig-wide quality ("the shot belongs to the rig") + + /// Test seams. + func setTimerTickInterval(_ t: TimeInterval) { timerTickInterval = t } + func activeVideoQualityForTesting() -> (VideoResolution, VideoFrameRate)? { + activeVideoQuality.map { ($0.resolution, $0.frameRate) } + } + func rigTimerForTesting() -> Int { rigTimerSeconds } + + /// The current rig quality menu, computed from every connected lane's + /// current-camera capabilities (the intersection model). + func rigQualityMenu() -> RigQualityMenu { + let menuLanes: [RigQualityMenu.Lane] = order.enumerated().compactMap { index, peer in + guard let link = links[peer], link.status != .failed, + let info = link.capabilities?.getCurrentCameraInfo() else { return nil } + return RigQualityMenu.Lane(name: link.displayName.isEmpty ? "Camera \(index + 1)" : link.displayName, + info: info) + } + return RigQualityMenu(lanes: menuLanes) + } + + /// Manual video pick — fans the chosen quality to every lane and records it + /// as the active rig setting (first-class; not a mode you leave). + func setVideoQuality(resolution: VideoResolution, frameRate: VideoFrameRate) { + activeVideoQuality = (resolution, frameRate) + for peer in order { + sendTo(peer, RemoteCmd.SetVideoQuality(resolution: resolution, frameRate: frameRate)) + } + refreshRematchFlags() + publishLanes() + } + + func setPhotoQuality(format: PhotoFormat, hdr: HDRMode) { + activePhotoQuality = (format, hdr) + for peer in order { + sendTo(peer, RemoteCmd.SetPhotoQuality(format: format, hdrMode: hdr)) + } + publishLanes() + } + + /// "Automatic" / re-match: recompute best-in-intersection and apply it. + func applyAutomaticVideoQuality() { + let auto = rigQualityMenu().automaticVideo() + setVideoQuality(resolution: auto.resolution, frameRate: auto.frameRate) + } + + func applyAutomaticPhotoQuality() { + let auto = rigQualityMenu().automaticPhoto() + setPhotoQuality(format: auto.format, hdr: auto.hdr) + } + + /// After caps change (new lane, device switch), flag any lane that can't + /// honor the running rig video setting — its tile is badged and the tray + /// offers a re-match. Never silently changes the rig. + private func refreshRematchFlags() { + guard let active = activeVideoQuality else { return } + let menu = rigQualityMenu() + for peer in order { + guard let link = links[peer], let info = link.capabilities?.getCurrentCameraInfo() else { continue } + let lane = RigQualityMenu.Lane(name: link.displayName, info: info) + link.needsQualityRematch = !menu.laneCanMatch( + lane, resolution: active.resolution, frameRate: active.frameRate) + } + } + + // MARK: - Rig self-timer + + func setRigTimer(_ seconds: Int) { + rigTimerSeconds = max(0, seconds) + publishRigSettings() + } + + private enum TimedAction { case photo, record } + + /// Run a director-side countdown, fanned out to every camera so subjects see + /// it, then fire the synced capture at zero. + private func startCountdown(then action: TimedAction) { + timerTask.value?.cancel() + let total = rigTimerSeconds + let interval = timerTickInterval + timerTask.value = Task { [weak self] in + for remaining in stride(from: total, through: 1, by: -1) { + if Task.isCancelled { return } + await self?.fanOutTimerTick(remaining) + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + if Task.isCancelled { return } + await self?.fireTimed(action) + } + } + + private func fireTimed(_ action: TimedAction) { + fanOutTimerTick(0) + switch action { + case .photo: performCapturePhoto() + case .record: performStartRecording() + } + } + + private func fanOutTimerTick(_ remaining: Int) { + for peer in order { sendTo(peer, RemoteCmd.TimerCountdown(value: remaining)) } + publishRigSettings(countdown: remaining > 0 ? remaining : nil) + } + + private func publishRigSettings(countdown: Int? = nil) { + let menu = rigQualityMenu() + let videoLabel: String + if let v = activeVideoQuality { + videoLabel = "\(v.resolution.displayName)\(v.frameRate.displayName)" + } else { + videoLabel = NSLocalizedString("Auto", comment: "automatic rig quality") + } + let snapshot = RigSettingsSnapshot( + timerSeconds: rigTimerSeconds, + countdown: countdown, + activeVideoLabel: videoLabel, + videoOptions: menu.videoPickerOptions(), + heifAvailable: menu.supportsHEIF(), + hdrAvailable: menu.supportsHDR(), + heifBlockedBy: menu.lanesBlockingHEIF(), + hdrBlockedBy: menu.lanesBlockingHDR(), + activePhotoFormat: activePhotoQuality?.format, + activeHDR: activePhotoQuality?.hdr) + let display = display + OperationQueue.main.addOperation { display?.applyRigSettings(snapshot) } + } + // MARK: Ack aggregation (shared across photo / start / stop) private func resolvePhotoAck(from peer: MCPeerID, success: Bool) { @@ -723,7 +888,8 @@ public actor MulticamController { isFocused: peer == focusedPeer, clockOffsetMillis: link.latestOffset?.offsetMillis, captureOutcome: link.captureOutcome, - isRecording: link.isRecording) + isRecording: link.isRecording, + needsQualityRematch: link.needsQualityRematch) } } diff --git a/RemoteCam/MulticamView.swift b/RemoteCam/MulticamView.swift index 9311e1de..4afbd75a 100644 --- a/RemoteCam/MulticamView.swift +++ b/RemoteCam/MulticamView.swift @@ -25,6 +25,15 @@ struct MulticamView: View { let onAddCamera: () -> Void /// Invite a discovered camera into the rig. let onInviteCamera: (MCPeerID) -> Void + /// Rig self-timer changed (seconds; 0 = off). + let onSetTimer: (Int) -> Void + /// Rig video quality picked (fans out to every lane). + let onSelectVideoQuality: (VideoResolution, VideoFrameRate) -> Void + /// "Automatic" — recompute best-in-intersection and apply. + let onAutomaticVideoQuality: () -> Void + /// Rig photo format / HDR picked. + let onSetPhotoFormat: (PhotoFormat) -> Void + let onSetHDR: (Bool) -> Void var body: some View { GeometryReader { geo in @@ -46,11 +55,65 @@ struct MulticamView: View { // The capture cluster stays in both modes; per-camera controls // (the strip) are hidden in grid. shutterOverlay(dock: dock) - gridToggle + topControls + countdownOverlay } .sheet(isPresented: $viewModel.showingAddCamera) { AddCameraSheet(peers: viewModel.availablePeers, onInvite: onInviteCamera) } + .sheet(isPresented: $viewModel.showingRigTray) { + RigTrayView(settings: viewModel.rigSettings, + onSetTimer: onSetTimer, + onSelectVideoQuality: onSelectVideoQuality, + onAutomaticVideoQuality: onAutomaticVideoQuality, + onSetPhotoFormat: onSetPhotoFormat, + onSetHDR: onSetHDR) + } + } + } + + /// Top-left cluster: the rig settings (tray) button, and the focus/grid + /// toggle when there's more than one camera. Reachable in both modes. + private var topControls: some View { + VStack { + HStack(spacing: 12) { + Button { viewModel.showingRigTray = true } label: { + Image(systemName: "slider.horizontal.3") + .font(.title3) + .foregroundColor(.white) + .frame(width: 44, height: 44) + .background(Color.black.opacity(0.4)) + .clipShape(Circle()) + } + if MultiCamChrome.showsGridToggle(cameraCount: viewModel.lanes.count) { + Button { + viewModel.displayMode = viewModel.displayMode == .grid ? .focus : .grid + } label: { + Image(systemName: viewModel.displayMode == .grid + ? "rectangle.inset.filled" : "square.grid.2x2.fill") + .font(.title3) + .foregroundColor(.white) + .frame(width: 44, height: 44) + .background(Color.black.opacity(0.4)) + .clipShape(Circle()) + } + } + Spacer() + } + .padding(.leading, 12) + .padding(.top, 12) + Spacer() + } + } + + /// The rig self-timer countdown, big and centered so subjects see it. + @ViewBuilder + private var countdownOverlay: some View { + if let n = viewModel.rigSettings.countdown, n > 0 { + Text("\(n)") + .font(.system(size: 96, weight: .bold, design: .rounded)) + .foregroundColor(.white) + .shadow(radius: 8) } } @@ -74,32 +137,6 @@ struct MulticamView: View { .padding(8) } - /// Focus/grid switch, only when there's more than one camera. - @ViewBuilder - private var gridToggle: some View { - if MultiCamChrome.showsGridToggle(cameraCount: viewModel.lanes.count) { - VStack { - HStack { - Button { - viewModel.displayMode = viewModel.displayMode == .grid ? .focus : .grid - } label: { - Image(systemName: viewModel.displayMode == .grid - ? "rectangle.inset.filled" : "square.grid.2x2.fill") - .font(.title3) - .foregroundColor(.white) - .frame(width: 44, height: 44) - .background(Color.black.opacity(0.4)) - .clipShape(Circle()) - } - .padding(.leading, 12) - .padding(.top, 12) - Spacer() - } - Spacer() - } - } - } - /// The all-camera shutter + a photo/video mode toggle, docked on the same /// edge the 1:1 monitor uses. Reuses the monitor's `ShutterButton` (and its /// activity ring) so the two screens feel of a piece. @@ -248,6 +285,19 @@ struct CameraTileView: View { .foregroundColor(.white)) } + if lane.needsQualityRematch { + VStack { + HStack { + Spacer() + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundColor(.yellow) + .padding(6) + } + Spacer() + } + } + if lane.isRecording { VStack { HStack { @@ -329,3 +379,82 @@ struct AddCameraSheet: View { } } } + +/// The rig settings tray: one self-timer + rig-wide quality (the intersection +/// model). Manual quality selection is first-class; "Automatic" is the reset at +/// the top. Reachable from both focus and grid modes. +struct RigTrayView: View { + let settings: RigSettingsSnapshot + let onSetTimer: (Int) -> Void + let onSelectVideoQuality: (VideoResolution, VideoFrameRate) -> Void + let onAutomaticVideoQuality: () -> Void + let onSetPhotoFormat: (PhotoFormat) -> Void + let onSetHDR: (Bool) -> Void + + private let timerStops = [0, 3, 5, 10, 20] + + var body: some View { + NavigationView { + Form { + Section(NSLocalizedString("Timer", comment: "rig self-timer")) { + Picker(NSLocalizedString("Timer", comment: ""), + selection: Binding(get: { settings.timerSeconds }, + set: { onSetTimer($0) })) { + ForEach(timerStops, id: \.self) { s in + Text(s == 0 ? NSLocalizedString("Off", comment: "timer off") : "\(s)s").tag(s) + } + } + .pickerStyle(.segmented) + } + + Section(NSLocalizedString("Video Quality", comment: "rig video quality")) { + Button { + onAutomaticVideoQuality() + } label: { + HStack { + Text(NSLocalizedString("Automatic", comment: "best-in-intersection")) + Spacer() + Text(settings.activeVideoLabel).foregroundColor(.secondary) + } + } + ForEach(settings.videoOptions) { opt in + Button { + if opt.enabled { onSelectVideoQuality(opt.resolution, opt.frameRate) } + } label: { + HStack { + Text(opt.label) + .foregroundColor(opt.enabled ? .primary : .secondary) + Spacer() + if !opt.enabled, let blocker = opt.blockedBy.first { + Text(String(format: NSLocalizedString("%@ can't", comment: "camera blocks a quality"), blocker)) + .font(.caption) + .foregroundColor(.secondary) + } + if settings.activeVideoLabel == opt.label { + Image(systemName: "checkmark").foregroundColor(AppTheme.accent) + } + } + } + .disabled(!opt.enabled) + } + } + + Section(NSLocalizedString("Photo", comment: "rig photo quality")) { + Toggle(NSLocalizedString("HEIF", comment: "photo format"), + isOn: Binding(get: { settings.activePhotoFormat == .heif }, + set: { onSetPhotoFormat($0 ? .heif : .jpeg) })) + .disabled(!settings.heifAvailable) + Toggle(NSLocalizedString("HDR", comment: "photo HDR"), + isOn: Binding(get: { settings.activeHDR == .on }, + set: { onSetHDR($0) })) + .disabled(!settings.hdrAvailable) + if !settings.hdrAvailable, let blocker = settings.hdrBlockedBy.first { + Text(String(format: NSLocalizedString("%@ can't do HDR", comment: "camera blocks HDR"), blocker)) + .font(.caption).foregroundColor(.secondary) + } + } + } + .navigationTitle(NSLocalizedString("Rig Settings", comment: "multicam settings tray")) + } + } +} diff --git a/RemoteCam/MulticamViewController.swift b/RemoteCam/MulticamViewController.swift index 787b5d70..e8f3c8e9 100644 --- a/RemoteCam/MulticamViewController.swift +++ b/RemoteCam/MulticamViewController.swift @@ -59,6 +59,25 @@ public final class MulticamViewController: UIViewController { guard let self else { return } self.viewModel.showingAddCamera = false Task { await self.controller.inviteCamera(peer) } + }, + onSetTimer: { [weak self] seconds in + Task { await self?.controller.setRigTimer(seconds) } + }, + onSelectVideoQuality: { [weak self] res, fps in + Task { await self?.controller.setVideoQuality(resolution: res, frameRate: fps) } + }, + onAutomaticVideoQuality: { [weak self] in + Task { await self?.controller.applyAutomaticVideoQuality() } + }, + onSetPhotoFormat: { [weak self] format in + Task { await self?.controller.setPhotoQuality( + format: format, + hdr: self?.viewModel.rigSettings.activeHDR ?? .off) } + }, + onSetHDR: { [weak self] on in + Task { await self?.controller.setPhotoQuality( + format: self?.viewModel.rigSettings.activePhotoFormat ?? .jpeg, + hdr: on ? .on : .off) } }) hosting = embedSwiftUIView(multicamView) @@ -164,6 +183,10 @@ extension MulticamViewController: MulticamDisplay { viewModel.availablePeers = peers } + func applyRigSettings(_ settings: RigSettingsSnapshot) { + viewModel.rigSettings = settings + } + func receiveFrame(_ frame: RemoteCmd.OnFrame) { // Route to exactly the source lane's decoder; a frame for camera B // never touches camera A's tile. diff --git a/RemoteCam/MulticamViewModel.swift b/RemoteCam/MulticamViewModel.swift index fcc88433..581819ca 100644 --- a/RemoteCam/MulticamViewModel.swift +++ b/RemoteCam/MulticamViewModel.swift @@ -29,6 +29,8 @@ final class CameraLane: ObservableObject, Identifiable { @Published var captureOutcome: CaptureOutcome? /// This camera is rolling in a synced recording — REC badge. @Published var isRecording: Bool + /// This camera can't match the running rig quality — badge + re-match. + @Published var needsQualityRematch: Bool /// This lane's own decoder + stall watchdog. The view controller wires its /// `onImage` to set `frames.cameraImage`, and its stall/keyframe callbacks @@ -42,6 +44,7 @@ final class CameraLane: ObservableObject, Identifiable { self.isFocused = info.isFocused self.captureOutcome = info.captureOutcome self.isRecording = info.isRecording + self.needsQualityRematch = info.needsQualityRematch } } @@ -62,6 +65,10 @@ final class MulticamViewModel: ObservableObject { @Published var availablePeers: [MCPeerID] = [] /// Whether the add-camera sheet is showing. @Published var showingAddCamera: Bool = false + /// Rig-wide settings (timer + quality intersection) for the tray. + @Published var rigSettings = RigSettingsSnapshot(activeVideoLabel: "Auto") + /// Whether the rig settings tray is showing. + @Published var showingRigTray: Bool = false var focusedLane: CameraLane? { lanes.first { $0.isFocused } } var otherLanes: [CameraLane] { lanes.filter { !$0.isFocused } } @@ -83,6 +90,7 @@ final class MulticamViewModel: ObservableObject { if lane.isFocused != info.isFocused { lane.isFocused = info.isFocused } if lane.captureOutcome != info.captureOutcome { lane.captureOutcome = info.captureOutcome } if lane.isRecording != info.isRecording { lane.isRecording = info.isRecording } + if lane.needsQualityRematch != info.needsQualityRematch { lane.needsQualityRematch = info.needsQualityRematch } return lane } let lane = CameraLane(info: info) diff --git a/RemoteCam/RigQualityMenu.swift b/RemoteCam/RigQualityMenu.swift new file mode 100644 index 00000000..fa18cc6b --- /dev/null +++ b/RemoteCam/RigQualityMenu.swift @@ -0,0 +1,156 @@ +// +// RigQualityMenu.swift +// RemoteShutter +// +// Copyright © 2026 Security Union LLC. All rights reserved. +// + +import Foundation + +/// The rig-wide quality menu: "the shot belongs to the rig", so video/photo +/// quality is the **intersection** of what every connected camera can do. A +/// pure value type — the picker UI and the fan-out both read it, and it is +/// unit-tested without any transport. +/// +/// Built from each lane's current-camera `CameraInfo` (resolution/fps matrix, +/// HEIF, HDR). An option is offered only when *every* camera supports it; a +/// non-intersection option is still listed, greyed, naming the camera(s) that +/// block it. +struct RigQualityMenu: Equatable { + + /// One lane's contribution: a display name + the camera it is currently on. + struct Lane: Equatable { + let name: String + let info: RemoteCmd.CameraInfo + } + + /// The universal floor every iPhone camera supports — used when the + /// intersection is empty and as the Automatic fallback. + static let floor: (resolution: VideoResolution, frameRate: VideoFrameRate) = (.hd1080p, .fps30) + + private let lanes: [Lane] + + init(lanes: [Lane]) { + self.lanes = lanes + } + + // MARK: - Video + + /// Every (resolution, frame-rate) pair the whole rig supports. An empty rig + /// has no cameras to agree on, so no options (Automatic then uses the floor). + func videoOptions() -> [(resolution: VideoResolution, frameRate: VideoFrameRate)] { + guard !lanes.isEmpty else { return [] } + return allVideoPairs().filter { blockingLanes(resolution: $0.resolution, frameRate: $0.frameRate).isEmpty } + } + + /// Cameras that cannot do this (resolution, fps) — empty means it is in the + /// rig intersection. Used to grey and annotate a picker row. + func blockingLanes(resolution: VideoResolution, frameRate: VideoFrameRate) -> [String] { + lanes.filter { !laneSupports($0, resolution: resolution, frameRate: frameRate) }.map(\.name) + } + + /// Automatic = the best pair every camera can do: highest resolution, then + /// highest frame rate. Falls back to the 1080p30 floor when the rig shares + /// nothing (heterogeneous cameras) or there are no lanes. + func automaticVideo() -> (resolution: VideoResolution, frameRate: VideoFrameRate) { + let best = videoOptions().max { a, b in + if a.resolution.rawValue != b.resolution.rawValue { + return a.resolution.rawValue < b.resolution.rawValue + } + return a.frameRate.rawValue < b.frameRate.rawValue + } + return best ?? Self.floor + } + + // MARK: - Photo + + /// HEIF is a rig option only when every camera can encode it. + func supportsHEIF() -> Bool { + !lanes.isEmpty && lanes.allSatisfy { $0.info.supportsHEIF } + } + + /// HDR is a rig option only when every camera can do it. + func supportsHDR() -> Bool { + !lanes.isEmpty && lanes.allSatisfy { $0.info.supportsHDR } + } + + /// Cameras blocking HEIF / HDR, for greying the photo tiles. + func lanesBlockingHEIF() -> [String] { lanes.filter { !$0.info.supportsHEIF }.map(\.name) } + func lanesBlockingHDR() -> [String] { lanes.filter { !$0.info.supportsHDR }.map(\.name) } + + /// Automatic photo = HEIF if the whole rig can, else JPEG; HDR on if all can. + func automaticPhoto() -> (format: PhotoFormat, hdr: HDRMode) { + (supportsHEIF() ? .heif : .jpeg, supportsHDR() ? .on : .off) + } + + // MARK: - Late joiner + + /// Whether a lane can honor a currently-applied rig video setting. A late + /// joiner that can't is badged and offered a "re-match" (re-run Automatic) + /// rather than silently changing the running rig. + func laneCanMatch(_ lane: Lane, + resolution: VideoResolution, + frameRate: VideoFrameRate) -> Bool { + laneSupports(lane, resolution: resolution, frameRate: frameRate) + } + + // MARK: - Internals + + private func allVideoPairs() -> [(resolution: VideoResolution, frameRate: VideoFrameRate)] { + var pairs: [(VideoResolution, VideoFrameRate)] = [] + for resolution in [VideoResolution.uhd4k, .hd1080p] { + for frameRate in [VideoFrameRate.fps60, .fps30, .fps24] { + pairs.append((resolution, frameRate)) + } + } + return pairs.map { (resolution: $0.0, frameRate: $0.1) } + } + + private func laneSupports(_ lane: Lane, + resolution: VideoResolution, + frameRate: VideoFrameRate) -> Bool { + let matrix = lane.info.getResolutionFrameRates() + if let rates = matrix[resolution] { return rates.contains(frameRate) } + // A camera that never advertised the matrix (older build) is treated as + // the universal floor only. + return resolution == .hd1080p && frameRate == .fps30 + } + + // MARK: - View-facing picker rows + + /// Every listed video option (highest first), each flagged enabled (in the + /// rig intersection) or greyed with the cameras that block it. Manual + /// selection of any enabled row is first-class. + func videoPickerOptions() -> [RigVideoOption] { + allVideoPairs().map { pair in + let blockers = blockingLanes(resolution: pair.resolution, frameRate: pair.frameRate) + return RigVideoOption(resolution: pair.resolution, frameRate: pair.frameRate, + enabled: blockers.isEmpty, blockedBy: blockers) + } + } +} + +/// One row of the rig video-quality picker. +struct RigVideoOption: Equatable, Identifiable { + let resolution: VideoResolution + let frameRate: VideoFrameRate + let enabled: Bool + let blockedBy: [String] + + var id: String { "\(resolution.rawValue):\(frameRate.rawValue)" } + var label: String { "\(resolution.displayName)\(frameRate.displayName)" } +} + +/// The rig-settings snapshot the director hands the tray UI. +struct RigSettingsSnapshot: Equatable { + var timerSeconds: Int = 0 + var countdown: Int? + var activeVideoLabel: String + var videoOptions: [RigVideoOption] = [] + var heifAvailable: Bool = false + var hdrAvailable: Bool = false + var heifBlockedBy: [String] = [] + var hdrBlockedBy: [String] = [] + var activePhotoFormat: PhotoFormat? + var activeHDR: HDRMode? +} diff --git a/RemoteCam/da.lproj/Localizable.strings b/RemoteCam/da.lproj/Localizable.strings index 67b6bb94..3674f372 100644 --- a/RemoteCam/da.lproj/Localizable.strings +++ b/RemoteCam/da.lproj/Localizable.strings @@ -249,3 +249,16 @@ "RECONNECTING" = "GENOPRETTER FORBINDELSE"; "Connect (%d)" = "Forbind (%d)"; "Select All" = "Vælg alle"; + +// Multicam rig settings tray +"Auto" = "Auto"; +"Timer" = "Timer"; +"Off" = "Fra"; +"Video Quality" = "Videokvalitet"; +"Automatic" = "Automatisk"; +"Photo" = "Foto"; +"HEIF" = "HEIF"; +"HDR" = "HDR"; +"Rig Settings" = "Rig-indstillinger"; +"%@ can't" = "%@ kan ikke"; +"%@ can't do HDR" = "%@ kan ikke HDR"; diff --git a/RemoteCam/de-DE.lproj/Localizable.strings b/RemoteCam/de-DE.lproj/Localizable.strings index 7461ca4e..b57edc6e 100644 --- a/RemoteCam/de-DE.lproj/Localizable.strings +++ b/RemoteCam/de-DE.lproj/Localizable.strings @@ -370,3 +370,16 @@ "RECONNECTING" = "VERBINDUNG WIRD WIEDERHERGESTELLT"; "Connect (%d)" = "Verbinden (%d)"; "Select All" = "Alle auswählen"; + +// Multicam rig settings tray +"Auto" = "Auto"; +"Timer" = "Timer"; +"Off" = "Aus"; +"Video Quality" = "Videoqualität"; +"Automatic" = "Automatisch"; +"Photo" = "Foto"; +"HEIF" = "HEIF"; +"HDR" = "HDR"; +"Rig Settings" = "Rig-Einstellungen"; +"%@ can't" = "%@ kann nicht"; +"%@ can't do HDR" = "%@ kann kein HDR"; diff --git a/RemoteCam/en.lproj/Localizable.strings b/RemoteCam/en.lproj/Localizable.strings index 2734eca4..36cfa913 100644 --- a/RemoteCam/en.lproj/Localizable.strings +++ b/RemoteCam/en.lproj/Localizable.strings @@ -372,3 +372,16 @@ "RECONNECTING" = "RECONNECTING"; "Connect (%d)" = "Connect (%d)"; "Select All" = "Select All"; + +// Multicam rig settings tray +"Auto" = "Auto"; +"Timer" = "Timer"; +"Off" = "Off"; +"Video Quality" = "Video Quality"; +"Automatic" = "Automatic"; +"Photo" = "Photo"; +"HEIF" = "HEIF"; +"HDR" = "HDR"; +"Rig Settings" = "Rig Settings"; +"%@ can't" = "%@ can't"; +"%@ can't do HDR" = "%@ can't do HDR"; diff --git a/RemoteCam/es-MX.lproj/Localizable.strings b/RemoteCam/es-MX.lproj/Localizable.strings index 392364c6..230e33df 100644 --- a/RemoteCam/es-MX.lproj/Localizable.strings +++ b/RemoteCam/es-MX.lproj/Localizable.strings @@ -264,3 +264,16 @@ "RECONNECTING" = "RECONECTANDO"; "Connect (%d)" = "Conectar (%d)"; "Select All" = "Seleccionar todas"; + +// Multicam rig settings tray +"Auto" = "Auto"; +"Timer" = "Temporizador"; +"Off" = "Desactivado"; +"Video Quality" = "Calidad de video"; +"Automatic" = "Automático"; +"Photo" = "Foto"; +"HEIF" = "HEIF"; +"HDR" = "HDR"; +"Rig Settings" = "Ajustes del equipo"; +"%@ can't" = "%@ no puede"; +"%@ can't do HDR" = "%@ no admite HDR"; diff --git a/RemoteCam/fr-FR.lproj/Localizable.strings b/RemoteCam/fr-FR.lproj/Localizable.strings index 83d3e6a3..84350a83 100644 --- a/RemoteCam/fr-FR.lproj/Localizable.strings +++ b/RemoteCam/fr-FR.lproj/Localizable.strings @@ -264,3 +264,16 @@ "RECONNECTING" = "RECONNEXION"; "Connect (%d)" = "Connecter (%d)"; "Select All" = "Tout sélectionner"; + +// Multicam rig settings tray +"Auto" = "Auto"; +"Timer" = "Minuteur"; +"Off" = "Désactivé"; +"Video Quality" = "Qualité vidéo"; +"Automatic" = "Automatique"; +"Photo" = "Photo"; +"HEIF" = "HEIF"; +"HDR" = "HDR"; +"Rig Settings" = "Réglages du rig"; +"%@ can't" = "%@ ne peut pas"; +"%@ can't do HDR" = "%@ ne gère pas le HDR"; diff --git a/RemoteCam/hi.lproj/Localizable.strings b/RemoteCam/hi.lproj/Localizable.strings index 461b35e2..7f202080 100644 --- a/RemoteCam/hi.lproj/Localizable.strings +++ b/RemoteCam/hi.lproj/Localizable.strings @@ -370,3 +370,16 @@ "RECONNECTING" = "फिर से कनेक्ट हो रहा है"; "Connect (%d)" = "कनेक्ट करें (%d)"; "Select All" = "सभी चुनें"; + +// Multicam rig settings tray +"Auto" = "ऑटो"; +"Timer" = "टाइमर"; +"Off" = "बंद"; +"Video Quality" = "वीडियो गुणवत्ता"; +"Automatic" = "स्वचालित"; +"Photo" = "फ़ोटो"; +"HEIF" = "HEIF"; +"HDR" = "HDR"; +"Rig Settings" = "रिग सेटिंग्स"; +"%@ can't" = "%@ नहीं कर सकता"; +"%@ can't do HDR" = "%@ HDR नहीं कर सकता"; diff --git a/RemoteCam/it.lproj/Localizable.strings b/RemoteCam/it.lproj/Localizable.strings index a4bbb29f..26f2ee29 100644 --- a/RemoteCam/it.lproj/Localizable.strings +++ b/RemoteCam/it.lproj/Localizable.strings @@ -249,3 +249,16 @@ "RECONNECTING" = "RICONNESSIONE"; "Connect (%d)" = "Connetti (%d)"; "Select All" = "Seleziona tutte"; + +// Multicam rig settings tray +"Auto" = "Auto"; +"Timer" = "Timer"; +"Off" = "Off"; +"Video Quality" = "Qualità video"; +"Automatic" = "Automatico"; +"Photo" = "Foto"; +"HEIF" = "HEIF"; +"HDR" = "HDR"; +"Rig Settings" = "Impostazioni rig"; +"%@ can't" = "%@ non può"; +"%@ can't do HDR" = "%@ non supporta HDR"; diff --git a/RemoteCam/ja.lproj/Localizable.strings b/RemoteCam/ja.lproj/Localizable.strings index a6622f75..fb7bba14 100644 --- a/RemoteCam/ja.lproj/Localizable.strings +++ b/RemoteCam/ja.lproj/Localizable.strings @@ -370,3 +370,16 @@ "RECONNECTING" = "再接続中"; "Connect (%d)" = "接続 (%d)"; "Select All" = "すべて選択"; + +// Multicam rig settings tray +"Auto" = "自動"; +"Timer" = "タイマー"; +"Off" = "オフ"; +"Video Quality" = "ビデオ画質"; +"Automatic" = "自動"; +"Photo" = "写真"; +"HEIF" = "HEIF"; +"HDR" = "HDR"; +"Rig Settings" = "リグ設定"; +"%@ can't" = "%@は非対応"; +"%@ can't do HDR" = "%@はHDR非対応"; diff --git a/RemoteCam/ko.lproj/Localizable.strings b/RemoteCam/ko.lproj/Localizable.strings index 9fefefb8..249dd713 100644 --- a/RemoteCam/ko.lproj/Localizable.strings +++ b/RemoteCam/ko.lproj/Localizable.strings @@ -370,3 +370,16 @@ "RECONNECTING" = "다시 연결 중"; "Connect (%d)" = "연결 (%d)"; "Select All" = "모두 선택"; + +// Multicam rig settings tray +"Auto" = "자동"; +"Timer" = "타이머"; +"Off" = "끔"; +"Video Quality" = "비디오 화질"; +"Automatic" = "자동"; +"Photo" = "사진"; +"HEIF" = "HEIF"; +"HDR" = "HDR"; +"Rig Settings" = "리그 설정"; +"%@ can't" = "%@ 불가"; +"%@ can't do HDR" = "%@ HDR 불가"; diff --git a/RemoteCam/ms.lproj/Localizable.strings b/RemoteCam/ms.lproj/Localizable.strings index e8185662..8024f22e 100644 --- a/RemoteCam/ms.lproj/Localizable.strings +++ b/RemoteCam/ms.lproj/Localizable.strings @@ -370,3 +370,16 @@ "RECONNECTING" = "MENYAMBUNG SEMULA"; "Connect (%d)" = "Sambung (%d)"; "Select All" = "Pilih semua"; + +// Multicam rig settings tray +"Auto" = "Auto"; +"Timer" = "Pemasa"; +"Off" = "Mati"; +"Video Quality" = "Kualiti video"; +"Automatic" = "Automatik"; +"Photo" = "Foto"; +"HEIF" = "HEIF"; +"HDR" = "HDR"; +"Rig Settings" = "Tetapan rig"; +"%@ can't" = "%@ tidak boleh"; +"%@ can't do HDR" = "%@ tidak boleh HDR"; diff --git a/RemoteCam/pt-BR.lproj/Localizable.strings b/RemoteCam/pt-BR.lproj/Localizable.strings index 416aad11..5bfaeede 100644 --- a/RemoteCam/pt-BR.lproj/Localizable.strings +++ b/RemoteCam/pt-BR.lproj/Localizable.strings @@ -370,3 +370,16 @@ "RECONNECTING" = "RECONECTANDO"; "Connect (%d)" = "Conectar (%d)"; "Select All" = "Selecionar todas"; + +// Multicam rig settings tray +"Auto" = "Auto"; +"Timer" = "Temporizador"; +"Off" = "Desligado"; +"Video Quality" = "Qualidade de vídeo"; +"Automatic" = "Automático"; +"Photo" = "Foto"; +"HEIF" = "HEIF"; +"HDR" = "HDR"; +"Rig Settings" = "Ajustes do rig"; +"%@ can't" = "%@ não pode"; +"%@ can't do HDR" = "%@ não faz HDR"; diff --git a/RemoteCam/ru.lproj/Localizable.strings b/RemoteCam/ru.lproj/Localizable.strings index 2fd3c28e..22100246 100644 --- a/RemoteCam/ru.lproj/Localizable.strings +++ b/RemoteCam/ru.lproj/Localizable.strings @@ -370,3 +370,16 @@ "RECONNECTING" = "ПЕРЕПОДКЛЮЧЕНИЕ"; "Connect (%d)" = "Подключить (%d)"; "Select All" = "Выбрать все"; + +// Multicam rig settings tray +"Auto" = "Авто"; +"Timer" = "Таймер"; +"Off" = "Выкл"; +"Video Quality" = "Качество видео"; +"Automatic" = "Автоматически"; +"Photo" = "Фото"; +"HEIF" = "HEIF"; +"HDR" = "HDR"; +"Rig Settings" = "Настройки рига"; +"%@ can't" = "%@ не может"; +"%@ can't do HDR" = "%@ не поддерживает HDR"; diff --git a/RemoteCam/tr.lproj/Localizable.strings b/RemoteCam/tr.lproj/Localizable.strings index c2f40c23..c5967e92 100644 --- a/RemoteCam/tr.lproj/Localizable.strings +++ b/RemoteCam/tr.lproj/Localizable.strings @@ -370,3 +370,16 @@ "RECONNECTING" = "YENİDEN BAĞLANIYOR"; "Connect (%d)" = "Bağlan (%d)"; "Select All" = "Tümünü seç"; + +// Multicam rig settings tray +"Auto" = "Otomatik"; +"Timer" = "Zamanlayıcı"; +"Off" = "Kapalı"; +"Video Quality" = "Video kalitesi"; +"Automatic" = "Otomatik"; +"Photo" = "Fotoğraf"; +"HEIF" = "HEIF"; +"HDR" = "HDR"; +"Rig Settings" = "Rig ayarları"; +"%@ can't" = "%@ yapamaz"; +"%@ can't do HDR" = "%@ HDR yapamaz"; diff --git a/RemoteCam/vi.lproj/Localizable.strings b/RemoteCam/vi.lproj/Localizable.strings index 986812e8..32287801 100644 --- a/RemoteCam/vi.lproj/Localizable.strings +++ b/RemoteCam/vi.lproj/Localizable.strings @@ -370,3 +370,16 @@ "RECONNECTING" = "ĐANG KẾT NỐI LẠI"; "Connect (%d)" = "Kết nối (%d)"; "Select All" = "Chọn tất cả"; + +// Multicam rig settings tray +"Auto" = "Tự động"; +"Timer" = "Hẹn giờ"; +"Off" = "Tắt"; +"Video Quality" = "Chất lượng video"; +"Automatic" = "Tự động"; +"Photo" = "Ảnh"; +"HEIF" = "HEIF"; +"HDR" = "HDR"; +"Rig Settings" = "Cài đặt rig"; +"%@ can't" = "%@ không thể"; +"%@ can't do HDR" = "%@ không hỗ trợ HDR"; diff --git a/RemoteCam/zh-Hans.lproj/Localizable.strings b/RemoteCam/zh-Hans.lproj/Localizable.strings index a125bc2b..f25e0a63 100644 --- a/RemoteCam/zh-Hans.lproj/Localizable.strings +++ b/RemoteCam/zh-Hans.lproj/Localizable.strings @@ -370,3 +370,16 @@ "RECONNECTING" = "正在重新连接"; "Connect (%d)" = "连接 (%d)"; "Select All" = "全选"; + +// Multicam rig settings tray +"Auto" = "自动"; +"Timer" = "定时器"; +"Off" = "关闭"; +"Video Quality" = "视频质量"; +"Automatic" = "自动"; +"Photo" = "照片"; +"HEIF" = "HEIF"; +"HDR" = "HDR"; +"Rig Settings" = "多机位设置"; +"%@ can't" = "%@ 不支持"; +"%@ can't do HDR" = "%@ 不支持 HDR"; diff --git a/RemoteCamTests/MulticamControllerTests.swift b/RemoteCamTests/MulticamControllerTests.swift index fceb7c39..feb0f7c5 100644 --- a/RemoteCamTests/MulticamControllerTests.swift +++ b/RemoteCamTests/MulticamControllerTests.swift @@ -16,6 +16,7 @@ private final class FakeMulticamDisplay: MulticamDisplay, @unchecked Sendable { var capturing = false var recording = false var availablePeers: [MCPeerID] = [] + var rigSettings: RigSettingsSnapshot? var didExit = false func applyLanes(_ lanes: [MulticamLaneInfo]) { lastLanes = lanes } @@ -24,6 +25,7 @@ private final class FakeMulticamDisplay: MulticamDisplay, @unchecked Sendable { self.recording = recording } func applyAvailablePeers(_ peers: [MCPeerID]) { availablePeers = peers } + func applyRigSettings(_ settings: RigSettingsSnapshot) { rigSettings = settings } func receiveFrame(_ frame: RemoteCmd.OnFrame) { receivedFrames.append(frame.peerId) } func exitMulticam() { didExit = true } } @@ -472,6 +474,135 @@ final class MulticamControllerTests: XCTestCase { XCTAssertEqual(focusedAfter, camB) } + // MARK: - Rig quality (intersection) + timer + + /// Caps whose current (back) camera advertises a resolution/fps matrix. + private func capsWith(_ matrix: [VideoResolution: [VideoFrameRate]], + heif: Bool = true, hdr: Bool = true) -> RemoteCmd.CameraCapabilitiesResp { + let info = RemoteCmd.CameraInfo( + availableLenses: [.wideAngle], hasFlash: true, hasTorch: true, + 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, + supportsMulticam: true, error: nil) + } + + private let full4K: [VideoResolution: [VideoFrameRate]] = + [.uhd4k: [.fps30, .fps60], .hd1080p: [.fps30, .fps60]] + private let only1080: [VideoResolution: [VideoFrameRate]] = [.hd1080p: [.fps30]] + + func testSetVideoQualityFansOutToEveryLane() async { + let (controller, transport, _) = await makeController(peers: [camA, camB]) + transport.sentMessages.removeAll() + + await controller.setVideoQuality(resolution: .uhd4k, frameRate: .fps30) + await controller.waitForIdle() + + let sends = sent(transport, RemoteCmd.SetVideoQuality.self) + XCTAssertEqual(Set(sends.flatMap(\.peers)), [camA, camB]) + for s in sends { + let q = s.msg as? RemoteCmd.SetVideoQuality + XCTAssertEqual(q?.resolution, .uhd4k) + XCTAssertEqual(q?.frameRate, .fps30) + } + let active = await controller.activeVideoQualityForTesting() + XCTAssertEqual(active?.0, .uhd4k) + } + + /// Manual toggle between two shared options fans out each time (Dario's + /// first-class-manual requirement). + func testManualQualityToggleFansOutEachTime() async { + let (controller, transport, _) = await makeController(peers: [camA, camB]) + controller.didReceiveMessage(capsWith(full4K), from: camA) + controller.didReceiveMessage(capsWith(full4K), from: camB) + await controller.waitForIdle() + transport.sentMessages.removeAll() + + await controller.setVideoQuality(resolution: .hd1080p, frameRate: .fps30) + await controller.setVideoQuality(resolution: .uhd4k, frameRate: .fps30) + await controller.waitForIdle() + + let sends = sent(transport, RemoteCmd.SetVideoQuality.self) + // Two toggles × two lanes = four sends. + XCTAssertEqual(sends.count, 4) + } + + func testAutomaticPicksBestInIntersectionAndFansOut() async { + let (controller, transport, _) = await makeController(peers: [camA, camB]) + // camA does 4K; camB only 1080p → the rig can only agree on 1080p30. + controller.didReceiveMessage(capsWith(full4K), from: camA) + controller.didReceiveMessage(capsWith(only1080), from: camB) + await controller.waitForIdle() + transport.sentMessages.removeAll() + + await controller.applyAutomaticVideoQuality() + await controller.waitForIdle() + + let sends = sent(transport, RemoteCmd.SetVideoQuality.self) + XCTAssertEqual(Set(sends.flatMap(\.peers)), [camA, camB]) + let q = sends.first?.msg as? RemoteCmd.SetVideoQuality + XCTAssertEqual(q?.resolution, .hd1080p) + XCTAssertEqual(q?.frameRate, .fps30) + } + + func testLateJoinerThatCannotMatchIsFlagged() async { + let (controller, _, _) = await makeController(peers: [camA, camB]) + controller.didReceiveMessage(capsWith(full4K), from: camA) + controller.didReceiveMessage(capsWith(full4K), from: camB) + await controller.waitForIdle() + // Rig set to 4K30 (both can). + await controller.setVideoQuality(resolution: .uhd4k, frameRate: .fps30) + await controller.waitForIdle() + let flaggedBefore = await controller.needsRematchForTesting(camB) + XCTAssertFalse(flaggedBefore) + + // camB device-switches to a 1080-only camera → can't match → flagged. + controller.didReceiveMessage(capsWith(only1080), from: camB) + await controller.waitForIdle() + let flaggedAfter = await controller.needsRematchForTesting(camB) + let camAstillOK = await controller.needsRematchForTesting(camA) + XCTAssertTrue(flaggedAfter) + XCTAssertFalse(camAstillOK) + } + + func testRigTimerCountsDownFansOutAndFiresCapture() async { + let (controller, transport, _) = await makeController(peers: [camA, camB]) + // Seed offsets so the fired capture takes the scheduled path. + await controller.seedLaneForTesting(camA, supportsMulticam: true, offsetMillis: 0) + await controller.seedLaneForTesting(camB, supportsMulticam: true, offsetMillis: 0) + await controller.setTimerTickInterval(0.001) // fast + await controller.setRigTimer(3) + transport.sentMessages.removeAll() + + await controller.capturePhoto() + // Wait for the countdown (3 ticks + fire) to elapse. + for _ in 0..<200 where sent(transport, RemoteCmd.ScheduledCapture.self).isEmpty { + try? await Task.sleep(nanoseconds: 5_000_000) + } + // The countdown fanned out TimerCountdown to both cameras... + let ticks = sent(transport, RemoteCmd.TimerCountdown.self) + XCTAssertTrue(ticks.contains { ($0.msg as? RemoteCmd.TimerCountdown)?.value == 3 }) + XCTAssertTrue(ticks.contains { ($0.msg as? RemoteCmd.TimerCountdown)?.value == 0 }) + // ...and the synced capture fired after expiry. + XCTAssertFalse(sent(transport, RemoteCmd.ScheduledCapture.self).isEmpty) + } + + func testSetPhotoQualityFansOut() async { + let (controller, transport, _) = await makeController(peers: [camA, camB]) + transport.sentMessages.removeAll() + await controller.setPhotoQuality(format: .heif, hdr: .on) + await controller.waitForIdle() + let sends = sent(transport, RemoteCmd.SetPhotoQuality.self) + XCTAssertEqual(Set(sends.flatMap(\.peers)), [camA, camB]) + let q = sends.first?.msg as? RemoteCmd.SetPhotoQuality + XCTAssertEqual(q?.format, .heif) + XCTAssertEqual(q?.hdrMode, .on) + } + // MARK: - Fixtures private func multicamCaps() -> RemoteCmd.CameraCapabilitiesResp { diff --git a/RemoteCamTests/MulticamViewModelTests.swift b/RemoteCamTests/MulticamViewModelTests.swift index 845376b2..c639f469 100644 --- a/RemoteCamTests/MulticamViewModelTests.swift +++ b/RemoteCamTests/MulticamViewModelTests.swift @@ -18,7 +18,7 @@ final class MulticamViewModelTests: XCTestCase { focused: Bool = false) -> MulticamLaneInfo { MulticamLaneInfo(peerID: peer, displayName: peer.displayName, status: status, isFocused: focused, clockOffsetMillis: nil, - captureOutcome: nil, isRecording: false) + captureOutcome: nil, isRecording: false, needsQualityRematch: false) } func testApplyAddsLanesAndReportsCreated() { diff --git a/RemoteCamTests/RigQualityMenuTests.swift b/RemoteCamTests/RigQualityMenuTests.swift new file mode 100644 index 00000000..c0321061 --- /dev/null +++ b/RemoteCamTests/RigQualityMenuTests.swift @@ -0,0 +1,119 @@ +// +// RigQualityMenuTests.swift +// RemoteShutterTests +// +// Copyright © 2026 Security Union LLC. All rights reserved. +// + +import XCTest +@testable import RemoteShutter + +final class RigQualityMenuTests: XCTestCase { + + /// A CameraInfo advertising a resolution/fps matrix (+ HEIF/HDR). + private func info(_ matrix: [VideoResolution: [VideoFrameRate]], + 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, + supportsHEIF: heif, supportsHDR: hdr) + } + + private func lane(_ name: String, _ matrix: [VideoResolution: [VideoFrameRate]], + heif: Bool = true, hdr: Bool = true) -> RigQualityMenu.Lane { + RigQualityMenu.Lane(name: name, info: info(matrix, heif: heif, hdr: hdr)) + } + + private let full4K: [VideoResolution: [VideoFrameRate]] = + [.uhd4k: [.fps24, .fps30, .fps60], .hd1080p: [.fps24, .fps30, .fps60]] + private let only1080: [VideoResolution: [VideoFrameRate]] = + [.hd1080p: [.fps24, .fps30]] + + // MARK: - Intersection + + func testHomogeneousRigOffersEverything() { + let menu = RigQualityMenu(lanes: [lane("Cam 1", full4K), lane("Cam 2", full4K)]) + let opts = Set(menu.videoOptions().map { "\($0.resolution.rawValue):\($0.frameRate.rawValue)" }) + XCTAssertTrue(opts.contains("2:3")) // 4K60 + XCTAssertTrue(opts.contains("1:2")) // 1080p30 + XCTAssertTrue(menu.blockingLanes(resolution: .uhd4k, frameRate: .fps60).isEmpty) + } + + func testHeterogeneousRigIntersectsAndNamesBlocker() { + // Cam 2 can only do 1080p{24,30}. The rig loses 4K and 1080p60. + let menu = RigQualityMenu(lanes: [lane("Cam 1", full4K), lane("Cam 2", only1080)]) + XCTAssertEqual(menu.blockingLanes(resolution: .uhd4k, frameRate: .fps30), ["Cam 2"]) + XCTAssertEqual(menu.blockingLanes(resolution: .hd1080p, frameRate: .fps60), ["Cam 2"]) + // 1080p30 survives (both do it). + XCTAssertTrue(menu.blockingLanes(resolution: .hd1080p, frameRate: .fps30).isEmpty) + } + + /// Dario's clarification: manual selection within the intersection is + /// first-class — both 1080p30 and 4K30 must be enabled for a rig where both + /// cameras do both, so the tray can toggle directly between them. + func testManualOptionsBothEnabledWhenSharedByAllCameras() { + let shared: [VideoResolution: [VideoFrameRate]] = [.uhd4k: [.fps30], .hd1080p: [.fps30]] + let menu = RigQualityMenu(lanes: [lane("Cam 1", shared), lane("Cam 2", shared)]) + XCTAssertTrue(menu.blockingLanes(resolution: .hd1080p, frameRate: .fps30).isEmpty) + XCTAssertTrue(menu.blockingLanes(resolution: .uhd4k, frameRate: .fps30).isEmpty) + } + + // MARK: - Automatic + + func testAutomaticPicksHighestResThenFps() { + let menu = RigQualityMenu(lanes: [lane("Cam 1", full4K), lane("Cam 2", full4K)]) + let auto = menu.automaticVideo() + XCTAssertEqual(auto.resolution, .uhd4k) + XCTAssertEqual(auto.frameRate, .fps60) + } + + func testAutomaticFallsBackToFloorOnEmptyIntersection() { + // Cam 1 does 4K only; Cam 2 does 1080p only → nothing shared but the + // floor, so Automatic is 1080p30. + let menu = RigQualityMenu(lanes: [ + lane("Cam 1", [.uhd4k: [.fps30]]), + lane("Cam 2", [.hd1080p: [.fps30]])]) + let auto = menu.automaticVideo() + XCTAssertEqual(auto.resolution, RigQualityMenu.floor.resolution) + XCTAssertEqual(auto.frameRate, RigQualityMenu.floor.frameRate) + } + + func testEmptyRigAutomaticIsFloor() { + let menu = RigQualityMenu(lanes: []) + XCTAssertEqual(menu.automaticVideo().resolution, .hd1080p) + XCTAssertEqual(menu.automaticVideo().frameRate, .fps30) + } + + // MARK: - Photo + + func testPhotoIntersection() { + let both = RigQualityMenu(lanes: [lane("A", only1080, heif: true, hdr: true), + lane("B", only1080, heif: true, hdr: true)]) + XCTAssertTrue(both.supportsHEIF()) + XCTAssertTrue(both.supportsHDR()) + XCTAssertEqual(both.automaticPhoto().format, .heif) + XCTAssertEqual(both.automaticPhoto().hdr, .on) + + let mixed = RigQualityMenu(lanes: [lane("A", only1080, heif: true, hdr: false), + lane("B", only1080, heif: false, hdr: true)]) + XCTAssertFalse(mixed.supportsHEIF()) + XCTAssertFalse(mixed.supportsHDR()) + XCTAssertEqual(mixed.lanesBlockingHEIF(), ["B"]) + XCTAssertEqual(mixed.lanesBlockingHDR(), ["A"]) + XCTAssertEqual(mixed.automaticPhoto().format, .jpeg) + } + + // MARK: - Late joiner + + func testLateJoinerThatCannotMatchIsDetected() { + let menu = RigQualityMenu(lanes: [lane("Cam 1", full4K), lane("Cam 2", only1080)]) + // The rig is running at 4K30; Cam 2 (1080-only) can't match. + XCTAssertFalse(menu.laneCanMatch(lane("Cam 2", only1080), + resolution: .uhd4k, frameRate: .fps30)) + XCTAssertTrue(menu.laneCanMatch(lane("Cam 1", full4K), + resolution: .uhd4k, frameRate: .fps30)) + } +} diff --git a/RemoteShutter.xcodeproj/project.pbxproj b/RemoteShutter.xcodeproj/project.pbxproj index 25779ec2..8abfb2ec 100644 --- a/RemoteShutter.xcodeproj/project.pbxproj +++ b/RemoteShutter.xcodeproj/project.pbxproj @@ -203,6 +203,8 @@ CAFEBABE0120000000000001 /* MonitorChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0120000000000002 /* MonitorChrome.swift */; }; CAFEBABE0130000000000001 /* CaptureSyncMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */; }; CAFEBABE0132000000000001 /* ClockOffsetEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0132000000000002 /* ClockOffsetEstimator.swift */; }; + CAFEBABE0160000000000001 /* RigQualityMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0160000000000002 /* RigQualityMenu.swift */; }; + CAFEBABE0161000000000002 /* RigQualityMenuTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0161000000000001 /* RigQualityMenuTests.swift */; }; CAFEBABE0150000000000001 /* MultiCamChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0150000000000002 /* MultiCamChrome.swift */; }; CAFEBABE0151000000000002 /* MultiCamChromeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0151000000000001 /* MultiCamChromeTests.swift */; }; CAFEBABE0140000000000001 /* CameraLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0140000000000002 /* CameraLink.swift */; }; @@ -457,6 +459,8 @@ CAFEBABE0120000000000002 /* MonitorChrome.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MonitorChrome.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 = ""; }; + CAFEBABE0160000000000002 /* RigQualityMenu.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RigQualityMenu.swift; sourceTree = ""; }; + CAFEBABE0161000000000001 /* RigQualityMenuTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RigQualityMenuTests.swift; sourceTree = ""; }; CAFEBABE0150000000000002 /* MultiCamChrome.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MultiCamChrome.swift; sourceTree = ""; }; CAFEBABE0151000000000001 /* MultiCamChromeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiCamChromeTests.swift; sourceTree = ""; }; CAFEBABE0140000000000002 /* CameraLink.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraLink.swift; sourceTree = ""; }; @@ -619,6 +623,7 @@ CAFEBABE0121000000000001 /* MonitorChromeTests.swift */, CAFEBABE0131000000000001 /* CaptureSyncMetadataTests.swift */, CAFEBABE0133000000000001 /* ClockOffsetEstimatorTests.swift */, + CAFEBABE0161000000000001 /* RigQualityMenuTests.swift */, CAFEBABE0151000000000001 /* MultiCamChromeTests.swift */, CAFEBABE0145000000000001 /* MulticamControllerTests.swift */, CAFEBABE0146000000000001 /* MulticamViewModelTests.swift */, @@ -716,6 +721,7 @@ CAFEBABE0120000000000002 /* MonitorChrome.swift */, CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */, CAFEBABE0132000000000002 /* ClockOffsetEstimator.swift */, + CAFEBABE0160000000000002 /* RigQualityMenu.swift */, CAFEBABE0150000000000002 /* MultiCamChrome.swift */, CAFEBABE0140000000000002 /* CameraLink.swift */, CAFEBABE0141000000000002 /* MulticamController.swift */, @@ -1216,6 +1222,7 @@ CAFEBABE0120000000000001 /* MonitorChrome.swift in Sources */, CAFEBABE0130000000000001 /* CaptureSyncMetadata.swift in Sources */, CAFEBABE0132000000000001 /* ClockOffsetEstimator.swift in Sources */, + CAFEBABE0160000000000001 /* RigQualityMenu.swift in Sources */, CAFEBABE0150000000000001 /* MultiCamChrome.swift in Sources */, CAFEBABE0140000000000001 /* CameraLink.swift in Sources */, CAFEBABE0141000000000001 /* MulticamController.swift in Sources */, @@ -1296,6 +1303,7 @@ CAFEBABE0121000000000002 /* MonitorChromeTests.swift in Sources */, CAFEBABE0131000000000002 /* CaptureSyncMetadataTests.swift in Sources */, CAFEBABE0133000000000002 /* ClockOffsetEstimatorTests.swift in Sources */, + CAFEBABE0161000000000002 /* RigQualityMenuTests.swift in Sources */, CAFEBABE0151000000000002 /* MultiCamChromeTests.swift in Sources */, CAFEBABE0145000000000002 /* MulticamControllerTests.swift in Sources */, CAFEBABE0146000000000002 /* MulticamViewModelTests.swift in Sources */, From ff9142f52b644dbdd36abcc5e840b76bbb78708d Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Tue, 11 Aug 2026 03:38:28 -0700 Subject: [PATCH 13/17] Multicam commit B: auto-collect footage to the director (Apple-style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every take now gathers to the director, while each camera keeps its own copy. Photos (inline return): a scheduled ScheduledCapture now stamps the still once (EXIF sync JSON + wall-clock DateTimeOriginal), saves that stamped image locally under RS___cam, AND returns it to the director via the existing TakePicResp media path. The director saves it to its own library under the shared RS_ name and marks the lane collected. Decision: inline return (not resource) — stills are small, so no staggering needed. Videos (resource transfer): a scheduled stop now saves locally AND pushes the clip to the director via the existing sendResource path (single-cam already did exactly this). The camera names the transfer with the RS_ filename so the director saves under the group; QuickTime sync metadata rides inside the .mov, untouched by transfer. Multicam clips are COPIED (not moved) to Photos so the temp survives the send/retry; the next recording cleans it up. Sequencing (decision): lane-index staggered send — each camera delays its transfer by (cameraIndex-1) × stagger (default 2s) so N×4K don't hit the link at once. Previews may degrade during collection (acceptable). Limitation: a fixed delay can overlap if one transfer runs long; a director-coordinated turn-taking is a follow-up. Per-peer receive routing: the resource delegate callbacks now carry the source peer (Seam A for resources; the 1:1 coordinator ignores it). The director tracks per-lane collection state (idle → transferring → collected/failed), shown as a tile badge; a failed transfer marks the lane (footage still safe on the camera) with a Retry that sends RequestVideoResend (new additive action 30), and the camera re-sends its held clip. Tests: video transfer start/finish updates lane state + saves; failed marks + retry re-requests to that peer; returned photo marks collected; scheduled capture/stop now send media to the director; RequestVideoResend round-trip. Full suite green (727). Co-Authored-By: Claude Fable 5 --- RemoteCam/CameraLink.swift | 12 ++ RemoteCam/FlatBufferSchemas.fbs | 4 +- RemoteCam/FlatBufferSchemas_generated.swift | 3 +- RemoteCam/MulticamController.swift | 148 +++++++++++++++++- RemoteCam/MulticamView.swift | 50 +++++- RemoteCam/MulticamViewController.swift | 3 + RemoteCam/MulticamViewModel.swift | 4 + RemoteCam/MultipeerService.swift | 8 +- RemoteCam/RecordingPipeline.swift | 7 +- RemoteCam/RemoteCmdFlatBuffers.swift | 13 ++ RemoteCam/RemoteCmds.swift | 11 ++ RemoteCam/SessionCoordinator.swift | 108 +++++++++---- RemoteCam/da.lproj/Localizable.strings | 1 + RemoteCam/de-DE.lproj/Localizable.strings | 1 + RemoteCam/en.lproj/Localizable.strings | 1 + RemoteCam/es-MX.lproj/Localizable.strings | 1 + RemoteCam/fr-FR.lproj/Localizable.strings | 1 + RemoteCam/hi.lproj/Localizable.strings | 1 + RemoteCam/it.lproj/Localizable.strings | 1 + RemoteCam/ja.lproj/Localizable.strings | 1 + RemoteCam/ko.lproj/Localizable.strings | 1 + RemoteCam/ms.lproj/Localizable.strings | 1 + RemoteCam/pt-BR.lproj/Localizable.strings | 1 + RemoteCam/ru.lproj/Localizable.strings | 1 + RemoteCam/tr.lproj/Localizable.strings | 1 + RemoteCam/vi.lproj/Localizable.strings | 1 + RemoteCam/zh-Hans.lproj/Localizable.strings | 1 + RemoteCamTests/MulticamControllerTests.swift | 51 ++++++ RemoteCamTests/MulticamViewModelTests.swift | 3 +- RemoteCamTests/RemoteCamSessionTests.swift | 7 +- .../RemoteCmdSerializationTests.swift | 6 + RemoteCamTests/StormoLoopbackTests.swift | 4 +- 32 files changed, 400 insertions(+), 57 deletions(-) diff --git a/RemoteCam/CameraLink.swift b/RemoteCam/CameraLink.swift index 9b781090..b9e5eb01 100644 --- a/RemoteCam/CameraLink.swift +++ b/RemoteCam/CameraLink.swift @@ -20,6 +20,14 @@ import Stormo /// would force a dictionary read-modify-write on every frame. final class CameraLink { + /// Progress of one lane's footage transfer to the director after a take. + enum LaneCollectionState: Equatable { + case idle + case transferring(Double) // 0…1 + case collected + case failed + } + enum Status: Equatable { /// The session is up and frames are expected. case linked @@ -68,6 +76,10 @@ final class CameraLink { /// re-match rather than silently changing the rig. var needsQualityRematch = false + /// Where this lane's footage is in the post-take auto-collect to the + /// director. Drives the tile's transfer progress / done / failed badge. + var collection: LaneCollectionState = .idle + /// This camera's own preview decoder + stall watchdog. Frames tagged with /// this peer's id are fed here; its `onImage` drives exactly this lane's /// tile, so a frame from another camera never touches it. diff --git a/RemoteCam/FlatBufferSchemas.fbs b/RemoteCam/FlatBufferSchemas.fbs index 47bd6e8d..cc350bb6 100644 --- a/RemoteCam/FlatBufferSchemas.fbs +++ b/RemoteCam/FlatBufferSchemas.fbs @@ -55,9 +55,11 @@ enum CommandAction : byte { // echoing the capture id. Only sent to multicam peers. ScheduledStopRecording = 28, // director -> camera: stop recording at the fire // instant, so clip lengths match across the rig. - SetStreamProfile = 29 // director -> camera: reconfigure the live preview + SetStreamProfile = 29, // director -> camera: reconfigure the live preview // encoder (resolution/bitrate/fps) for tiered // multicam previews. Only sent to multicam peers. + RequestVideoResend = 30 // director -> camera: re-send the last collected + // clip (auto-collect retry). Reuses capture_id. } // Whether the camera device drives its own on-screen live preview. On is the diff --git a/RemoteCam/FlatBufferSchemas_generated.swift b/RemoteCam/FlatBufferSchemas_generated.swift index 0b2559e9..86270daf 100644 --- a/RemoteCam/FlatBufferSchemas_generated.swift +++ b/RemoteCam/FlatBufferSchemas_generated.swift @@ -38,8 +38,9 @@ public enum RemoteShutter_CommandAction: Int8, Enum, Verifiable { case scheduledstartrecording = 27 case scheduledstoprecording = 28 case setstreamprofile = 29 + case requestvideoresend = 30 - public static var max: RemoteShutter_CommandAction { return .setstreamprofile } + public static var max: RemoteShutter_CommandAction { return .requestvideoresend } public static var min: RemoteShutter_CommandAction { return .unknown } } diff --git a/RemoteCam/MulticamController.swift b/RemoteCam/MulticamController.swift index 46e56795..5dad0545 100644 --- a/RemoteCam/MulticamController.swift +++ b/RemoteCam/MulticamController.swift @@ -9,6 +9,7 @@ import Foundation import MPCCompat +import Photos import Stormo import UIKit @@ -50,6 +51,8 @@ struct MulticamLaneInfo: Equatable { let isRecording: Bool /// This camera can't match the running rig quality — tile badge + re-match. let needsQualityRematch: Bool + /// Where this lane's footage is in the post-take auto-collect. + let collection: CameraLink.LaneCollectionState } /// The main-actor bridge from the controller to the multicam screen — the @@ -150,6 +153,9 @@ public actor MulticamController { /// unless `state` is `.capturingPhoto`. private var capturingLanes: Set = [] private var currentCaptureID: String? + /// The most recent photo/video capture id, used to name auto-collected + /// footage on the director under the shared `RS___cam` group. + private var lastCaptureID: String? /// Lead time before a synced shutter fires — long enough to cover the /// worst-case one-way latency plus a retransmit and scheduling slop, so @@ -267,6 +273,12 @@ public actor MulticamController { case let frame as RemoteCmd.OnFrame: handleFrame(frame) + case let started as ResourceTransferStarted: + handleResourceStarted(started) + + case let finished as ResourceTransferFinished: + handleResourceFinished(finished) + case let measured as ClockPongMeasured: storePong(measured.pong, t3: measured.t3, from: measured.peer) @@ -320,9 +332,13 @@ public actor MulticamController { resolvePhotoAck(from: peer, success: true) case let resp as RemoteCmd.TakePicResp: - // Fallback failure signal; a success here is a duplicate of the ack - // and is ignored (the lane is already resolved). - if resp.error != nil { resolvePhotoAck(from: peer, success: false) } + if resp.error != nil { + resolvePhotoAck(from: peer, success: false) + } else if let pic = resp.pic { + // Auto-collect: the camera returned its (EXIF-stamped) still. + // Save it to the director's library under the shared RS_ name. + collectPhoto(pic, from: peer) + } case let ack as RemoteCmd.ScheduledRecordingAck: resolveRecordingAck(from: peer, isStop: ack.isStop, success: ack.error == nil) @@ -534,6 +550,8 @@ public actor MulticamController { let captureID = UUID().uuidString capturingLanes = Set(lanes.map(\.peerID)) currentCaptureID = captureID + lastCaptureID = captureID + for link in lanes { link.collection = .idle } if lanes.allSatisfy({ $0.latestOffset != nil }) { let fireAt = SyncClock.nowMillis() + captureLeadMillis @@ -833,6 +851,94 @@ public actor MulticamController { publishLanes() } + // MARK: - Auto-collect (footage back to the director) + + /// Test seams. + func collectionStateForTesting(_ peer: MCPeerID) -> CameraLink.LaneCollectionState? { links[peer]?.collection } + + /// The lane's 1-based index, for the shared RS_ filename group. + private func cameraIndex(of peer: MCPeerID) -> Int { + (order.firstIndex(of: peer) ?? 0) + 1 + } + + private func rigMetadata(for peer: MCPeerID) -> CaptureSyncMetadata { + CaptureSyncMetadata( + sessionID: sessionID, captureID: lastCaptureID ?? UUID().uuidString, + cameraIndex: cameraIndex(of: peer), anchorMillis: 0, + clockOffsetMillis: 0, roundTripMillis: 0) + } + + /// Save a camera's returned still (already EXIF-stamped on the camera) to + /// the director's library under the shared RS_ name, and mark the lane + /// collected. + private func collectPhoto(_ data: Data, from peer: MCPeerID) { + guard let link = links[peer] else { return } + let name = rigMetadata(for: peer).photoFilename(isHEIC: Self.isHEIC(data)) + link.collection = .collected + publishLanes() + Self.savePhotoToLibrary(data, originalFilename: name) + } + + private func handleResourceStarted(_ started: ResourceTransferStarted) { + guard let link = links[started.peer] else { return } + link.collection = .transferring(0) + publishLanes() + } + + private func handleResourceFinished(_ finished: ResourceTransferFinished) { + guard let link = links[finished.peer] else { return } + if finished.error != nil || finished.localURL == nil { + // Footage is still safe on the camera; the tile offers a retry. + link.collection = .failed + publishLanes() + return + } + link.collection = .collected + publishLanes() + // The resource is named with the RS_ filename by the camera, so save it + // under that; QuickTime sync metadata rides inside the .mov itself. + Self.saveVideoToLibrary(at: finished.localURL!, originalFilename: finished.name) + } + + /// Re-request a failed lane's footage (the camera still holds it). + func retryCollection(for peer: MCPeerID) { + guard let link = links[peer], link.collection == .failed else { return } + link.collection = .transferring(0) + publishLanes() + sendTo(peer, RemoteCmd.RequestVideoResend(captureId: lastCaptureID ?? "")) + } + + private static func isHEIC(_ data: Data) -> Bool { + data.count > 12 && data[4] == 0x66 && data[5] == 0x74 && data[6] == 0x79 && data[7] == 0x70 + } + + private static func savePhotoToLibrary(_ data: Data, originalFilename: String) { + PHPhotoLibrary.requestAuthorization { status in + guard status == .authorized else { return } + PHPhotoLibrary.shared().performChanges({ + let options = PHAssetResourceCreationOptions() + options.originalFilename = originalFilename + PHAssetCreationRequest.forAsset().addResource(with: .photo, data: data, options: options) + }, completionHandler: { ok, _ in + print(ok ? "Director collected photo \(originalFilename)" : "collect photo failed") + }) + } + } + + private static func saveVideoToLibrary(at url: URL, originalFilename: String) { + PHPhotoLibrary.requestAuthorization { status in + guard status == .authorized else { return } + PHPhotoLibrary.shared().performChanges({ + let options = PHAssetResourceCreationOptions() + options.shouldMoveFile = true + options.originalFilename = originalFilename + PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: url, options: options) + }, completionHandler: { ok, _ in + print(ok ? "Director collected clip \(originalFilename)" : "collect clip failed") + }) + } + } + /// A lane's stall watchdog fired — re-request a frame to unstick just that /// camera's pump (the others are unaffected). func nudgeFrame(for peer: MCPeerID) { @@ -889,7 +995,8 @@ public actor MulticamController { clockOffsetMillis: link.latestOffset?.offsetMillis, captureOutcome: link.captureOutcome, isRecording: link.isRecording, - needsQualityRematch: link.needsQualityRematch) + needsQualityRematch: link.needsQualityRematch, + collection: link.collection) } } @@ -1003,6 +1110,35 @@ extension MulticamController: MultipeerServiceDelegate { public nonisolated func browserDidFail(_ error: Error) {} public nonisolated func advertiserDidFail(_ error: Error) {} - public nonisolated func didStartReceivingResource(name: String, progress: Progress) {} - public nonisolated func didFinishReceivingResource(name: String, at localURL: URL?, error: Error?) {} + + public nonisolated func didStartReceivingResource(name: String, from peer: MCPeerID, progress: Progress) { + tell(ResourceTransferStarted(peer: peer, name: name, progress: progress)) + } + + public nonisolated func didFinishReceivingResource(name: String, from peer: MCPeerID, at localURL: URL?, error: Error?) { + tell(ResourceTransferFinished(peer: peer, name: name, localURL: localURL, error: error)) + } +} + +/// A footage transfer from one camera started (carries a `Progress` to observe). +final class ResourceTransferStarted: Message, @unchecked Sendable { + let peer: MCPeerID + let name: String + let progress: Progress + init(peer: MCPeerID, name: String, progress: Progress) { + self.peer = peer; self.name = name; self.progress = progress + super.init(sender: nil) + } +} + +/// A footage transfer from one camera finished (or failed). +final class ResourceTransferFinished: Message, @unchecked Sendable { + let peer: MCPeerID + let name: String + let localURL: URL? + let error: Error? + init(peer: MCPeerID, name: String, localURL: URL?, error: Error?) { + self.peer = peer; self.name = name; self.localURL = localURL; self.error = error + super.init(sender: nil) + } } diff --git a/RemoteCam/MulticamView.swift b/RemoteCam/MulticamView.swift index 4afbd75a..39a02da0 100644 --- a/RemoteCam/MulticamView.swift +++ b/RemoteCam/MulticamView.swift @@ -34,6 +34,8 @@ struct MulticamView: View { /// Rig photo format / HDR picked. let onSetPhotoFormat: (PhotoFormat) -> Void let onSetHDR: (Bool) -> Void + /// Retry a lane's failed footage collection. + let onRetryCollection: (CameraLane) -> Void var body: some View { GeometryReader { geo in @@ -126,7 +128,7 @@ struct MulticamView: View { count: MultiCamChrome.gridColumnCount(cameraCount: count)) return LazyVGrid(columns: columns, spacing: 4) { ForEach(viewModel.lanes) { lane in - CameraTileView(lane: lane, isThumbnail: true) + CameraTileView(lane: lane, isThumbnail: true, onRetry: { onRetryCollection(lane) }) .aspectRatio(9.0 / 16.0, contentMode: .fit) .onTapGesture { onFocusLane(lane) @@ -220,7 +222,7 @@ struct MulticamView: View { Spacer() HStack(spacing: 8) { ForEach(others) { lane in - CameraTileView(lane: lane, isThumbnail: true) + CameraTileView(lane: lane, isThumbnail: true, onRetry: { onRetryCollection(lane) }) .frame(width: 96, height: 128) .onTapGesture { onFocusLane(lane) } } @@ -233,7 +235,7 @@ struct MulticamView: View { if dock == .trailing { Spacer() } VStack(spacing: 8) { ForEach(others) { lane in - CameraTileView(lane: lane, isThumbnail: true) + CameraTileView(lane: lane, isThumbnail: true, onRetry: { onRetryCollection(lane) }) .frame(width: 128, height: 96) .onTapGesture { onFocusLane(lane) } } @@ -269,6 +271,8 @@ struct MulticamView: View { struct CameraTileView: View { @ObservedObject var lane: CameraLane var isThumbnail: Bool = false + /// Retry a failed footage collection for this lane (nil = not offered). + var onRetry: (() -> Void)? = nil var body: some View { ZStack { @@ -276,6 +280,8 @@ struct CameraTileView: View { .clipShape(RoundedRectangle(cornerRadius: isThumbnail ? 10 : 0)) .saturation(lane.status == .linked ? 1 : 0) + collectionBadge + if lane.status == .reconnecting { RoundedRectangle(cornerRadius: isThumbnail ? 10 : 0) .fill(Color.black.opacity(0.45)) @@ -340,6 +346,44 @@ struct CameraTileView: View { RoundedRectangle(cornerRadius: isThumbnail ? 10 : 0) .stroke(lane.isFocused ? AppTheme.accent : .clear, lineWidth: 3)) } + + /// Post-take footage collection: a spinner while transferring, a check when + /// collected, and a tappable retry when the transfer failed. + @ViewBuilder + private var collectionBadge: some View { + switch lane.collection { + case .idle: + EmptyView() + case .transferring: + VStack { + Spacer() + HStack { + ProgressView().tint(.white) + Image(systemName: "square.and.arrow.down").foregroundColor(.white) + } + Spacer() + } + case .collected: + VStack { + HStack { + Spacer() + Image(systemName: "checkmark.icloud.fill").foregroundColor(.green).padding(6) + } + Spacer() + } + case .failed: + Button { onRetry?() } label: { + VStack(spacing: 4) { + Image(systemName: "arrow.clockwise.icloud").font(.title3) + Text(NSLocalizedString("Retry", comment: "retry footage collection")).font(.caption2) + } + .foregroundColor(.white) + .padding(8) + .background(Color.black.opacity(0.55)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } } /// Lists the cameras the director's browser has discovered but not yet added, diff --git a/RemoteCam/MulticamViewController.swift b/RemoteCam/MulticamViewController.swift index e8f3c8e9..eaf11e64 100644 --- a/RemoteCam/MulticamViewController.swift +++ b/RemoteCam/MulticamViewController.swift @@ -78,6 +78,9 @@ public final class MulticamViewController: UIViewController { Task { await self?.controller.setPhotoQuality( format: self?.viewModel.rigSettings.activePhotoFormat ?? .jpeg, hdr: on ? .on : .off) } + }, + onRetryCollection: { [weak self] lane in + Task { await self?.controller.retryCollection(for: lane.peerID) } }) hosting = embedSwiftUIView(multicamView) diff --git a/RemoteCam/MulticamViewModel.swift b/RemoteCam/MulticamViewModel.swift index 581819ca..d6548131 100644 --- a/RemoteCam/MulticamViewModel.swift +++ b/RemoteCam/MulticamViewModel.swift @@ -31,6 +31,8 @@ final class CameraLane: ObservableObject, Identifiable { @Published var isRecording: Bool /// This camera can't match the running rig quality — badge + re-match. @Published var needsQualityRematch: Bool + /// Post-take footage collection progress — transfer badge / done / failed. + @Published var collection: CameraLink.LaneCollectionState /// This lane's own decoder + stall watchdog. The view controller wires its /// `onImage` to set `frames.cameraImage`, and its stall/keyframe callbacks @@ -45,6 +47,7 @@ final class CameraLane: ObservableObject, Identifiable { self.captureOutcome = info.captureOutcome self.isRecording = info.isRecording self.needsQualityRematch = info.needsQualityRematch + self.collection = info.collection } } @@ -91,6 +94,7 @@ final class MulticamViewModel: ObservableObject { if lane.captureOutcome != info.captureOutcome { lane.captureOutcome = info.captureOutcome } if lane.isRecording != info.isRecording { lane.isRecording = info.isRecording } if lane.needsQualityRematch != info.needsQualityRematch { lane.needsQualityRematch = info.needsQualityRematch } + if lane.collection != info.collection { lane.collection = info.collection } return lane } let lane = CameraLane(info: info) diff --git a/RemoteCam/MultipeerService.swift b/RemoteCam/MultipeerService.swift index dfaa7603..b197ec36 100644 --- a/RemoteCam/MultipeerService.swift +++ b/RemoteCam/MultipeerService.swift @@ -21,8 +21,8 @@ protocol MultipeerServiceDelegate: AnyObject { func peerDidConnect(_ peer: MCPeerID) func peerDidDisconnect(_ peer: MCPeerID) func didDetectIncompatibility() - func didStartReceivingResource(name: String, progress: Progress) - func didFinishReceivingResource(name: String, at localURL: URL?, error: Error?) + func didStartReceivingResource(name: String, from peer: MCPeerID, progress: Progress) + func didFinishReceivingResource(name: String, from peer: MCPeerID, at localURL: URL?, error: Error?) func browserDidFindPeer(_ peer: MCPeerID) func browserDidLosePeer(_ peer: MCPeerID) func browserDidFail(_ error: Error) @@ -218,11 +218,11 @@ class MultipeerService: NSObject, MCSessionDelegate, public func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) { - delegate?.didStartReceivingResource(name: resourceName, progress: progress) + delegate?.didStartReceivingResource(name: resourceName, from: peerID, progress: progress) } public func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Error?) { - delegate?.didFinishReceivingResource(name: resourceName, at: localURL, error: error) + delegate?.didFinishReceivingResource(name: resourceName, from: peerID, at: localURL, error: error) } } diff --git a/RemoteCam/RecordingPipeline.swift b/RemoteCam/RecordingPipeline.swift index b91c2689..87bba920 100644 --- a/RemoteCam/RecordingPipeline.swift +++ b/RemoteCam/RecordingPipeline.swift @@ -209,7 +209,10 @@ class RecordingPipeline { let syncMetadata = self?.pendingSyncMetadata PHPhotoLibrary.shared().performChanges({ let options = PHAssetResourceCreationOptions() - options.shouldMoveFile = true + // A multicam clip is COPIED (not moved) so the temp file + // survives the staggered/retried auto-collect transfer to + // the director; the next recording cleans it up. + options.shouldMoveFile = (syncMetadata == nil) // Synced clip: name it under the shared RS_ group. if let syncMetadata { options.originalFilename = syncMetadata.videoFilename() } let creationRequest = PHAssetCreationRequest.forAsset() @@ -218,7 +221,7 @@ class RecordingPipeline { if !success { print("AVCam couldn't save the movie to your photo library: \(String(describing: error))") } - cleanupFileAt(outputFileURL) + if syncMetadata == nil { cleanupFileAt(outputFileURL) } } ) self?.pendingSyncMetadata = nil diff --git a/RemoteCam/RemoteCmdFlatBuffers.swift b/RemoteCam/RemoteCmdFlatBuffers.swift index 2ff3ba77..070e7296 100644 --- a/RemoteCam/RemoteCmdFlatBuffers.swift +++ b/RemoteCam/RemoteCmdFlatBuffers.swift @@ -37,6 +37,7 @@ func serializeToFlatBuffer(_ msg: Message) -> Data? { case let m as RemoteCmd.ScheduledStopRecording: return m.toFlatBuffer() case let m as RemoteCmd.ScheduledRecordingAck: return m.toFlatBuffer() 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() @@ -834,6 +835,15 @@ extension RemoteCmd.ScheduledStopRecording { } } +extension RemoteCmd.RequestVideoResend { + func toFlatBuffer() -> Data { + var fbb = FlatBufferBuilder() + let idOffset = fbb.create(string: captureId) + let params = RemoteShutter_CommandParameters.createCommandParameters(&fbb, captureIdOffset: idOffset) + return buildCommand(&fbb, action: .requestvideoresend, parameters: params) + } +} + extension RemoteCmd.SetStreamProfile { func toFlatBuffer() -> Data { var fbb = FlatBufferBuilder() @@ -1255,6 +1265,9 @@ extension RemoteCmd { bitrateKbps: Int(params?.streamBitrateKbps ?? 0), fps: Int(params?.streamFps ?? 0)) + case .requestvideoresend: + return RequestVideoResend(captureId: params?.captureId ?? "") + case .toggleflash: return ToggleFlash() diff --git a/RemoteCam/RemoteCmds.swift b/RemoteCam/RemoteCmds.swift index 78b1d19b..b8996d88 100644 --- a/RemoteCam/RemoteCmds.swift +++ b/RemoteCam/RemoteCmds.swift @@ -340,6 +340,17 @@ public class RemoteCmd: Message, @unchecked Sendable { } } + /// Director → camera: re-send the clip for `captureId` — the auto-collect + /// retry after a failed transfer. The camera keeps its last multicam clip + /// until collected, so it can honor this. + public class RequestVideoResend: Message, @unchecked Sendable { + public let captureId: String + public init(captureId: String, sender: AnyObject? = nil) { + self.captureId = captureId + super.init(sender: sender) + } + } + public class OnFrame: Message, @unchecked Sendable { public let data: Data public let peerId: MCPeerID diff --git a/RemoteCam/SessionCoordinator.swift b/RemoteCam/SessionCoordinator.swift index 604d9eab..4d871868 100644 --- a/RemoteCam/SessionCoordinator.swift +++ b/RemoteCam/SessionCoordinator.swift @@ -249,6 +249,16 @@ public actor SessionCoordinator { /// saved under its `RS___cam` filename. Nil for ordinary /// single-camera captures, which save exactly as before. private var pendingSyncMetadata: CaptureSyncMetadata? + /// The sync metadata for the multicam clip currently being recorded, used + /// to name its auto-collect transfer (`RS_…cam.mov`) and to stagger the + /// send by camera index. Kept until the next recording so a director retry + /// (`RequestVideoResend`) can re-send from `lastMulticamClipURL`. + private var pendingVideoSyncMetadata: CaptureSyncMetadata? + private var lastMulticamClipURL: URL? + /// Per-camera transfer stagger, so N×4K clips don't all hit the link at once. + private var multicamTransferStaggerSeconds: Double = 2 + /// Test seam. + func setMulticamTransferStaggerForTesting(_ s: Double) { multicamTransferStaggerSeconds = s } /// How far in the past a scheduled fire time may be before the camera /// refuses it (clock error and one retransmit can nudge it slightly late; @@ -1063,9 +1073,9 @@ public actor SessionCoordinator { case let fire as FireScheduledRecordingStart: // The start instant arrived. Stamp the recording with its sync - // metadata, then roll exactly as a normal StartRecordingVideo — - // local save, no auto-transfer to the director in v1. + // metadata (for the QuickTime keys + the RS_ filename), then roll. inMulticamSession = true + pendingVideoSyncMetadata = fire.metadata ctrl.setVideoSyncMetadata(fire.metadata) ctrl.currentCameraMode = .Video ctrl.updateCameraStatus() @@ -1074,16 +1084,16 @@ public actor SessionCoordinator { case let fire as FireScheduledCapture: // The fire instant arrived (enqueued by the off-actor delay task). - // Pull the shutter through the normal photo path, saving locally - // only — the director does not collect stills in v1 — but stamp - // this photo with its sync metadata on the way to Photos. + // Pull the shutter through the normal photo path: save locally + // stamped, AND return the (stamped) still to the director so it + // auto-collects every angle. pendingSyncMetadata = fire.metadata ctrl.currentCameraMode = .Photo ctrl.updateCameraStatus() - ctrl.takePicture(false) + ctrl.takePicture(true) let generation = scheduleTimeout(.cameraTakingPic) await showCameraAlert(NSLocalizedString("Taking picture", comment: "")) - await transition(to: .cameraTakingPic(sendMediaToPeer: false, generation: generation)) + await transition(to: .cameraTakingPic(sendMediaToPeer: true, generation: generation)) case is RemoteCmd.ToggleCamera: do { @@ -1387,15 +1397,15 @@ public actor SessionCoordinator { } case let picture as UICmd.OnPicture: - if let pic = picture.pic { - // A scheduled multicam capture stamps its sync metadata and - // saves under the shared RS___cam name; an - // ordinary capture saves exactly as before. - if let metadata = pendingSyncMetadata { - saveSyncedPictureToLibrary(pic, metadata: metadata) - } else { - photoLibrarySaver(pic) - } + // A scheduled multicam capture stamps the still with its sync + // metadata once; that same stamped image is saved locally under the + // shared RS___cam name AND returned to the director so + // it collects every angle. An ordinary capture saves as before. + let metadata = pendingSyncMetadata + let stampedPic = picture.pic.map { pic in metadata.map { $0.stamped(pic) } ?? pic } + if let pic = stampedPic { + if let metadata { saveSyncedPictureToLibrary(pic, metadata: metadata) } + else { photoLibrarySaver(pic) } } pendingSyncMetadata = nil await dismissCameraAlert() @@ -1405,7 +1415,7 @@ public actor SessionCoordinator { } let resp = RemoteCmd.TakePicResp( sender: nil, - pic: sendMediaToPeer ? picture.pic : nil, + pic: sendMediaToPeer ? stampedPic : nil, error: picture.error) guard sendMessage(resp) else { await popToScanning() @@ -1483,9 +1493,10 @@ public actor SessionCoordinator { fps: UInt32(max(0, profile.fps)))) case is FireScheduledRecordingStop: - // The scheduled stop instant arrived. Save locally only (multicam - // never auto-transfers in v1) and return to the camera screen. - ctrl.stopRecordingVideo(false) + // The scheduled stop instant arrived. Save locally AND push the clip + // to the director so it auto-collects every angle (the existing + // resource-transfer path; QuickTime sync metadata rides in the file). + ctrl.stopRecordingVideo(true) await transition(to: .cameraTransmittingVideo) case let disconnected as DisconnectPeer: @@ -1702,6 +1713,16 @@ public actor SessionCoordinator { case let collect as UICmd.SetMulticamCollecting: multicamCollecting = collect.on + case is RemoteCmd.RequestVideoResend: + // Auto-collect retry: the director's transfer failed. Re-send the + // last multicam clip, which we kept for exactly this. + if let url = lastMulticamClipURL, FileManager.default.fileExists(atPath: url.path), + let name = pendingVideoSyncMetadata?.videoFilename() { + for director in connectedPeers { + await sendVideoResourceNow(url: url, name: name, to: director) + } + } + case is UICmd.PeerTrafficObserved: // Arrives with every inbound message; only a wait cares (see // `inReconnecting`), and it is swallowed here so the rest of the @@ -1844,26 +1865,43 @@ public actor SessionCoordinator { return } - let resourceName = "video_\(UUID().uuidString).mov" + // Multicam auto-collect: name the transfer with the shared RS_ filename + // so the director saves it under the group, remember the clip for a + // possible retry, and stagger the send by camera index so N clips don't + // saturate the link at once. + let multicamMeta = pendingVideoSyncMetadata + let resourceName = multicamMeta?.videoFilename() ?? "video_\(UUID().uuidString).mov" + if multicamMeta != nil { lastMulticamClipURL = sendVideo.videoURL } + let staggerDelay = multicamMeta.map { Double($0.cameraIndex - 1) * multicamTransferStaggerSeconds } ?? 0 tell(UICmd.VideoResourceTransferStarted(totalBytes: fileSize, resourceName: resourceName, sender: nil)) + let url = sendVideo.videoURL for peer in peers { - let sendProgress = multipeerService.sendResource( - at: sendVideo.videoURL, - withName: resourceName, - toPeer: peer - ) { [weak self] error in - if let error { - self?.tell(UICmd.VideoResourceTransferFailed(error: error, resourceName: resourceName, sender: nil)) - } else { - self?.tell(UICmd.VideoResourceTransferCompleted(resourceName: resourceName, success: true, sender: nil)) + if staggerDelay > 0 { + let ns = UInt64(staggerDelay * 1_000_000_000) + Task { [weak self] in + try? await Task.sleep(nanoseconds: ns) + await self?.sendVideoResourceNow(url: url, name: resourceName, to: peer) } + } else { + await sendVideoResourceNow(url: url, name: resourceName, to: peer) } + } + } - if let progress = sendProgress { - trackSendProgress(progress, resourceName: resourceName, into: multipeerService) + /// The actual staggered send of one clip to one peer. + private func sendVideoResourceNow(url: URL, name: String, to peer: MCPeerID) async { + guard let multipeerService else { return } + let sendProgress = multipeerService.sendResource(at: url, withName: name, toPeer: peer) { [weak self] error in + if let error { + self?.tell(UICmd.VideoResourceTransferFailed(error: error, resourceName: name, sender: nil)) + } else { + self?.tell(UICmd.VideoResourceTransferCompleted(resourceName: name, success: true, sender: nil)) } } + if let progress = sendProgress { + trackSendProgress(progress, resourceName: name, into: multipeerService) + } } private nonisolated func trackSendProgress(_ progress: Progress, @@ -2860,7 +2898,9 @@ extension SessionCoordinator: MultipeerServiceDelegate { tell(UICmd.BrowserFailed(error: error)) } - public nonisolated func didStartReceivingResource(name resourceName: String, progress: Progress) { + public nonisolated func didStartReceivingResource(name resourceName: String, from peer: MCPeerID, progress: Progress) { + // `peer` unused: this 1:1 coordinator has one source. The multicam + // director routes by it. debugLog("📥 DEBUG: Started receiving resource: \(resourceName)") guard resourceName.hasPrefix("video_") else { return } @@ -2911,7 +2951,7 @@ extension SessionCoordinator: MultipeerServiceDelegate { .store(in: &service.progressCancellables) } - public nonisolated func didFinishReceivingResource(name resourceName: String, at localURL: URL?, error: Error?) { + public nonisolated func didFinishReceivingResource(name resourceName: String, from peer: MCPeerID, at localURL: URL?, error: Error?) { debugLog("📥 DEBUG: Finished receiving resource: \(resourceName)") if let error { diff --git a/RemoteCam/da.lproj/Localizable.strings b/RemoteCam/da.lproj/Localizable.strings index 3674f372..76b49f1b 100644 --- a/RemoteCam/da.lproj/Localizable.strings +++ b/RemoteCam/da.lproj/Localizable.strings @@ -262,3 +262,4 @@ "Rig Settings" = "Rig-indstillinger"; "%@ can't" = "%@ kan ikke"; "%@ can't do HDR" = "%@ kan ikke HDR"; +"Retry" = "Prøv igen"; diff --git a/RemoteCam/de-DE.lproj/Localizable.strings b/RemoteCam/de-DE.lproj/Localizable.strings index b57edc6e..fbd5ecb8 100644 --- a/RemoteCam/de-DE.lproj/Localizable.strings +++ b/RemoteCam/de-DE.lproj/Localizable.strings @@ -383,3 +383,4 @@ "Rig Settings" = "Rig-Einstellungen"; "%@ can't" = "%@ kann nicht"; "%@ can't do HDR" = "%@ kann kein HDR"; +"Retry" = "Wiederholen"; diff --git a/RemoteCam/en.lproj/Localizable.strings b/RemoteCam/en.lproj/Localizable.strings index 36cfa913..093a9097 100644 --- a/RemoteCam/en.lproj/Localizable.strings +++ b/RemoteCam/en.lproj/Localizable.strings @@ -385,3 +385,4 @@ "Rig Settings" = "Rig Settings"; "%@ can't" = "%@ can't"; "%@ can't do HDR" = "%@ can't do HDR"; +"Retry" = "Retry"; diff --git a/RemoteCam/es-MX.lproj/Localizable.strings b/RemoteCam/es-MX.lproj/Localizable.strings index 230e33df..00a0ec2b 100644 --- a/RemoteCam/es-MX.lproj/Localizable.strings +++ b/RemoteCam/es-MX.lproj/Localizable.strings @@ -277,3 +277,4 @@ "Rig Settings" = "Ajustes del equipo"; "%@ can't" = "%@ no puede"; "%@ can't do HDR" = "%@ no admite HDR"; +"Retry" = "Reintentar"; diff --git a/RemoteCam/fr-FR.lproj/Localizable.strings b/RemoteCam/fr-FR.lproj/Localizable.strings index 84350a83..36f32739 100644 --- a/RemoteCam/fr-FR.lproj/Localizable.strings +++ b/RemoteCam/fr-FR.lproj/Localizable.strings @@ -277,3 +277,4 @@ "Rig Settings" = "Réglages du rig"; "%@ can't" = "%@ ne peut pas"; "%@ can't do HDR" = "%@ ne gère pas le HDR"; +"Retry" = "Réessayer"; diff --git a/RemoteCam/hi.lproj/Localizable.strings b/RemoteCam/hi.lproj/Localizable.strings index 7f202080..a4048da0 100644 --- a/RemoteCam/hi.lproj/Localizable.strings +++ b/RemoteCam/hi.lproj/Localizable.strings @@ -383,3 +383,4 @@ "Rig Settings" = "रिग सेटिंग्स"; "%@ can't" = "%@ नहीं कर सकता"; "%@ can't do HDR" = "%@ HDR नहीं कर सकता"; +"Retry" = "पुनः प्रयास करें"; diff --git a/RemoteCam/it.lproj/Localizable.strings b/RemoteCam/it.lproj/Localizable.strings index 26f2ee29..555992a5 100644 --- a/RemoteCam/it.lproj/Localizable.strings +++ b/RemoteCam/it.lproj/Localizable.strings @@ -262,3 +262,4 @@ "Rig Settings" = "Impostazioni rig"; "%@ can't" = "%@ non può"; "%@ can't do HDR" = "%@ non supporta HDR"; +"Retry" = "Riprova"; diff --git a/RemoteCam/ja.lproj/Localizable.strings b/RemoteCam/ja.lproj/Localizable.strings index fb7bba14..c6766369 100644 --- a/RemoteCam/ja.lproj/Localizable.strings +++ b/RemoteCam/ja.lproj/Localizable.strings @@ -383,3 +383,4 @@ "Rig Settings" = "リグ設定"; "%@ can't" = "%@は非対応"; "%@ can't do HDR" = "%@はHDR非対応"; +"Retry" = "再試行"; diff --git a/RemoteCam/ko.lproj/Localizable.strings b/RemoteCam/ko.lproj/Localizable.strings index 249dd713..1a85801e 100644 --- a/RemoteCam/ko.lproj/Localizable.strings +++ b/RemoteCam/ko.lproj/Localizable.strings @@ -383,3 +383,4 @@ "Rig Settings" = "리그 설정"; "%@ can't" = "%@ 불가"; "%@ can't do HDR" = "%@ HDR 불가"; +"Retry" = "다시 시도"; diff --git a/RemoteCam/ms.lproj/Localizable.strings b/RemoteCam/ms.lproj/Localizable.strings index 8024f22e..f434c870 100644 --- a/RemoteCam/ms.lproj/Localizable.strings +++ b/RemoteCam/ms.lproj/Localizable.strings @@ -383,3 +383,4 @@ "Rig Settings" = "Tetapan rig"; "%@ can't" = "%@ tidak boleh"; "%@ can't do HDR" = "%@ tidak boleh HDR"; +"Retry" = "Cuba lagi"; diff --git a/RemoteCam/pt-BR.lproj/Localizable.strings b/RemoteCam/pt-BR.lproj/Localizable.strings index 5bfaeede..e1fe7221 100644 --- a/RemoteCam/pt-BR.lproj/Localizable.strings +++ b/RemoteCam/pt-BR.lproj/Localizable.strings @@ -383,3 +383,4 @@ "Rig Settings" = "Ajustes do rig"; "%@ can't" = "%@ não pode"; "%@ can't do HDR" = "%@ não faz HDR"; +"Retry" = "Tentar novamente"; diff --git a/RemoteCam/ru.lproj/Localizable.strings b/RemoteCam/ru.lproj/Localizable.strings index 22100246..f6dc8709 100644 --- a/RemoteCam/ru.lproj/Localizable.strings +++ b/RemoteCam/ru.lproj/Localizable.strings @@ -383,3 +383,4 @@ "Rig Settings" = "Настройки рига"; "%@ can't" = "%@ не может"; "%@ can't do HDR" = "%@ не поддерживает HDR"; +"Retry" = "Повторить"; diff --git a/RemoteCam/tr.lproj/Localizable.strings b/RemoteCam/tr.lproj/Localizable.strings index c5967e92..5a412c74 100644 --- a/RemoteCam/tr.lproj/Localizable.strings +++ b/RemoteCam/tr.lproj/Localizable.strings @@ -383,3 +383,4 @@ "Rig Settings" = "Rig ayarları"; "%@ can't" = "%@ yapamaz"; "%@ can't do HDR" = "%@ HDR yapamaz"; +"Retry" = "Tekrar dene"; diff --git a/RemoteCam/vi.lproj/Localizable.strings b/RemoteCam/vi.lproj/Localizable.strings index 32287801..5311f1bc 100644 --- a/RemoteCam/vi.lproj/Localizable.strings +++ b/RemoteCam/vi.lproj/Localizable.strings @@ -383,3 +383,4 @@ "Rig Settings" = "Cài đặt rig"; "%@ can't" = "%@ không thể"; "%@ can't do HDR" = "%@ không hỗ trợ HDR"; +"Retry" = "Thử lại"; diff --git a/RemoteCam/zh-Hans.lproj/Localizable.strings b/RemoteCam/zh-Hans.lproj/Localizable.strings index f25e0a63..528ce42a 100644 --- a/RemoteCam/zh-Hans.lproj/Localizable.strings +++ b/RemoteCam/zh-Hans.lproj/Localizable.strings @@ -383,3 +383,4 @@ "Rig Settings" = "多机位设置"; "%@ can't" = "%@ 不支持"; "%@ can't do HDR" = "%@ 不支持 HDR"; +"Retry" = "重试"; diff --git a/RemoteCamTests/MulticamControllerTests.swift b/RemoteCamTests/MulticamControllerTests.swift index feb0f7c5..8a7297dc 100644 --- a/RemoteCamTests/MulticamControllerTests.swift +++ b/RemoteCamTests/MulticamControllerTests.swift @@ -603,6 +603,57 @@ final class MulticamControllerTests: XCTestCase { XCTAssertEqual(q?.hdrMode, .on) } + // MARK: - Auto-collect + + func testVideoResourceTransferUpdatesLaneStateAndSaves() async { + let (controller, _, _) = await makeController(peers: [camA, camB]) + let progress = Progress(totalUnitCount: 100) + + controller.didStartReceivingResource(name: "RS_a_b_cam1.mov", from: camA, progress: progress) + await controller.waitForIdle() + var stateA = await controller.collectionStateForTesting(camA) + XCTAssertEqual(stateA, .transferring(0)) + + let url = FileManager.default.temporaryDirectory.appendingPathComponent("clip.mov") + controller.didFinishReceivingResource(name: "RS_a_b_cam1.mov", from: camA, at: url, error: nil) + await controller.waitForIdle() + stateA = await controller.collectionStateForTesting(camA) + XCTAssertEqual(stateA, .collected) + // camB untouched. + let stateB = await controller.collectionStateForTesting(camB) + XCTAssertEqual(stateB, .idle) + } + + func testFailedTransferMarksLaneAndRetryReRequests() async { + let (controller, transport, _) = await makeController(peers: [camA, camB]) + controller.didFinishReceivingResource( + name: "RS_a_b_cam1.mov", from: camA, at: nil, + error: NSError(domain: "x", code: 1)) + await controller.waitForIdle() + let failed = await controller.collectionStateForTesting(camA) + XCTAssertEqual(failed, .failed) + + transport.sentMessages.removeAll() + await controller.retryCollection(for: camA) + await controller.waitForIdle() + // Re-request goes to that camera, and its lane returns to transferring. + let resends = sent(transport, RemoteCmd.RequestVideoResend.self) + XCTAssertEqual(resends.map(\.peers), [[camA]]) + let retrying = await controller.collectionStateForTesting(camA) + XCTAssertEqual(retrying, .transferring(0)) + } + + func testReturnedPhotoMarksLaneCollected() async { + let (controller, _, _) = await makeController(peers: [camA, camB]) + // A returned still (non-nil pic, no error) is collected on the director. + let jpeg = Data([0xFF, 0xD8, 0xFF, 0xE0, 0, 0, 0, 0]) + controller.didReceiveMessage( + RemoteCmd.TakePicResp(sender: nil, pic: jpeg, error: nil), from: camB) + await controller.waitForIdle() + let state = await controller.collectionStateForTesting(camB) + XCTAssertEqual(state, .collected) + } + // MARK: - Fixtures private func multicamCaps() -> RemoteCmd.CameraCapabilitiesResp { diff --git a/RemoteCamTests/MulticamViewModelTests.swift b/RemoteCamTests/MulticamViewModelTests.swift index c639f469..d94f37d7 100644 --- a/RemoteCamTests/MulticamViewModelTests.swift +++ b/RemoteCamTests/MulticamViewModelTests.swift @@ -18,7 +18,8 @@ final class MulticamViewModelTests: XCTestCase { focused: Bool = false) -> MulticamLaneInfo { MulticamLaneInfo(peerID: peer, displayName: peer.displayName, status: status, isFocused: focused, clockOffsetMillis: nil, - captureOutcome: nil, isRecording: false, needsQualityRematch: false) + captureOutcome: nil, isRecording: false, needsQualityRematch: false, + collection: .idle) } func testApplyAddsLanesAndReportsCreated() { diff --git a/RemoteCamTests/RemoteCamSessionTests.swift b/RemoteCamTests/RemoteCamSessionTests.swift index ae07e59e..ee77a8b8 100644 --- a/RemoteCamTests/RemoteCamSessionTests.swift +++ b/RemoteCamTests/RemoteCamSessionTests.swift @@ -397,8 +397,8 @@ class SessionCoordinatorTests: XCTestCase { for _ in 0..<200 where camera.takePictureCalls.isEmpty { try? await Task.sleep(nanoseconds: 10_000_000) } - XCTAssertEqual(camera.takePictureCalls, [false], - "the scheduled shutter fires, saving locally (not to the director)") + XCTAssertEqual(camera.takePictureCalls, [true], + "the scheduled shutter fires, saving locally AND returning the still to the director") } // MARK: - Resilient camera (multicam only) @@ -485,7 +485,8 @@ class SessionCoordinatorTests: XCTestCase { for _ in 0..<200 where camera.stopRecordingCalls.isEmpty { try? await Task.sleep(nanoseconds: 10_000_000) } - XCTAssertEqual(camera.stopRecordingCalls, [false], "scheduled stop saves locally") + XCTAssertEqual(camera.stopRecordingCalls, [true], + "scheduled stop saves locally AND pushes the clip to the director") } func testMonitorPhotoModeUnbecomeMonitorPopsToConnected() async { diff --git a/RemoteCamTests/RemoteCmdSerializationTests.swift b/RemoteCamTests/RemoteCmdSerializationTests.swift index 40fd68e5..e154d147 100644 --- a/RemoteCamTests/RemoteCmdSerializationTests.swift +++ b/RemoteCamTests/RemoteCmdSerializationTests.swift @@ -55,6 +55,7 @@ final class RemoteCmdSerializationTests: XCTestCase { case let m as RemoteCmd.ScheduledStopRecording: return m.toFlatBuffer() case let m as RemoteCmd.ScheduledRecordingAck: return m.toFlatBuffer() 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() @@ -1270,6 +1271,11 @@ extension RemoteCmdSerializationTests { XCTAssertEqual(result.fps, 20) } + func testRequestVideoResend_roundTrip() { + let result = roundTrip(RemoteCmd.RequestVideoResend(captureId: "R7")) + XCTAssertEqual(result.captureId, "R7") + } + /// The multicam capability survives the wire, and a peer that predates it /// (absent field) decodes as not-multicam-capable. func testCapabilitiesCarryMulticamSupport() { diff --git a/RemoteCamTests/StormoLoopbackTests.swift b/RemoteCamTests/StormoLoopbackTests.swift index 4c4928ad..0b424b36 100644 --- a/RemoteCamTests/StormoLoopbackTests.swift +++ b/RemoteCamTests/StormoLoopbackTests.swift @@ -67,8 +67,8 @@ final class StormoLoopbackTests: XCTestCase { func didReceiveFrameRequest(_ request: RemoteCmd.RequestFrame) {} func didReceiveFrame(_ frame: RemoteCmd.SendFrame, from peer: PeerID) {} func didDetectIncompatibility() {} - func didStartReceivingResource(name: String, progress: Progress) {} - func didFinishReceivingResource(name: String, at localURL: URL?, error: Error?) {} + func didStartReceivingResource(name: String, from peer: PeerID, progress: Progress) {} + func didFinishReceivingResource(name: String, from peer: PeerID, at localURL: URL?, error: Error?) {} func browserDidLosePeer(_ peer: PeerID) {} func browserDidFail(_ error: Error) {} func advertiserDidFail(_ error: Error) {} From c3b2a2698f33f6e86941a241045e744dc2b69300 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Thu, 13 Aug 2026 00:19:32 -0700 Subject: [PATCH 14/17] Multicam polish 1/4: single-entry actor (all UI commands through the inbox) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MulticamController is now single-entry like SessionCoordinator: every UI intent is a message on the same FIFO AsyncStream inbox as transport events, so there is one arrival-ordered stream and one place state changes — the pump. - Public UI commands become `nonisolated func x() { tell(MCX()) }`; the pump's `handle` switch gains the cases and calls the (now private) `handleX`/`perform` impls. Idempotence guards stay in the pump (capturePhoto no-ops unless monitoring). `install`/`setDisplay` stay direct (pre-pump setup); `cameraCount` stays a direct read (a pure query, not a command). - VC call sites drop their `Task { await … }` wrappers — the sends are plain synchronous `controller.x()` now (shutter, focus, invite, quality, timer, retry, stall/keyframe recovery). - The side Tasks (reconnect re-browse, timer countdown) no longer mutate the actor directly: they run a one-shot sleep and then `tell` their tick (MCPeerCommand(.reconnectTick) / MCTimerAdvance), so the mutation happens in the pump, in order, and `waitForIdle` sees it. The timer is now a pump-driven countdown (`countdown` state + `advanceCountdown`); the timer test drives ticks deterministically via `advanceTimerForTesting()` instead of polling. Full suite green (no behavior change; single-cam untouched). Co-Authored-By: Claude Fable 5 --- RemoteCam/MulticamController.swift | 200 +++++++++++++++---- RemoteCam/MulticamViewController.swift | 54 ++--- RemoteCamTests/MulticamControllerTests.swift | 76 ++++--- 3 files changed, 220 insertions(+), 110 deletions(-) diff --git a/RemoteCam/MulticamController.swift b/RemoteCam/MulticamController.swift index 5dad0545..054c17fd 100644 --- a/RemoteCam/MulticamController.swift +++ b/RemoteCam/MulticamController.swift @@ -174,6 +174,8 @@ public actor MulticamController { /// One rig self-timer (seconds); 0 = off. Fans out to every camera so /// subjects see the countdown, and its expiry triggers the synced capture. private var rigTimerSeconds: Int = 0 + /// The live countdown, or nil when not counting down. + private var countdown: (remaining: Int, action: MulticamTimedAction)? /// The countdown tick interval — injectable so tests don't wait real seconds. private var timerTickInterval: TimeInterval = 1 private nonisolated let timerTask = Locked?>(nil) @@ -282,6 +284,27 @@ public actor MulticamController { case let measured as ClockPongMeasured: storePong(measured.pong, t3: measured.t3, from: measured.peer) + // --- UI commands (single-entry inbox) --- + case is MCCapturePhoto: handleCapturePhoto() + case is MCStartRecording: handleStartRecording() + case is MCStopRecording: handleStopRecording() + case is MCAutomaticVideoQuality: handleAutomaticVideoQuality() + case is MCAutomaticPhotoQuality: handleAutomaticPhotoQuality() + case is MCTimerAdvance: advanceCountdown() + case let q as MCSetVideoQuality: handleSetVideoQuality(resolution: q.resolution, frameRate: q.frameRate) + case let q as MCSetPhotoQuality: handleSetPhotoQuality(format: q.format, hdr: q.hdr) + case let t as MCSetRigTimer: handleSetRigTimer(t.seconds) + case let c as MCPeerCommand: + switch c.kind { + case .focus: handleSetFocusedPeer(c.peer) + case .invite: handleInviteCamera(c.peer) + case .remove: handleRemoveCamera(c.peer) + case .retryCollection: handleRetryCollection(c.peer) + case .nudgeFrame: handleNudgeFrame(c.peer) + case .requestKeyframe: handleRequestKeyframe(c.peer) + case .reconnectTick: reBrowseIfStillMissing(c.peer) + } + case is UICmd.AppForegrounded: // Clocks freeze while backgrounded; the estimates are stale. Drop // them and re-measure, mirroring the frame path's foreground rearm. @@ -404,21 +427,24 @@ public actor MulticamController { /// Invite a discovered camera into the rig (the "add camera" flow). The /// tier cap is enforced by the UI before this is called. - func inviteCamera(_ peer: MCPeerID) { + public nonisolated func inviteCamera(_ peer: MCPeerID) { tell(MCPeerCommand(.invite, peer)) } + + private func handleInviteCamera(_ peer: MCPeerID) { guard available.contains(peer) else { return } multipeerService?.invitePeer(peer, timeout: reconnectInviteTimeout) } - /// The current number of cameras in the rig — the UI checks this against - /// `StoreManager.maxCameras()` before offering to add another. + /// The current number of cameras in the rig — a pure query the UI reads + /// (directly, not through the inbox) to gate the add-camera paywall. func cameraCount() -> Int { order.count } + /// Schedule a reconnect tick; the tick itself runs in the pump (ordered), + /// so the only thing off-actor is the one-shot sleep. private func armReconnect(_ peer: MCPeerID) { let delay = reconnectRetryDelay Task { [weak self] in try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - guard let self else { return } - await self.reBrowseIfStillMissing(peer) + self?.tell(MCPeerCommand(.reconnectTick, peer)) } } @@ -451,7 +477,9 @@ public actor MulticamController { func switchLens(_ lens: CameraLensType) { focusedSend(RemoteCmd.SwitchLens(lensType: lens)) } - func setFocusedPeer(_ peer: MCPeerID) { + public nonisolated func setFocusedPeer(_ peer: MCPeerID) { tell(MCPeerCommand(.focus, peer)) } + + private func handleSetFocusedPeer(_ peer: MCPeerID) { guard links[peer] != nil else { return } focusedPeer = peer // Retier previews: the newly focused camera goes full-size, the rest @@ -484,7 +512,9 @@ public actor MulticamController { /// per-peer teardown, so this stops the lane (no more acks/handshakes) and /// drops it from the UI; the peer times out on its side. A true per-peer /// disconnect needs a transport API and is out of scope here. - func removeCamera(_ peer: MCPeerID) { + public nonisolated func removeCamera(_ peer: MCPeerID) { tell(MCPeerCommand(.remove, peer)) } + + private func handleRemoveCamera(_ peer: MCPeerID) { links[peer] = nil order.removeAll { $0 == peer } if focusedPeer == peer { focusedPeer = order.first } @@ -572,9 +602,11 @@ public actor MulticamController { /// set, run one director-side countdown first (fanned out so subjects see /// it), then fire. Scheduled at a shared instant when every clock offset is /// known; else a plain `TakePic` fan-out. No-op unless idle with a camera. - func capturePhoto() { + public nonisolated func capturePhoto() { tell(MCCapturePhoto()) } + + private func handleCapturePhoto() { guard rigTimerSeconds > 0 else { performCapturePhoto(); return } - startCountdown(then: .photo) + beginCountdown(.photo) } private func performCapturePhoto() { @@ -597,9 +629,11 @@ public actor MulticamController { } /// Start a synced recording on every ready multicam camera (timer-gated). - func startRecording() { + public nonisolated func startRecording() { tell(MCStartRecording()) } + + private func handleStartRecording() { guard rigTimerSeconds > 0 else { performStartRecording(); return } - startCountdown(then: .record) + beginCountdown(.record) } private func performStartRecording() { @@ -623,7 +657,9 @@ public actor MulticamController { /// Stop the synced recording on every rolling camera, anchored so the clips /// end together. - func stopRecording() { + public nonisolated func stopRecording() { tell(MCStopRecording()) } + + private func handleStopRecording() { guard case .recording(_, _) = state else { return } let rolling = order.compactMap { links[$0] }.filter { $0.isRecording } guard !rolling.isEmpty else { return } @@ -645,6 +681,10 @@ public actor MulticamController { /// Test seams. func setTimerTickInterval(_ t: TimeInterval) { timerTickInterval = t } + /// Advance the rig countdown one tick deterministically (production uses the + /// one-shot Task; tests set a large interval and drive with this instead). + func advanceTimerForTesting() { advanceCountdown() } + func countdownRemainingForTesting() -> Int? { countdown?.remaining } func activeVideoQualityForTesting() -> (VideoResolution, VideoFrameRate)? { activeVideoQuality.map { ($0.resolution, $0.frameRate) } } @@ -664,7 +704,11 @@ public actor MulticamController { /// Manual video pick — fans the chosen quality to every lane and records it /// as the active rig setting (first-class; not a mode you leave). - func setVideoQuality(resolution: VideoResolution, frameRate: VideoFrameRate) { + public nonisolated func setVideoQuality(resolution: VideoResolution, frameRate: VideoFrameRate) { + tell(MCSetVideoQuality(resolution, frameRate)) + } + + private func handleSetVideoQuality(resolution: VideoResolution, frameRate: VideoFrameRate) { activeVideoQuality = (resolution, frameRate) for peer in order { sendTo(peer, RemoteCmd.SetVideoQuality(resolution: resolution, frameRate: frameRate)) @@ -673,7 +717,11 @@ public actor MulticamController { publishLanes() } - func setPhotoQuality(format: PhotoFormat, hdr: HDRMode) { + public nonisolated func setPhotoQuality(format: PhotoFormat, hdr: HDRMode) { + tell(MCSetPhotoQuality(format, hdr)) + } + + private func handleSetPhotoQuality(format: PhotoFormat, hdr: HDRMode) { activePhotoQuality = (format, hdr) for peer in order { sendTo(peer, RemoteCmd.SetPhotoQuality(format: format, hdrMode: hdr)) @@ -682,14 +730,18 @@ public actor MulticamController { } /// "Automatic" / re-match: recompute best-in-intersection and apply it. - func applyAutomaticVideoQuality() { + public nonisolated func applyAutomaticVideoQuality() { tell(MCAutomaticVideoQuality()) } + + private func handleAutomaticVideoQuality() { let auto = rigQualityMenu().automaticVideo() - setVideoQuality(resolution: auto.resolution, frameRate: auto.frameRate) + handleSetVideoQuality(resolution: auto.resolution, frameRate: auto.frameRate) } - func applyAutomaticPhotoQuality() { + public nonisolated func applyAutomaticPhotoQuality() { tell(MCAutomaticPhotoQuality()) } + + private func handleAutomaticPhotoQuality() { let auto = rigQualityMenu().automaticPhoto() - setPhotoQuality(format: auto.format, hdr: auto.hdr) + handleSetPhotoQuality(format: auto.format, hdr: auto.hdr) } /// After caps change (new lane, device switch), flag any lane that can't @@ -706,37 +758,50 @@ public actor MulticamController { } } - // MARK: - Rig self-timer + // MARK: - Rig self-timer (inbox-driven countdown) - func setRigTimer(_ seconds: Int) { - rigTimerSeconds = max(0, seconds) + public nonisolated func setRigTimer(_ seconds: Int) { tell(MCSetRigTimer(max(0, seconds))) } + + private func handleSetRigTimer(_ seconds: Int) { + rigTimerSeconds = seconds publishRigSettings() } - private enum TimedAction { case photo, record } - - /// Run a director-side countdown, fanned out to every camera so subjects see - /// it, then fire the synced capture at zero. - private func startCountdown(then action: TimedAction) { - timerTask.value?.cancel() - let total = rigTimerSeconds - let interval = timerTickInterval - timerTask.value = Task { [weak self] in - for remaining in stride(from: total, through: 1, by: -1) { - if Task.isCancelled { return } - await self?.fanOutTimerTick(remaining) - try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + /// Begin a director-side countdown, fanned out to every camera so subjects + /// see it, then fire the synced capture at zero. Each tick is a `tell` on + /// the inbox (so it is ordered with everything else and visible to + /// `waitForIdle`); the only Task is a one-shot sleep between ticks. + private func beginCountdown(_ action: MulticamTimedAction) { + countdown = (remaining: rigTimerSeconds, action: action) + fanOutTimerTick(rigTimerSeconds) + scheduleNextTick() + } + + /// Advance one tick (pump-driven). Fires the capture at zero. + private func advanceCountdown() { + guard let c = countdown else { return } + let remaining = c.remaining - 1 + fanOutTimerTick(remaining) + if remaining > 0 { + countdown = (remaining: remaining, action: c.action) + scheduleNextTick() + } else { + countdown = nil + switch c.action { + case .photo: performCapturePhoto() + case .record: performStartRecording() } - if Task.isCancelled { return } - await self?.fireTimed(action) } } - private func fireTimed(_ action: TimedAction) { - fanOutTimerTick(0) - switch action { - case .photo: performCapturePhoto() - case .record: performStartRecording() + /// One-shot: after `timerTickInterval`, enqueue the next tick. Production + /// only — tests drive `advanceCountdown` directly via `advanceTimerForTesting`. + private func scheduleNextTick() { + let interval = timerTickInterval + timerTask.value?.cancel() + timerTask.value = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + self?.tell(MCTimerAdvance()) } } @@ -901,7 +966,9 @@ public actor MulticamController { } /// Re-request a failed lane's footage (the camera still holds it). - func retryCollection(for peer: MCPeerID) { + public nonisolated func retryCollection(for peer: MCPeerID) { tell(MCPeerCommand(.retryCollection, peer)) } + + private func handleRetryCollection(_ peer: MCPeerID) { guard let link = links[peer], link.collection == .failed else { return } link.collection = .transferring(0) publishLanes() @@ -941,7 +1008,9 @@ public actor MulticamController { /// A lane's stall watchdog fired — re-request a frame to unstick just that /// camera's pump (the others are unaffected). - func nudgeFrame(for peer: MCPeerID) { + public nonisolated func nudgeFrame(for peer: MCPeerID) { tell(MCPeerCommand(.nudgeFrame, peer)) } + + private func handleNudgeFrame(_ peer: MCPeerID) { guard links[peer] != nil else { return } sendTo(peer, RemoteCmd.RequestFrame(sender: nil)) } @@ -949,7 +1018,9 @@ public actor MulticamController { /// A lane's decoder desynced — force a keyframe, but only from a camera /// that has proven it speaks VP9 (else an old peer reads the unknown /// action as TakePicture). Mirrors the 1:1 `requestKeyframeIfVP9` gate. - func requestKeyframe(for peer: MCPeerID) { + public nonisolated func requestKeyframe(for peer: MCPeerID) { tell(MCPeerCommand(.requestKeyframe, peer)) } + + private func handleRequestKeyframe(_ peer: MCPeerID) { guard links[peer]?.sawVP9 == true else { return } sendTo(peer, RemoteCmd.RequestKeyframe(sender: nil), mode: .reliable) } @@ -1142,3 +1213,44 @@ final class ResourceTransferFinished: Message, @unchecked Sendable { super.init(sender: nil) } } + +// MARK: - Director UI-command messages (single-entry inbox) +// +// Every UI intent is a message on the same FIFO inbox as transport events, so +// there is one arrival-ordered stream and one place state changes — the pump. +// (`install`/`setDisplay` stay direct: they run before the pump matters.) + +enum MulticamTimedAction { case photo, record } + +final class MCCapturePhoto: Message, @unchecked Sendable {} +final class MCStartRecording: Message, @unchecked Sendable {} +final class MCStopRecording: Message, @unchecked Sendable {} +final class MCAutomaticVideoQuality: Message, @unchecked Sendable {} +final class MCAutomaticPhotoQuality: Message, @unchecked Sendable {} +final class MCTimerAdvance: Message, @unchecked Sendable {} + +final class MCPeerCommand: Message, @unchecked Sendable { + enum Kind { case focus, invite, remove, retryCollection, nudgeFrame, requestKeyframe, reconnectTick } + let kind: Kind + let peer: MCPeerID + init(_ kind: Kind, _ peer: MCPeerID) { self.kind = kind; self.peer = peer; super.init(sender: nil) } +} + +final class MCSetVideoQuality: Message, @unchecked Sendable { + let resolution: VideoResolution + let frameRate: VideoFrameRate + init(_ resolution: VideoResolution, _ frameRate: VideoFrameRate) { + self.resolution = resolution; self.frameRate = frameRate; super.init(sender: nil) + } +} + +final class MCSetPhotoQuality: Message, @unchecked Sendable { + let format: PhotoFormat + let hdr: HDRMode + init(_ format: PhotoFormat, _ hdr: HDRMode) { self.format = format; self.hdr = hdr; super.init(sender: nil) } +} + +final class MCSetRigTimer: Message, @unchecked Sendable { + let seconds: Int + init(_ seconds: Int) { self.seconds = seconds; super.init(sender: nil) } +} diff --git a/RemoteCam/MulticamViewController.swift b/RemoteCam/MulticamViewController.swift index eaf11e64..cf7ba009 100644 --- a/RemoteCam/MulticamViewController.swift +++ b/RemoteCam/MulticamViewController.swift @@ -40,16 +40,13 @@ public final class MulticamViewController: UIViewController { super.viewDidLoad() view.backgroundColor = .black + // The controller's UI commands are `nonisolated` (they `tell` the + // single inbox), so the call sites are plain synchronous sends — no + // `Task { await }` wrappers. let multicamView = MulticamView( viewModel: viewModel, - onFocusLane: { [weak self] lane in - guard let self else { return } - Task { await self.controller.setFocusedPeer(lane.peerID) } - }, - onShutter: { [weak self] in - guard let self else { return } - self.triggerShutter() - }, + onFocusLane: { [weak self] lane in self?.controller.setFocusedPeer(lane.peerID) }, + onShutter: { [weak self] in self?.triggerShutter() }, onToggleMode: { [weak self] in guard let self, !self.viewModel.isRecording else { return } self.viewModel.mode = self.viewModel.mode == .photo ? .video : .photo @@ -58,30 +55,23 @@ public final class MulticamViewController: UIViewController { onInviteCamera: { [weak self] peer in guard let self else { return } self.viewModel.showingAddCamera = false - Task { await self.controller.inviteCamera(peer) } - }, - onSetTimer: { [weak self] seconds in - Task { await self?.controller.setRigTimer(seconds) } + self.controller.inviteCamera(peer) }, + onSetTimer: { [weak self] seconds in self?.controller.setRigTimer(seconds) }, onSelectVideoQuality: { [weak self] res, fps in - Task { await self?.controller.setVideoQuality(resolution: res, frameRate: fps) } - }, - onAutomaticVideoQuality: { [weak self] in - Task { await self?.controller.applyAutomaticVideoQuality() } + self?.controller.setVideoQuality(resolution: res, frameRate: fps) }, + onAutomaticVideoQuality: { [weak self] in self?.controller.applyAutomaticVideoQuality() }, onSetPhotoFormat: { [weak self] format in - Task { await self?.controller.setPhotoQuality( - format: format, - hdr: self?.viewModel.rigSettings.activeHDR ?? .off) } + self?.controller.setPhotoQuality(format: format, + hdr: self?.viewModel.rigSettings.activeHDR ?? .off) }, onSetHDR: { [weak self] on in - Task { await self?.controller.setPhotoQuality( + self?.controller.setPhotoQuality( format: self?.viewModel.rigSettings.activePhotoFormat ?? .jpeg, - hdr: on ? .on : .off) } + hdr: on ? .on : .off) }, - onRetryCollection: { [weak self] lane in - Task { await self?.controller.retryCollection(for: lane.peerID) } - }) + onRetryCollection: { [weak self] lane in self?.controller.retryCollection(for: lane.peerID) }) hosting = embedSwiftUIView(multicamView) Task { await controller.setDisplay(self) } @@ -127,14 +117,10 @@ public final class MulticamViewController: UIViewController { /// Route the shutter: a photo, or record start/stop, per the current mode. private func triggerShutter() { - let mode = viewModel.mode - let recording = viewModel.isRecording - Task { - switch (mode, recording) { - case (.photo, _): await controller.capturePhoto() - case (.video, false): await controller.startRecording() - case (.video, true): await controller.stopRecording() - } + switch (viewModel.mode, viewModel.isRecording) { + case (.photo, _): controller.capturePhoto() + case (.video, false): controller.startRecording() + case (.video, true): controller.stopRecording() } } @@ -159,10 +145,10 @@ public final class MulticamViewController: UIViewController { OperationQueue.main.addOperation { lane?.frames.cameraImage = image } } lane.receiver.onStall = { [weak self] in - Task { await self?.controller.nudgeFrame(for: peer) } + self?.controller.nudgeFrame(for: peer) } lane.receiver.onKeyframeNeeded = { [weak self] in - Task { await self?.controller.requestKeyframe(for: peer) } + self?.controller.requestKeyframe(for: peer) } lane.receiver.start() } diff --git a/RemoteCamTests/MulticamControllerTests.swift b/RemoteCamTests/MulticamControllerTests.swift index 8a7297dc..6fbc0f09 100644 --- a/RemoteCamTests/MulticamControllerTests.swift +++ b/RemoteCamTests/MulticamControllerTests.swift @@ -111,7 +111,8 @@ final class MulticamControllerTests: XCTestCase { func testPerCameraCommandTargetsOnlyTheFocusedPeer() async { let (controller, transport, _) = await makeController(peers: [camA, camB]) - await controller.setFocusedPeer(camB) + controller.setFocusedPeer(camB) + await controller.waitForIdle() transport.sentMessages.removeAll() await controller.setZoom(2.0) @@ -179,7 +180,7 @@ final class MulticamControllerTests: XCTestCase { await controller.seedLaneForTesting(camB, supportsMulticam: true, offsetMillis: -50) transport.sentMessages.removeAll() - await controller.capturePhoto() + controller.capturePhoto() await controller.waitForIdle() let scheduled = sent(transport, RemoteCmd.ScheduledCapture.self) @@ -208,7 +209,7 @@ final class MulticamControllerTests: XCTestCase { await controller.seedLaneForTesting(camB, supportsMulticam: false, offsetMillis: 10) transport.sentMessages.removeAll() - await controller.capturePhoto() + controller.capturePhoto() await controller.waitForIdle() let scheduled = sent(transport, RemoteCmd.ScheduledCapture.self) @@ -222,7 +223,7 @@ final class MulticamControllerTests: XCTestCase { await controller.seedLaneForTesting(camA, supportsMulticam: true, offsetMillis: 0) await controller.seedLaneForTesting(camB, supportsMulticam: true, offsetMillis: 0) - await controller.capturePhoto() + controller.capturePhoto() await controller.waitForIdle() let captureID = await controller.captureStateForTesting()?.id XCTAssertNotNil(captureID) @@ -248,7 +249,7 @@ final class MulticamControllerTests: XCTestCase { await controller.seedLaneForTesting(camA, supportsMulticam: true, offsetMillis: 0) await controller.seedLaneForTesting(camB, supportsMulticam: true, offsetMillis: 0) - await controller.capturePhoto() + controller.capturePhoto() await controller.waitForIdle() let captureID = await controller.captureStateForTesting()?.id controller.didReceiveMessage(RemoteCmd.ScheduledCaptureAck(captureId: captureID!), from: camA) @@ -270,7 +271,7 @@ final class MulticamControllerTests: XCTestCase { await controller.seedLaneForTesting(camB, supportsMulticam: true, offsetMillis: nil) transport.sentMessages.removeAll() - await controller.capturePhoto() + controller.capturePhoto() await controller.waitForIdle() XCTAssertTrue(sent(transport, RemoteCmd.ScheduledCapture.self).isEmpty, @@ -307,13 +308,13 @@ final class MulticamControllerTests: XCTestCase { await controller.waitForIdle() transport.invitedPeers.removeAll() - await controller.inviteCamera(camC) + controller.inviteCamera(camC) await controller.waitForIdle() XCTAssertEqual(transport.invitedPeers.map(\.peer), [camC]) // Inviting a peer that was never discovered does nothing. transport.invitedPeers.removeAll() - await controller.inviteCamera(MCPeerID(displayName: "Ghost")) + controller.inviteCamera(MCPeerID(displayName: "Ghost")) await controller.waitForIdle() XCTAssertTrue(transport.invitedPeers.isEmpty) } @@ -393,7 +394,7 @@ final class MulticamControllerTests: XCTestCase { await controller.seedLaneForTesting(camB, supportsMulticam: true, offsetMillis: -50) transport.sentMessages.removeAll() - await controller.startRecording() + controller.startRecording() await controller.waitForIdle() let starts = sent(transport, RemoteCmd.ScheduledStartRecording.self) func startFire(_ p: MCPeerID) -> UInt64 { @@ -407,7 +408,7 @@ final class MulticamControllerTests: XCTestCase { await controller.waitForIdle() transport.sentMessages.removeAll() - await controller.stopRecording() + controller.stopRecording() await controller.waitForIdle() let stops = sent(transport, RemoteCmd.ScheduledStopRecording.self) func stopFire(_ p: MCPeerID) -> UInt64 { @@ -430,7 +431,7 @@ final class MulticamControllerTests: XCTestCase { await controller.seedLaneForTesting(camA, supportsMulticam: true, offsetMillis: 0) await controller.seedLaneForTesting(camB, supportsMulticam: true, offsetMillis: 0) - await controller.startRecording() + controller.startRecording() await controller.waitForIdle() let recID = await controller.recordingStateForTesting()?.id XCTAssertNotNil(recID) @@ -448,7 +449,7 @@ final class MulticamControllerTests: XCTestCase { XCTAssertEqual(stillRecording?.remaining, 0) // Stop resolves back to monitoring. - await controller.stopRecording() + controller.stopRecording() await controller.waitForIdle() let stopID = await controller.stoppingStateForTesting()?.id controller.didReceiveMessage(RemoteCmd.ScheduledRecordingAck(captureId: stopID!, isStop: true), from: camA) @@ -467,7 +468,8 @@ final class MulticamControllerTests: XCTestCase { func testRemoveCameraDropsTheLaneAndRefocuses() async { let (controller, _, _) = await makeController(peers: [camA, camB]) - await controller.removeCamera(camA) + controller.removeCamera(camA) + await controller.waitForIdle() let lanes = await controller.lanesForTesting() XCTAssertEqual(lanes.map(\.peerID), [camB]) let focusedAfter = await controller.focusedPeerForTesting() @@ -499,7 +501,7 @@ final class MulticamControllerTests: XCTestCase { let (controller, transport, _) = await makeController(peers: [camA, camB]) transport.sentMessages.removeAll() - await controller.setVideoQuality(resolution: .uhd4k, frameRate: .fps30) + controller.setVideoQuality(resolution: .uhd4k, frameRate: .fps30) await controller.waitForIdle() let sends = sent(transport, RemoteCmd.SetVideoQuality.self) @@ -522,8 +524,8 @@ final class MulticamControllerTests: XCTestCase { await controller.waitForIdle() transport.sentMessages.removeAll() - await controller.setVideoQuality(resolution: .hd1080p, frameRate: .fps30) - await controller.setVideoQuality(resolution: .uhd4k, frameRate: .fps30) + controller.setVideoQuality(resolution: .hd1080p, frameRate: .fps30) + controller.setVideoQuality(resolution: .uhd4k, frameRate: .fps30) await controller.waitForIdle() let sends = sent(transport, RemoteCmd.SetVideoQuality.self) @@ -539,7 +541,7 @@ final class MulticamControllerTests: XCTestCase { await controller.waitForIdle() transport.sentMessages.removeAll() - await controller.applyAutomaticVideoQuality() + controller.applyAutomaticVideoQuality() await controller.waitForIdle() let sends = sent(transport, RemoteCmd.SetVideoQuality.self) @@ -555,7 +557,7 @@ final class MulticamControllerTests: XCTestCase { controller.didReceiveMessage(capsWith(full4K), from: camB) await controller.waitForIdle() // Rig set to 4K30 (both can). - await controller.setVideoQuality(resolution: .uhd4k, frameRate: .fps30) + controller.setVideoQuality(resolution: .uhd4k, frameRate: .fps30) await controller.waitForIdle() let flaggedBefore = await controller.needsRematchForTesting(camB) XCTAssertFalse(flaggedBefore) @@ -574,27 +576,37 @@ final class MulticamControllerTests: XCTestCase { // Seed offsets so the fired capture takes the scheduled path. await controller.seedLaneForTesting(camA, supportsMulticam: true, offsetMillis: 0) await controller.seedLaneForTesting(camB, supportsMulticam: true, offsetMillis: 0) - await controller.setTimerTickInterval(0.001) // fast - await controller.setRigTimer(3) + // Large interval so the production tick Task never fires during the + // test — the ticks are driven deterministically through the inbox. + await controller.setTimerTickInterval(1000) + controller.setRigTimer(3) + await controller.waitForIdle() transport.sentMessages.removeAll() - await controller.capturePhoto() - // Wait for the countdown (3 ticks + fire) to elapse. - for _ in 0..<200 where sent(transport, RemoteCmd.ScheduledCapture.self).isEmpty { - try? await Task.sleep(nanoseconds: 5_000_000) + controller.capturePhoto() + await controller.waitForIdle() // arms the countdown, fans tick 3 + var remaining = await controller.countdownRemainingForTesting() + XCTAssertEqual(remaining, 3) + + // Drive the countdown 3 → 2 → 1 → 0 (fires) through the pump. + for _ in 0..<3 { + await controller.advanceTimerForTesting() + await controller.waitForIdle() } - // The countdown fanned out TimerCountdown to both cameras... - let ticks = sent(transport, RemoteCmd.TimerCountdown.self) - XCTAssertTrue(ticks.contains { ($0.msg as? RemoteCmd.TimerCountdown)?.value == 3 }) - XCTAssertTrue(ticks.contains { ($0.msg as? RemoteCmd.TimerCountdown)?.value == 0 }) - // ...and the synced capture fired after expiry. - XCTAssertFalse(sent(transport, RemoteCmd.ScheduledCapture.self).isEmpty) + remaining = await controller.countdownRemainingForTesting() + XCTAssertNil(remaining, "countdown finished") + + let ticks = sent(transport, RemoteCmd.TimerCountdown.self).compactMap { $0.msg as? RemoteCmd.TimerCountdown } + XCTAssertTrue(ticks.contains { $0.value == 3 }) + XCTAssertTrue(ticks.contains { $0.value == 0 }) + XCTAssertFalse(sent(transport, RemoteCmd.ScheduledCapture.self).isEmpty, + "expiry fired the synced capture") } func testSetPhotoQualityFansOut() async { let (controller, transport, _) = await makeController(peers: [camA, camB]) transport.sentMessages.removeAll() - await controller.setPhotoQuality(format: .heif, hdr: .on) + controller.setPhotoQuality(format: .heif, hdr: .on) await controller.waitForIdle() let sends = sent(transport, RemoteCmd.SetPhotoQuality.self) XCTAssertEqual(Set(sends.flatMap(\.peers)), [camA, camB]) @@ -634,7 +646,7 @@ final class MulticamControllerTests: XCTestCase { XCTAssertEqual(failed, .failed) transport.sentMessages.removeAll() - await controller.retryCollection(for: camA) + controller.retryCollection(for: camA) await controller.waitForIdle() // Re-request goes to that camera, and its lane returns to transferring. let resends = sent(transport, RemoteCmd.RequestVideoResend.self) From 4ce71e420101de32d065fdd3b0470d03c36defbf Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Thu, 13 Aug 2026 00:25:53 -0700 Subject: [PATCH 15/17] Multicam polish 2/4: single lane-state declaration + derived publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors #2 and #3, one motion. Lane state is declared once. `CameraLink` gains `isFocused` (the controller mirrors `focusedPeer` onto it via `syncFocusFlags`) and a `snapshot` computed property — the sole place every displayed field is named. `laneSnapshot()` is now `order.compactMap { links[$0]?.snapshot }`. `CameraLane` holds one `@Published info` with pure-read passthroughs (`status`, `captureOutcome`, …), and reconcile collapses to `lane.update(info)` — one Equatable compare, one assignment — instead of six field-by-field diffs. Adding a lane field is now a one-line change in two places (CameraLink field + snapshot), not four. Publishing is derived, not imperative. Pump handlers `markLanesDirty()` / `markRigDirty()` instead of pushing to the UI; the pump flushes at most one lanes-publish and one rig-publish per message, so multiple mutations in one message coalesce into a single main hop. The ~20 scattered `publishLanes()` / `publishRigSettings()` calls are gone; only pre-pump setup (`install`, `setDisplay`) publishes directly. The transient countdown value is read from `countdown` state at publish time rather than threaded through a parameter. Full suite green; no behavior change. Co-Authored-By: Claude Fable 5 --- RemoteCam/CameraLink.swift | 18 +++++ RemoteCam/MulticamController.swift | 102 ++++++++++++++++------------- RemoteCam/MulticamViewModel.swift | 40 +++++------ 3 files changed, 93 insertions(+), 67 deletions(-) diff --git a/RemoteCam/CameraLink.swift b/RemoteCam/CameraLink.swift index b9e5eb01..279834c8 100644 --- a/RemoteCam/CameraLink.swift +++ b/RemoteCam/CameraLink.swift @@ -41,6 +41,9 @@ final class CameraLink { let peerID: MCPeerID let displayName: String var status: Status = .linked + /// Whether this lane is the focused one (the controller maintains it when + /// focus changes, so the snapshot is a pure read). + var isFocused = false /// The most recent capabilities the camera advertised, or nil until the /// first exchange completes. `supportsMulticam` gates the multicam-only @@ -89,4 +92,19 @@ final class CameraLink { self.peerID = peerID self.displayName = peerID.displayName } + + /// The single source of the UI snapshot for this lane — every displayed + /// field is declared exactly once, here. + var snapshot: MulticamLaneInfo { + MulticamLaneInfo( + peerID: peerID, + displayName: displayName, + status: status, + isFocused: isFocused, + clockOffsetMillis: latestOffset?.offsetMillis, + captureOutcome: captureOutcome, + isRecording: isRecording, + needsQualityRematch: needsQualityRematch, + collection: collection) + } } diff --git a/RemoteCam/MulticamController.swift b/RemoteCam/MulticamController.swift index 054c17fd..6d56695f 100644 --- a/RemoteCam/MulticamController.swift +++ b/RemoteCam/MulticamController.swift @@ -194,11 +194,11 @@ public actor MulticamController { func setDisplay(_ display: MulticamDisplay) { self.display = display - publishRigSettings() - // The display is wired after `install` (the screen is pushed only once - // the handoff is done), so replay the current lanes now — otherwise the - // first snapshot, emitted during install, reaches no one. - publishLanes() + // Pre-pump setup: replay the current state directly (no pump epilogue + // to flush a dirty flag). The display is wired after `install`, so the + // first snapshot emitted during install would otherwise reach no one. + publishRigSettingsNow() + publishLanesNow() } func setReconnectRetryDelay(_ delay: TimeInterval) { reconnectRetryDelay = delay } @@ -236,7 +236,7 @@ public actor MulticamController { for peer in order { beginHandshake(with: peer) } startClockSyncLoop() - publishLanes() + publishLanesNow() // pre-pump setup } /// The initial per-camera handshake: announce the director role (carries @@ -252,7 +252,21 @@ public actor MulticamController { // MARK: - Message handling + /// Derived publishing: handlers mark what changed instead of pushing to the + /// UI themselves, and the pump flushes at most one lanes-publish and one + /// rig-publish per message — so multiple mutations coalesce into one main hop. + private var lanesDirty = false + private var rigDirty = false + private func markLanesDirty() { lanesDirty = true } + private func markRigDirty() { rigDirty = true } + func handle(_ msg: Message) async { + await route(msg) + if lanesDirty { lanesDirty = false; publishLanesNow() } + if rigDirty { rigDirty = false; publishRigSettingsNow() } + } + + private func route(_ msg: Message) async { switch msg { case let connected as OnConnectToDevice: handlePeerConnected(connected.peer) @@ -327,7 +341,7 @@ public actor MulticamController { // fully drive. if !isPeerCompatible(became) { link.status = .failed - publishLanes() + markLanesDirty() } case let caps as RemoteCmd.CameraCapabilitiesResp: @@ -337,8 +351,8 @@ public actor MulticamController { // tile badges + the tray offers re-match) rather than silently // changing the rig. Also refreshes the intersection menu. refreshRematchFlags() - publishLanes() - publishRigSettings() + markLanesDirty() + markRigDirty() // A multicam-capable camera gets an immediate clock probe so its // offset is ready well before the first synced capture (PR4), and // its preview tier (full if focused, else thumbnail). @@ -392,8 +406,15 @@ public actor MulticamController { publishAvailable() } focusedPeer = focusedPeer ?? peer + syncFocusFlags() beginHandshake(with: peer) - publishLanes() + markLanesDirty() + } + + /// Mirror `focusedPeer` onto each lane's `isFocused` so `CameraLink.snapshot` + /// stays the single source of truth for the tile. + private func syncFocusFlags() { + for (peer, link) in links { link.isFocused = (peer == focusedPeer) } } private func publishAvailable() { @@ -407,7 +428,7 @@ public actor MulticamController { // Degrade the tile, keep the rest of the rig recording/monitoring. The // controller stays browsing, so `browserDidFindPeer` re-invites. link.status = .reconnecting - publishLanes() + markLanesDirty() armReconnect(peer) } @@ -482,10 +503,11 @@ public actor MulticamController { private func handleSetFocusedPeer(_ peer: MCPeerID) { guard links[peer] != nil else { return } focusedPeer = peer + syncFocusFlags() // Retier previews: the newly focused camera goes full-size, the rest // (including the one that just lost focus) drop to thumbnail. for p in order { pushProfile(to: p) } - publishLanes() + markLanesDirty() } /// The preview tier a camera should be on: full for the focused lane, @@ -517,8 +539,8 @@ public actor MulticamController { private func handleRemoveCamera(_ peer: MCPeerID) { links[peer] = nil order.removeAll { $0 == peer } - if focusedPeer == peer { focusedPeer = order.first } - publishLanes() + if focusedPeer == peer { focusedPeer = order.first; syncFocusFlags() } + markLanesDirty() } private func focusedSend(_ msg: Message) { @@ -624,7 +646,7 @@ public actor MulticamController { lanes: ready) state = .capturingPhoto(captureId: captureID, acksRemaining: capturingLanes.count) - publishLanes() + markLanesDirty() armAckTimeout(captureID) } @@ -651,7 +673,7 @@ public actor MulticamController { lanes: ready) state = .recording(captureId: captureID, acksRemaining: capturingLanes.count) - publishLanes() + markLanesDirty() armAckTimeout(captureID) } @@ -673,7 +695,7 @@ public actor MulticamController { lanes: rolling) state = .stoppingRecording(captureId: captureID, acksRemaining: capturingLanes.count) - publishLanes() + markLanesDirty() armAckTimeout(captureID) } @@ -714,7 +736,7 @@ public actor MulticamController { sendTo(peer, RemoteCmd.SetVideoQuality(resolution: resolution, frameRate: frameRate)) } refreshRematchFlags() - publishLanes() + markLanesDirty() } public nonisolated func setPhotoQuality(format: PhotoFormat, hdr: HDRMode) { @@ -726,7 +748,7 @@ public actor MulticamController { for peer in order { sendTo(peer, RemoteCmd.SetPhotoQuality(format: format, hdrMode: hdr)) } - publishLanes() + markLanesDirty() } /// "Automatic" / re-match: recompute best-in-intersection and apply it. @@ -764,7 +786,7 @@ public actor MulticamController { private func handleSetRigTimer(_ seconds: Int) { rigTimerSeconds = seconds - publishRigSettings() + markRigDirty() } /// Begin a director-side countdown, fanned out to every camera so subjects @@ -807,10 +829,10 @@ public actor MulticamController { private func fanOutTimerTick(_ remaining: Int) { for peer in order { sendTo(peer, RemoteCmd.TimerCountdown(value: remaining)) } - publishRigSettings(countdown: remaining > 0 ? remaining : nil) + markRigDirty() // the countdown value is read from `countdown` state at publish } - private func publishRigSettings(countdown: Int? = nil) { + private func publishRigSettingsNow() { let menu = rigQualityMenu() let videoLabel: String if let v = activeVideoQuality { @@ -820,7 +842,7 @@ public actor MulticamController { } let snapshot = RigSettingsSnapshot( timerSeconds: rigTimerSeconds, - countdown: countdown, + countdown: countdown?.remaining, activeVideoLabel: videoLabel, videoOptions: menu.videoPickerOptions(), heifAvailable: menu.supportsHEIF(), @@ -872,7 +894,7 @@ public actor MulticamController { case .monitoring: break } - publishLanes() + markLanesDirty() } private func armAckTimeout(_ captureID: String) { @@ -913,7 +935,7 @@ public actor MulticamController { default: state = .monitoring(mode: .photo) } - publishLanes() + markLanesDirty() } // MARK: - Auto-collect (footage back to the director) @@ -940,14 +962,14 @@ public actor MulticamController { guard let link = links[peer] else { return } let name = rigMetadata(for: peer).photoFilename(isHEIC: Self.isHEIC(data)) link.collection = .collected - publishLanes() + markLanesDirty() Self.savePhotoToLibrary(data, originalFilename: name) } private func handleResourceStarted(_ started: ResourceTransferStarted) { guard let link = links[started.peer] else { return } link.collection = .transferring(0) - publishLanes() + markLanesDirty() } private func handleResourceFinished(_ finished: ResourceTransferFinished) { @@ -955,11 +977,11 @@ public actor MulticamController { if finished.error != nil || finished.localURL == nil { // Footage is still safe on the camera; the tile offers a retry. link.collection = .failed - publishLanes() + markLanesDirty() return } link.collection = .collected - publishLanes() + markLanesDirty() // The resource is named with the RS_ filename by the camera, so save it // under that; QuickTime sync metadata rides inside the .mov itself. Self.saveVideoToLibrary(at: finished.localURL!, originalFilename: finished.name) @@ -971,7 +993,7 @@ public actor MulticamController { private func handleRetryCollection(_ peer: MCPeerID) { guard let link = links[peer], link.collection == .failed else { return } link.collection = .transferring(0) - publishLanes() + markLanesDirty() sendTo(peer, RemoteCmd.RequestVideoResend(captureId: lastCaptureID ?? "")) } @@ -1055,23 +1077,13 @@ public actor MulticamController { // MARK: - Snapshots + // Each lane declares its own snapshot (see `CameraLink.snapshot`); the + // controller just gathers them in order. private func laneSnapshot() -> [MulticamLaneInfo] { - order.compactMap { peer in - guard let link = links[peer] else { return nil } - return MulticamLaneInfo( - peerID: peer, - displayName: link.displayName, - status: link.status, - isFocused: peer == focusedPeer, - clockOffsetMillis: link.latestOffset?.offsetMillis, - captureOutcome: link.captureOutcome, - isRecording: link.isRecording, - needsQualityRematch: link.needsQualityRematch, - collection: link.collection) - } + order.compactMap { links[$0]?.snapshot } } - private func publishLanes() { + private func publishLanesNow() { let snapshot = laneSnapshot() let capturing: Bool if case .capturingPhoto = state { capturing = true } else { capturing = false } @@ -1143,7 +1155,7 @@ extension MulticamController: MultipeerServiceDelegate { t0Millis: pong.echoT0Millis, cameraClockMillis: pong.cameraClockMillis, t3Millis: t3) - publishLanes() + markLanesDirty() } public nonisolated func didReceiveFrameRequest(_ request: RemoteCmd.RequestFrame) { diff --git a/RemoteCam/MulticamViewModel.swift b/RemoteCam/MulticamViewModel.swift index d6548131..4a823386 100644 --- a/RemoteCam/MulticamViewModel.swift +++ b/RemoteCam/MulticamViewModel.swift @@ -23,16 +23,17 @@ final class CameraLane: ObservableObject, Identifiable { /// view model — see `FrameDisplayModel`). let frames = FrameDisplayModel() - @Published var status: CameraLink.Status - @Published var isFocused: Bool - /// How this camera answered the last synced capture — a brief tile badge. - @Published var captureOutcome: CaptureOutcome? - /// This camera is rolling in a synced recording — REC badge. - @Published var isRecording: Bool - /// This camera can't match the running rig quality — badge + re-match. - @Published var needsQualityRematch: Bool - /// Post-take footage collection progress — transfer badge / done / failed. - @Published var collection: CameraLink.LaneCollectionState + /// The whole lane snapshot, published as one value — the fields below are + /// pure reads, so a status change and a badge change coalesce into a single + /// SwiftUI invalidation (frames are separate, in `FrameDisplayModel`). + @Published private(set) var info: MulticamLaneInfo + + var status: CameraLink.Status { info.status } + var isFocused: Bool { info.isFocused } + var captureOutcome: CaptureOutcome? { info.captureOutcome } + var isRecording: Bool { info.isRecording } + var needsQualityRematch: Bool { info.needsQualityRematch } + var collection: CameraLink.LaneCollectionState { info.collection } /// This lane's own decoder + stall watchdog. The view controller wires its /// `onImage` to set `frames.cameraImage`, and its stall/keyframe callbacks @@ -42,12 +43,12 @@ final class CameraLane: ObservableObject, Identifiable { init(info: MulticamLaneInfo) { self.peerID = info.peerID self.displayName = info.displayName - self.status = info.status - self.isFocused = info.isFocused - self.captureOutcome = info.captureOutcome - self.isRecording = info.isRecording - self.needsQualityRematch = info.needsQualityRematch - self.collection = info.collection + self.info = info + } + + /// Mechanical reconcile: one Equatable compare, one assignment. + func update(_ info: MulticamLaneInfo) { + if self.info != info { self.info = info } } } @@ -89,12 +90,7 @@ final class MulticamViewModel: ObservableObject { let next: [CameraLane] = infos.map { info in if let lane = existing.removeValue(forKey: info.peerID) { - if lane.status != info.status { lane.status = info.status } - if lane.isFocused != info.isFocused { lane.isFocused = info.isFocused } - if lane.captureOutcome != info.captureOutcome { lane.captureOutcome = info.captureOutcome } - if lane.isRecording != info.isRecording { lane.isRecording = info.isRecording } - if lane.needsQualityRematch != info.needsQualityRematch { lane.needsQualityRematch = info.needsQualityRematch } - if lane.collection != info.collection { lane.collection = info.collection } + lane.update(info) // one Equatable compare + assignment return lane } let lane = CameraLane(info: info) From 5a8e2e438cfde90a0d0583c66d070692fc079356 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Thu, 13 Aug 2026 07:27:17 -0700 Subject: [PATCH 16/17] =?UTF-8?q?Multicam=20polish=203/4:=20de-spook=20?= =?UTF-8?q?=E2=80=94=20explicit=20states,=20owned=20lifecycles,=20sink-rou?= =?UTF-8?q?ted=20frames?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete the dead CameraLink.receiver field (frame decoding lives entirely UI-side, one FrameStreamReceiver per CameraLane). - Camera-side inMulticamSession boolean -> typed CameraDriver enum (.solo/.director), so the one gated behavior (keep recording through a director drop) reads as an explicit state rather than a spooky flag. Regression-proven both ways (multicam drop keeps recording, single-cam drop stops). - Route preview frames actor -> per-lane Sendable sink (MulticamFrameSink) instead of the actor calling into a UIViewController. FrameStreamReceiver is now @unchecked Sendable (decode state confined to its queue). - Owned temp-file lifecycle for collected clips: the controller deletes the transport's temp file on every path that doesn't move it into the photo library (removed lane, transfer error, unauthorized, failed import). - Type the stringly rig-quality label: activeVideoLabel String -> RigVideoSelection?, so the tray matches the running quality by value. Full suite green (727). Co-Authored-By: Claude Fable 5 --- RemoteCam/CameraLink.swift | 17 ++--- RemoteCam/FrameStreamReceiver.swift | 7 +- RemoteCam/MulticamController.swift | 73 +++++++++++++++----- RemoteCam/MulticamView.swift | 6 +- RemoteCam/MulticamViewController.swift | 14 ++-- RemoteCam/MulticamViewModel.swift | 2 +- RemoteCam/RigQualityMenu.swift | 15 +++- RemoteCam/SessionCoordinator.swift | 32 +++++---- RemoteCamTests/MulticamControllerTests.swift | 22 ++++-- 9 files changed, 131 insertions(+), 57 deletions(-) diff --git a/RemoteCam/CameraLink.swift b/RemoteCam/CameraLink.swift index 279834c8..0796c33a 100644 --- a/RemoteCam/CameraLink.swift +++ b/RemoteCam/CameraLink.swift @@ -11,13 +11,13 @@ import Stormo /// One camera in a multicam director session. The director holds one of these /// per connected camera, keyed by `MCPeerID`; it is the multicam analog of the -/// single link + single `FrameStreamReceiver` that `SessionCoordinator` holds -/// for a 1:1 monitor. +/// single link that `SessionCoordinator` holds for a 1:1 monitor. (Frame +/// decoding lives entirely on the UI side, one `FrameStreamReceiver` per +/// `CameraLane`; this actor-domain link never touches pixels.) /// -/// A reference type, not a struct: it owns a `FrameStreamReceiver` (a class -/// with a running decode timer) and the `MulticamController` actor mutates it -/// in place as frames, capabilities and clock samples arrive — a value type -/// would force a dictionary read-modify-write on every frame. +/// A reference type, not a struct: the `MulticamController` actor mutates it in +/// place as capabilities and clock samples arrive — a value type would force a +/// dictionary read-modify-write on every update. final class CameraLink { /// Progress of one lane's footage transfer to the director after a take. @@ -83,11 +83,6 @@ final class CameraLink { /// director. Drives the tile's transfer progress / done / failed badge. var collection: LaneCollectionState = .idle - /// This camera's own preview decoder + stall watchdog. Frames tagged with - /// this peer's id are fed here; its `onImage` drives exactly this lane's - /// tile, so a frame from another camera never touches it. - let receiver = FrameStreamReceiver() - init(peerID: MCPeerID) { self.peerID = peerID self.displayName = peerID.displayName diff --git a/RemoteCam/FrameStreamReceiver.swift b/RemoteCam/FrameStreamReceiver.swift index e3d9adce..bdd7ec07 100644 --- a/RemoteCam/FrameStreamReceiver.swift +++ b/RemoteCam/FrameStreamReceiver.swift @@ -20,7 +20,12 @@ import CoreImage import VideoToolbox import VideocallCodecs -final class FrameStreamReceiver { +/// `@unchecked Sendable`: `receive(_:)` hops straight to `decodeQueue`, and all +/// mutable decode state is confined there; the `onImage`/`onStall`/ +/// `onKeyframeNeeded` callbacks are wired once before `start()`. This lets the +/// multicam controller hold a per-lane frame sink that calls `receive` from the +/// actor without reaching into a `UIViewController`. +final class FrameStreamReceiver: @unchecked Sendable { /// Decoded frame ready for display. Called on the decode queue — hop to /// main before touching UI. diff --git a/RemoteCam/MulticamController.swift b/RemoteCam/MulticamController.swift index 6d56695f..7ac62197 100644 --- a/RemoteCam/MulticamController.swift +++ b/RemoteCam/MulticamController.swift @@ -55,11 +55,18 @@ struct MulticamLaneInfo: Equatable { let collection: CameraLink.LaneCollectionState } +/// A Sendable pipe that carries one lane's decoded-preview frames from the +/// controller (actor domain) to exactly that lane's UI-side decoder. The view +/// controller hands one to the controller per lane at creation +/// (`setFrameSink(for:_:)`), so the ~20fps stream routes actor → closure — +/// never reaching into a `UIViewController` from the actor. +typealias MulticamFrameSink = @Sendable (RemoteCmd.OnFrame) -> Void + /// The main-actor bridge from the controller to the multicam screen — the /// multicam analog of `MonitorDisplay`. Low-frequency lane changes go through -/// `applyLanes`; the ~20fps preview stream goes through `receiveFrame`, which -/// the view controller routes to exactly one lane's decoder so a frame from -/// camera B never re-renders camera A. +/// `applyLanes`; the ~20fps preview stream goes through per-lane frame sinks +/// (see `MulticamFrameSink`), each routing to exactly one lane's decoder so a +/// frame from camera B never re-renders camera A. protocol MulticamDisplay: AnyObject { func applyLanes(_ lanes: [MulticamLaneInfo]) /// Aggregate shutter state: `capturing` = a synced photo is in flight @@ -70,7 +77,6 @@ protocol MulticamDisplay: AnyObject { func applyAvailablePeers(_ peers: [MCPeerID]) /// The rig-wide settings (timer + quality intersection) for the tray. func applyRigSettings(_ settings: RigSettingsSnapshot) - func receiveFrame(_ frame: RemoteCmd.OnFrame) func exitMulticam() } @@ -182,6 +188,11 @@ public actor MulticamController { private weak var display: MulticamDisplay? + /// One preview-frame sink per lane, keyed by peer. The view controller + /// registers one when it creates a lane; `handleFrame` routes each frame to + /// its source's sink. Cleared when the link is dropped. + private var frameSinks: [MCPeerID: MulticamFrameSink] = [:] + /// The interval between clock-offset refreshes per camera. private let clockSyncInterval: TimeInterval = 30 /// How far ahead a re-invite waits before re-browsing a dropped camera. @@ -202,6 +213,13 @@ public actor MulticamController { } func setReconnectRetryDelay(_ delay: TimeInterval) { reconnectRetryDelay = delay } + /// Register (or replace) the preview-frame sink for a lane. Called by the + /// view controller when it creates the lane; the sink is dropped when the + /// link goes away (`handleRemoveCamera`). + func setFrameSink(for peer: MCPeerID, _ sink: @escaping MulticamFrameSink) { + frameSinks[peer] = sink + } + /// Test support. func lanesForTesting() -> [MulticamLaneInfo] { laneSnapshot() } func focusedPeerForTesting() -> MCPeerID? { focusedPeer } @@ -481,7 +499,7 @@ public actor MulticamController { // Route to exactly this lane's decoder (rendering isolation), then ack // only this camera so its credit window advances and no other camera // sends on a frame it didn't produce (Seam B). - display?.receiveFrame(frame) + frameSinks[frame.peerId]?(frame) sendTo(frame.peerId, RemoteCmd.RequestFrame(sender: nil)) } @@ -538,6 +556,7 @@ public actor MulticamController { private func handleRemoveCamera(_ peer: MCPeerID) { links[peer] = nil + frameSinks[peer] = nil order.removeAll { $0 == peer } if focusedPeer == peer { focusedPeer = order.first; syncFocusFlags() } markLanesDirty() @@ -834,16 +853,12 @@ public actor MulticamController { private func publishRigSettingsNow() { let menu = rigQualityMenu() - let videoLabel: String - if let v = activeVideoQuality { - videoLabel = "\(v.resolution.displayName)\(v.frameRate.displayName)" - } else { - videoLabel = NSLocalizedString("Auto", comment: "automatic rig quality") - } let snapshot = RigSettingsSnapshot( timerSeconds: rigTimerSeconds, countdown: countdown?.remaining, - activeVideoLabel: videoLabel, + activeVideo: activeVideoQuality.map { + RigVideoSelection(resolution: $0.resolution, frameRate: $0.frameRate) + }, videoOptions: menu.videoPickerOptions(), heifAvailable: menu.supportsHEIF(), hdrAvailable: menu.supportsHDR(), @@ -973,9 +988,16 @@ public actor MulticamController { } private func handleResourceFinished(_ finished: ResourceTransferFinished) { - guard let link = links[finished.peer] else { return } - if finished.error != nil || finished.localURL == nil { - // Footage is still safe on the camera; the tile offers a retry. + guard let link = links[finished.peer] else { + // No lane owns this transfer any more (camera removed mid-flight); + // still delete the temp file the transport handed us. + finished.localURL.map(Self.discardTempFile) + return + } + guard finished.error == nil, let localURL = finished.localURL else { + // Footage is still safe on the camera; the tile offers a retry. Any + // partial temp file is ours to clean up. + finished.localURL.map(Self.discardTempFile) link.collection = .failed markLanesDirty() return @@ -983,8 +1005,17 @@ public actor MulticamController { link.collection = .collected markLanesDirty() // The resource is named with the RS_ filename by the camera, so save it - // under that; QuickTime sync metadata rides inside the .mov itself. - Self.saveVideoToLibrary(at: finished.localURL!, originalFilename: finished.name) + // under that; QuickTime sync metadata rides inside the .mov itself. The + // controller owns `localURL` until `saveVideoToLibrary` either moves it + // into the library or deletes it. + Self.saveVideoToLibrary(at: localURL, originalFilename: finished.name) + } + + /// The transport hands the director a temp file per received clip; the + /// controller owns it and must not leak it on any path that doesn't move it + /// into the photo library. + private static func discardTempFile(_ url: URL) { + try? FileManager.default.removeItem(at: url) } /// Re-request a failed lane's footage (the camera still holds it). @@ -1016,13 +1047,19 @@ public actor MulticamController { private static func saveVideoToLibrary(at url: URL, originalFilename: String) { PHPhotoLibrary.requestAuthorization { status in - guard status == .authorized else { return } + guard status == .authorized else { + discardTempFile(url) // never authorized to import — don't leak it + return + } PHPhotoLibrary.shared().performChanges({ let options = PHAssetResourceCreationOptions() options.shouldMoveFile = true options.originalFilename = originalFilename PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: url, options: options) }, completionHandler: { ok, _ in + // `shouldMoveFile` consumes the file only on success; on failure + // it stays behind, so the owner removes it. + if !ok { discardTempFile(url) } print(ok ? "Director collected clip \(originalFilename)" : "collect clip failed") }) } diff --git a/RemoteCam/MulticamView.swift b/RemoteCam/MulticamView.swift index 39a02da0..4cad84ac 100644 --- a/RemoteCam/MulticamView.swift +++ b/RemoteCam/MulticamView.swift @@ -458,7 +458,9 @@ struct RigTrayView: View { HStack { Text(NSLocalizedString("Automatic", comment: "best-in-intersection")) Spacer() - Text(settings.activeVideoLabel).foregroundColor(.secondary) + Text(settings.activeVideo?.label + ?? NSLocalizedString("Auto", comment: "automatic rig quality")) + .foregroundColor(.secondary) } } ForEach(settings.videoOptions) { opt in @@ -474,7 +476,7 @@ struct RigTrayView: View { .font(.caption) .foregroundColor(.secondary) } - if settings.activeVideoLabel == opt.label { + if settings.activeVideo?.matches(opt) == true { Image(systemName: "checkmark").foregroundColor(AppTheme.accent) } } diff --git a/RemoteCam/MulticamViewController.swift b/RemoteCam/MulticamViewController.swift index cf7ba009..2474170f 100644 --- a/RemoteCam/MulticamViewController.swift +++ b/RemoteCam/MulticamViewController.swift @@ -151,6 +151,14 @@ public final class MulticamViewController: UIViewController { self?.controller.requestKeyframe(for: peer) } lane.receiver.start() + // Hand the controller a sink that feeds only this lane's decoder, so it + // routes frames actor → closure without touching this view controller. + // `receive` is queue-hopping and thread-safe; the weak capture lets the + // lane deallocate freely. + let receiver = lane.receiver + Task { await controller.setFrameSink(for: peer) { [weak receiver] frame in + receiver?.receive(frame) + } } } } @@ -176,12 +184,6 @@ extension MulticamViewController: MulticamDisplay { viewModel.rigSettings = settings } - func receiveFrame(_ frame: RemoteCmd.OnFrame) { - // Route to exactly the source lane's decoder; a frame for camera B - // never touches camera A's tile. - viewModel.lane(for: frame.peerId)?.receiver.receive(frame) - } - func exitMulticam() { navigationController?.popViewController(animated: true) } diff --git a/RemoteCam/MulticamViewModel.swift b/RemoteCam/MulticamViewModel.swift index 4a823386..ef969b0d 100644 --- a/RemoteCam/MulticamViewModel.swift +++ b/RemoteCam/MulticamViewModel.swift @@ -70,7 +70,7 @@ final class MulticamViewModel: ObservableObject { /// Whether the add-camera sheet is showing. @Published var showingAddCamera: Bool = false /// Rig-wide settings (timer + quality intersection) for the tray. - @Published var rigSettings = RigSettingsSnapshot(activeVideoLabel: "Auto") + @Published var rigSettings = RigSettingsSnapshot() /// Whether the rig settings tray is showing. @Published var showingRigTray: Bool = false diff --git a/RemoteCam/RigQualityMenu.swift b/RemoteCam/RigQualityMenu.swift index fa18cc6b..d42f25e0 100644 --- a/RemoteCam/RigQualityMenu.swift +++ b/RemoteCam/RigQualityMenu.swift @@ -141,11 +141,24 @@ struct RigVideoOption: Equatable, Identifiable { var label: String { "\(resolution.displayName)\(frameRate.displayName)" } } +/// The rig's active video quality, or nil for Automatic (best-in-intersection). +/// Typed so the tray matches the running quality by value, not by comparing +/// rendered label strings. +struct RigVideoSelection: Equatable { + let resolution: VideoResolution + let frameRate: VideoFrameRate + + var label: String { "\(resolution.displayName)\(frameRate.displayName)" } + func matches(_ option: RigVideoOption) -> Bool { + resolution == option.resolution && frameRate == option.frameRate + } +} + /// The rig-settings snapshot the director hands the tray UI. struct RigSettingsSnapshot: Equatable { var timerSeconds: Int = 0 var countdown: Int? - var activeVideoLabel: String + var activeVideo: RigVideoSelection? var videoOptions: [RigVideoOption] = [] var heifAvailable: Bool = false var hdrAvailable: Bool = false diff --git a/RemoteCam/SessionCoordinator.swift b/RemoteCam/SessionCoordinator.swift index 4d871868..ff311a3d 100644 --- a/RemoteCam/SessionCoordinator.swift +++ b/RemoteCam/SessionCoordinator.swift @@ -265,15 +265,21 @@ public actor SessionCoordinator { /// beyond this it is stale and would fire out of sync). private let scheduledCaptureMaxLatenessMillis: Int64 = 1000 - /// Camera side: this session is being driven by a multicam director. Latched - /// the first time any scheduled multicam command arrives, cleared when the - /// session ends (`popToScanning`/`leaveSession`). It gates the ONE behavior - /// change on the camera — keep recording through a director drop instead of - /// stopping — so a single-camera session is never affected. - private var inMulticamSession = false + /// Camera side: who is driving this session. `.solo` is an ordinary 1:1 + /// session (or none yet); `.director` latches the first time any scheduled + /// multicam command arrives, and is cleared when the session ends + /// (`popToScanning`). It gates the ONE behavior change on the camera — keep + /// recording through a director drop instead of stopping — so a + /// single-camera session is never affected. A reconnect goes through + /// `.reconnecting`, not teardown, so the latch survives a transient drop. + private enum CameraDriver: Equatable { + case solo + case director + } + private var cameraDriver: CameraDriver = .solo /// Test support. - func inMulticamSessionForTesting() -> Bool { inMulticamSession } + func inMulticamSessionForTesting() -> Bool { cameraDriver == .director } /// Multicam director "collecting" mode. Off by default and only ever set /// by the scanner when `ENABLE_MULTICAM` and the monitor role, so every @@ -676,7 +682,7 @@ public actor SessionCoordinator { // or a dead link) — a fresh session starts single-cam until a director // says otherwise. A reconnect goes through `.reconnecting`, not here, so // the latch survives a transient director drop. - inMulticamSession = false + cameraDriver = .solo pendingSyncMetadata = nil switch state { case .scanning: @@ -1074,7 +1080,7 @@ public actor SessionCoordinator { case let fire as FireScheduledRecordingStart: // The start instant arrived. Stamp the recording with its sync // metadata (for the QuickTime keys + the RS_ filename), then roll. - inMulticamSession = true + cameraDriver = .director pendingVideoSyncMetadata = fire.metadata ctrl.setVideoSyncMetadata(fire.metadata) ctrl.currentCameraMode = .Video @@ -1303,7 +1309,7 @@ public actor SessionCoordinator { /// enqueues `FireScheduledCapture`, so the capture is pulled by the message /// pump in order — never racing a state transition. private func handleScheduledCapture(_ scheduled: RemoteCmd.ScheduledCapture) async { - inMulticamSession = true + cameraDriver = .director let now = SyncClock.nowMillis() let lateness = Int64(now) - Int64(scheduled.fireAtCameraClockMillis) guard lateness <= scheduledCaptureMaxLatenessMillis else { @@ -1337,7 +1343,7 @@ public actor SessionCoordinator { /// schedule-off-the-actor pattern as `handleScheduledCapture`; the fire /// message rolls the recording through the normal pipeline. private func handleScheduledStartRecording(_ scheduled: RemoteCmd.ScheduledStartRecording) async { - inMulticamSession = true + cameraDriver = .director let lateness = Int64(SyncClock.nowMillis()) - Int64(scheduled.fireAtCameraClockMillis) guard lateness <= scheduledCaptureMaxLatenessMillis else { await sendOrGoToScanning(RemoteCmd.ScheduledRecordingAck( @@ -1501,13 +1507,13 @@ public actor SessionCoordinator { case let disconnected as DisconnectPeer: if let lost = disconnected.peer, lost == peer, connectedPeers.isEmpty { - if inMulticamSession { + if cameraDriver == .director { // Resilient camera: the director dropped mid-recording, but // the OTHER cameras in the rig keep rolling — so must this // one. Keep recording (do NOT stop), and enter the normal // reconnect path; the clip is saved when the scheduled stop // finally fires, or when the user stops on-device. This is - // the ONE behavior gated on `inMulticamSession`; a + // the ONE behavior gated on `cameraDriver == .director`; a // single-camera session falls through to the stop below. await loseSessionPeer(lost) } else { diff --git a/RemoteCamTests/MulticamControllerTests.swift b/RemoteCamTests/MulticamControllerTests.swift index 6fbc0f09..a6ceb292 100644 --- a/RemoteCamTests/MulticamControllerTests.swift +++ b/RemoteCamTests/MulticamControllerTests.swift @@ -12,7 +12,6 @@ import XCTest /// Captures what the controller pushes to the screen. private final class FakeMulticamDisplay: MulticamDisplay, @unchecked Sendable { var lastLanes: [MulticamLaneInfo] = [] - var receivedFrames: [MCPeerID] = [] var capturing = false var recording = false var availablePeers: [MCPeerID] = [] @@ -26,10 +25,18 @@ private final class FakeMulticamDisplay: MulticamDisplay, @unchecked Sendable { } func applyAvailablePeers(_ peers: [MCPeerID]) { availablePeers = peers } func applyRigSettings(_ settings: RigSettingsSnapshot) { rigSettings = settings } - func receiveFrame(_ frame: RemoteCmd.OnFrame) { receivedFrames.append(frame.peerId) } func exitMulticam() { didExit = true } } +/// Records which peers' frame sinks fired — the test stand-in for the view +/// controller's per-lane decoders. Reads happen after `waitForIdle`. +private final class FrameSinkCollector: @unchecked Sendable { + private let lock = NSLock() + private var _peers: [MCPeerID] = [] + var peers: [MCPeerID] { lock.lock(); defer { lock.unlock() }; return _peers } + func record(_ peer: MCPeerID) { lock.lock(); _peers.append(peer); lock.unlock() } +} + final class MulticamControllerTests: XCTestCase { private let camA = MCPeerID(displayName: "CameraA") @@ -95,13 +102,20 @@ final class MulticamControllerTests: XCTestCase { // MARK: - Frame routing (Seam B) func testFrameRoutesToItsLaneAndAcksOnlyItsSource() async { - let (controller, transport, display) = await makeController(peers: [camA, camB]) + let (controller, transport, _) = await makeController(peers: [camA, camB]) + // Register a per-lane sink for each camera, mirroring how the view + // controller wires one at lane creation. + let collector = FrameSinkCollector() + let (a, b) = (camA, camB) + await controller.setFrameSink(for: a) { _ in collector.record(a) } + await controller.setFrameSink(for: b) { _ in collector.record(b) } transport.sentMessages.removeAll() controller.didReceiveFrame(sendFrame(), from: camB) await controller.waitForIdle() - XCTAssertEqual(display.receivedFrames, [camB]) + XCTAssertEqual(collector.peers, [camB], + "the frame reaches only its own lane's decoder, never another's") let acks = sent(transport, RemoteCmd.RequestFrame.self) XCTAssertEqual(acks.map(\.peers), [[camB]], "the frame ack must address only the camera that sent the frame") From d65ca1b0c0bc4d25c666396ad4ab79f6913ae40d Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Thu, 13 Aug 2026 07:28:42 -0700 Subject: [PATCH 17/17] =?UTF-8?q?Multicam=20polish=204/4:=20docs/multicam.?= =?UTF-8?q?md=20=E2=80=94=20architecture=20with=20C&C=20+=20sequence=20dia?= =?UTF-8?q?grams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design doc for Director mode, written to match the post-refactor code: components & connections (four isolation domains, single-entry inbox), the five-hop frame path, sequence diagrams for synced photo and a camera drop mid-recording, the design rules worth preserving (single-entry inbox = arrival-order invariant; commands are requests, results are state; framing vs the shot; per-lane rendering isolation; additive gated wire), and an honest known-debts section. Mermaid renders natively on GitHub. Co-Authored-By: Claude Fable 5 --- Docs/multicam.md | 217 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 Docs/multicam.md diff --git a/Docs/multicam.md b/Docs/multicam.md new file mode 100644 index 00000000..4ac28bab --- /dev/null +++ b/Docs/multicam.md @@ -0,0 +1,217 @@ +# Multicam Director mode — architecture + +One phone (the **director**) drives up to four camera phones over the existing +Stormo/QUIC transport, showing a live preview of every angle and firing +**synchronized** photo capture and video recording across all of them. Each +camera saves full-resolution media locally; the footage then auto-collects to +the director, stamped with alignment metadata so any editor can line the angles +up. + +The shape is deliberately lopsided: **one director actor, N unchanged 1:1 +cameras.** A camera runs the same `SessionCoordinator` it always has — it never +learns it is one of several. Everything multicam lives on the director, in +`MulticamController`. A single-camera session never enters this code at all: the +scanner hands off to `MulticamController` only when two or more cameras connect, +and every wire message here is gated on a `supports_multicam` capability flag, +so a 9.0.x phone pairs as an ordinary single camera. + +> Free tier = 2 cameras, Pro = 4. The whole feature is behind +> `FeatureFlags.ENABLE_MULTICAM`. + +## Components & connections + +Four isolation domains. The director's UI is main-actor; `MulticamController` is +an actor with a single FIFO inbox; the transport is shared; each camera is its +own process on its own phone. + +```mermaid +flowchart LR + subgraph UI["Director UI · main actor"] + VC["MulticamViewController"] + VM["MulticamViewModel
lanes: [CameraLane]"] + LANE["CameraLane ×N
FrameStreamReceiver + FrameDisplayModel"] + VIEW["MulticamView · RigTray · Grid"] + end + + subgraph CTRL["MulticamController · actor"] + INBOX["tell → AsyncStream FIFO inbox → pump"] + LINKS["links: [PeerID: CameraLink]
the source of truth"] + SINKS["frameSinks: [PeerID: FrameSink]"] + end + + subgraph XPORT["Transport"] + MP["MultipeerService
one QUIC session, N peers"] + end + + subgraph CAMS["Camera phones ×N"] + CAM["SessionCoordinator (camera role)
1:1, unchanged"] + end + + VC -- "commands: capturePhoto, setFocusedPeer …
(nonisolated → tell)" --> INBOX + MP -- "transport events: frames, acks,
connect/disconnect (nonisolated → tell)" --> INBOX + INBOX --> LINKS + LINKS -- "one coalesced main-hop:
lane + rig snapshots" --> VM + VM --> LANE --> VIEW + LINKS -- "send(cmd, to: peer)" --> MP + SINKS -- "per-lane preview frame" --> LANE + MP <-- "QUIC" --> CAM +``` + +The load-bearing property: **both** UI commands and transport events enter the +actor the same way — a `nonisolated` method that calls `tell(_:)`, appending to +one `AsyncStream`. A single pump task (`for await msg in stream`) processes them +one at a time, in arrival order. There is no second door. See +[Design rules](#design-rules). + +## The camera link — one source of truth per camera + +`CameraLink` (a `final class`, actor-confined) is the only place a camera's +state is declared: status, capabilities, clock estimator, capture outcome, +recording flag, stream profile, collection progress. The UI is a projection of +it: + +``` +CameraLink (actor truth) + │ snapshot (one computed property) + ▼ +MulticamLaneInfo (Sendable value) + │ one coalesced hop to main + ▼ +CameraLane (@Published) → SwiftUI tile +``` + +Mutations never publish by hand. The controller mutates a `CameraLink` and marks +the lanes dirty; a single coalescing step turns the current links into +`[MulticamLaneInfo]` and hands them to the main actor once per pump message. A +state change can never be "forgotten" on the way to the screen, because +publishing is a consequence of mutation, not a separate call. + +## Live preview — five hops, four domains + +A preview frame crosses every isolation boundary exactly once, and lands on +exactly one tile: + +```mermaid +flowchart LR + A["didReceiveFrame
nonisolated · transport thread"] + -->|tell| B["inbox pump
actor · FIFO"] + B -->|"frameSinks[peerId]"| C["FrameStreamReceiver.receive
decode queue"] + C -->|"onImage → main"| D["FrameDisplayModel
main · @Published image"] + D -->|publish| E["LiveFrameView
this lane's tile only"] + B -->|"RequestFrame → source peer only"| F["credit-window ack (Seam B)"] +``` + +Two things make this robust: + +- **Rendering isolation.** Each lane owns its own `FrameStreamReceiver` and + `FrameDisplayModel`, so a frame from camera B publishes into camera B's model + and re-renders only camera B's tile — never camera A, never the chrome. +- **Sink routing, not display calls.** The actor routes frames through a + per-lane `@Sendable` closure (`MulticamFrameSink`) registered when the lane is + created. The actor never calls into a `UIViewController`; it calls a Sendable + sink that hops to its own decode queue. + +## Synced capture — the clock trick + +The director never says "everyone shoot now" (that skews by 10–40 ms as the +command arrives at each camera at a different time). It measures each camera's +clock offset and schedules a shutter instant expressed in *that camera's* clock. + +```mermaid +sequenceDiagram + participant UI as Director UI + participant MC as MulticamController (actor) + participant A as Camera A + participant B as Camera B + + Note over MC,B: every ~30s per lane
ClockSyncPing(t0) → Pong(echo, camClock)
→ offset ±2–5 ms (min-RTT of 5) + + UI->>MC: capturePhoto() [tell → inbox] + MC->>MC: fireAt = now + 150 ms · captureId + MC->>A: ScheduledCapture(fireAt + offsetA, captureId) + MC->>B: ScheduledCapture(fireAt + offsetB, captureId) + A-->>MC: ScheduledCaptureAck(captureId) + B-->>MC: ScheduledCaptureAck(captureId) + Note over MC: state = capturingPhoto(acksRemaining)
per-lane 3 s timeout → lane .failed, capture proceeds + Note over A,B: each fires at that instant on its OWN clock
→ sub-frame skew at 30 fps + A-->>MC: TakePicResp(stamped photo) + B-->>MC: TakePicResp(stamped photo) + MC->>MC: save RS___cam to Photos · lane .collected + MC-->>UI: lane snapshots (badges) +``` + +Video start and stop ride the same mechanism +(`ScheduledStartRecording` / `ScheduledStopRecording`), so every clip shares a +start and stop anchor and the lengths match. Photos come back inline in +`TakePicResp`; video clips transfer as resources afterward (see +[Known debts](#known-debts)). + +## Resilient camera — a drop mid-recording + +The one behavior a camera changes in a director session: if the director drops +while recording, the camera keeps rolling instead of stopping. It is gated on an +explicit `CameraDriver` state (`.solo` vs `.director`), latched when the first +scheduled multicam command arrives and cleared only when the session truly ends +— so a single-camera session is byte-identical. + +```mermaid +sequenceDiagram + participant MC as MulticamController + participant A as Camera A (recording) + participant B as Camera B (recording) + + Note over A,B: cameraDriver = .director (latched at scheduled start) + Note over MC: director drops B's link + MC->>MC: link[B].status = .reconnecting
armReconnect(B) · other lanes untouched + Note over B: DisconnectPeer while .director →
KEEP recording, enter reconnect (do NOT stop) + MC->>B: browser re-finds B → re-invite (only reconnecting lanes) + B-->>MC: reconnected · lane .linked + Note over B: clip saved when the scheduled stop fires,
or when the user stops on-device +``` + +## Design rules + +These are the invariants worth preserving through future changes. + +- **Single-entry inbox = arrival-order invariant.** Every input — UI command or + transport event — becomes a message through `tell`. Because there is one + queue, the actor always sees the world in the order things happened: a shutter + tap that arrives after a disconnect is processed after it, so it can never fan + a capture out to a camera the actor already knows is gone. (Contrast: direct + `await` command methods would interleave with queued events at every `await`.) +- **Commands are requests; results are state.** A command returns nothing and + cannot fail loudly — it enqueues. Outcomes (captured / failed / reconnecting / + transferring) arrive later as published `CameraLink` state and render as tile + badges. The UI is a pure function of that state, not of any command's return. +- **Framing belongs to a camera; the shot belongs to the rig.** Per-camera + controls (zoom, focus, flash, torch, lens, camera flip) address the *focused* + camera only. Rig controls (shutter, record, timer, quality/HDR) fan out to + all. Nothing per-camera ever broadcasts. +- **Rendering isolation per lane.** One decoder and one published image per + camera; a frame from one camera can only touch its own tile. +- **Additive, gated wire.** Every multicam action (`ClockSyncPing`, + `ScheduledCapture`, `ScheduledStartRecording`, `ScheduledStopRecording`, + `SetStreamProfile`, `RequestVideoResend`) is an appended FlatBuffers action + gated on the `supports_multicam` capability, so old peers pair normally and + never receive a message they would misread. + +## Known debts + +Honest list of what is deliberately first-draft, for whoever picks this up next. + +- **Fixed-delay transfer stagger.** Auto-collect delays each camera's clip send + by `(cameraIndex − 1) × staggerSeconds` so N×4K clips don't hit the link at + once (`SessionCoordinator.handleSendVideoResource`). It is open-loop: if one + transfer runs long, the next can still overlap. The real fix is + director-coordinated turn-taking (the director grants "your turn" per lane). + Worth doing once real-world 4-camera 4K load is observed post-release. +- **`RequestVideoResend` shape.** Retry re-sends `lastMulticamClipURL`, which + holds only the *last* clip; it reuses `capture_id` loosely and depends on the + file still existing. Fine for one-clip-at-a-time retry; it would need a real + per-capture store to retry an older clip. +- **`PeerSessionCore` extraction pending.** Reconnect, the per-peer version + gate, capability parsing, and the frame pump exist in both + `SessionCoordinator` (camera + 1:1 monitor) and `MulticamController`. They + have begun to diverge; extracting a shared core is the highest-value + robustness refactor, deferred until after the 9.1 release so it doesn't ride + the same release as the feature.