Skip to content
Open
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
8 changes: 8 additions & 0 deletions RemoteCam/CameraControlling.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ 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?)
/// 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
Expand Down
92 changes: 92 additions & 0 deletions RemoteCam/CameraLink.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//
// 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 {

/// 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
/// 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

/// 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 is rolling as part of a synced recording — drives the tile's
/// 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?

/// 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

/// 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.
let receiver = FrameStreamReceiver()

init(peerID: MCPeerID) {
self.peerID = peerID
self.displayName = peerID.displayName
}
}
8 changes: 8 additions & 0 deletions RemoteCam/CameraRig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,14 @@ extension CameraRig: CameraControlling {
pipeline.stopRecording(shouldSendVideo)
}

func setVideoSyncMetadata(_ metadata: CaptureSyncMetadata?) {
pipeline.pendingSyncMetadata = metadata
}

func applyStreamProfile(_ profile: StreamProfile) {
streamingCoordinator.applyStreamProfile(profile)
}

func updateTimerCountdown(value: Int) {
OperationQueue.main.addOperation {
if value > 0 {
Expand Down
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
139 changes: 139 additions & 0 deletions RemoteCam/CaptureSyncMetadata.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//
// CaptureSyncMetadata.swift
// RemoteShutter
//
// Created by Dario Lencina on 2026.
// Copyright © 2026 Security Union. All rights reserved.
//

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
/// 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)
}

/// Re-encode `imageData` with this shot's sync fields embedded in EXIF, so
/// 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 }

var properties = (CGImageSourceCopyPropertiesAtIndex(source, 0, nil)
as? [CFString: Any]) ?? [:]
var exif = (properties[kCGImagePropertyExifDictionary] as? [CFString: Any]) ?? [:]

if let json = jsonString() {
exif[kCGImagePropertyExifUserComment] = json
}
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()
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_<sess>_<cap>_cam2.heic`. Groups a capture's files across cameras.
func photoFilename(isHEIC: Bool) -> String {
"\(filenamePrefix).\(isHEIC ? "heic" : "jpg")"
}

/// A Photos `originalFilename` for this clip, e.g. `RS_<sess>_<cap>_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()
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()
}

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
}
}
74 changes: 74 additions & 0 deletions RemoteCam/ClockOffsetEstimator.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading
Loading