/// Counts received and missing video frames from NDI timestamps. /// /// A gap larger than 1.5 frame durations counts the frames that should have arrived in between. /// Gaps longer than `stallThreshold` are treated as an outage (reconnect), not as lost frames. public struct FrameStats: Sendable, Equatable { public private(set) var received: Int64 = 0 public private(set) var missing: Int64 = 0 public private(set) var droppedByReceiver: Int64 = 0 private var lastTimestamp: Int64? /// NDI timestamps are in 100 ns units. public static let ticksPerSecond: Double = 10_000_000 public var stallThreshold: Double = 2.0 // seconds public init() {} /// Lost frames relative to all frames that should have been shown. public var lossRate: Double { let lost = missing + droppedByReceiver let expected = received + lost return expected == 0 ? 0 : Double(lost) / Double(expected) } public mutating func record(timestamp: Int64, frameRateN: Int32, frameRateD: Int32) { received += 1 defer { lastTimestamp = timestamp } guard let last = lastTimestamp, frameRateN > 0, frameRateD > 0 else { return } let delta = Double(timestamp - last) / Self.ticksPerSecond let frameDuration = Double(frameRateD) / Double(frameRateN) guard delta > frameDuration * 1.5, delta < stallThreshold else { return } missing += Int64((delta / frameDuration).rounded()) - 1 } /// Total frames NDI itself dropped because we did not capture fast enough. public mutating func updateDroppedByReceiver(_ total: Int64) { droppedByReceiver = total } /// Call after a reconnect so the outage is not counted as lost frames. public mutating func resetTimeline() { lastTimestamp = nil } }