Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions RemoteCam/CaptureEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
88 changes: 88 additions & 0 deletions RemoteCam/CaptureSyncMetadata.swift
Original file line number Diff line number Diff line change
@@ -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_<sess>_<cap>_cam<k>` — 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
}
}
6 changes: 6 additions & 0 deletions RemoteCam/FeatureFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions RemoteCam/FlatBufferSchemas.fbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions RemoteCam/FlatBufferSchemas_generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand All @@ -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) }
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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)
}

Expand All @@ -1061,6 +1067,7 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable {
try _v.visit(field: VTOFFSET.activeDeviceId.p, fieldName: "activeDeviceId", required: false, type: ForwardOffset<String>.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()
}
}
Expand Down
4 changes: 3 additions & 1 deletion RemoteCam/RemoteCmdFlatBuffers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
)
Expand Down
6 changes: 6 additions & 0 deletions RemoteCam/RemoteCmds.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down
73 changes: 73 additions & 0 deletions RemoteCamTests/CaptureSyncMetadataTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
16 changes: 16 additions & 0 deletions RemoteCamTests/RemoteCmdSerializationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
8 changes: 8 additions & 0 deletions RemoteShutter.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand Down Expand Up @@ -442,6 +444,8 @@
FADEC0DE0002000000000001 /* PeerLinkStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerLinkStatus.swift; sourceTree = "<group>"; };
FC0CF5A201FE65A9800F0B238 /* FocusPointMapping.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FocusPointMapping.swift; sourceTree = "<group>"; };
CAFEBABE0120000000000002 /* MonitorChrome.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MonitorChrome.swift; sourceTree = "<group>"; };
CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CaptureSyncMetadata.swift; sourceTree = "<group>"; };
CAFEBABE0131000000000001 /* CaptureSyncMetadataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptureSyncMetadataTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
/* End PBXFileReference section */
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -683,6 +688,7 @@
0684A2D01BE65A9800F0B238 /* OrientationUtils.swift */,
FC0CF5A201FE65A9800F0B238 /* FocusPointMapping.swift */,
CAFEBABE0120000000000002 /* MonitorChrome.swift */,
CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */,
060B1E141BE7079800077BCC /* Helpers */,
06E965202535199400E5A8B3 /* MediaProcessors.swift */,
06E9652625351E3F00E5A8B3 /* SwiftConstants.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
Loading