@@ -42,6 +42,15 @@ final class ScreenCaptureController: NSObject {
4242 /// ~1s telemetry poll, live only. Cancelled on every teardown path (they all
4343 /// funnel through `stopCapture`).
4444 @ObservationIgnored private var statsTask : Task < Void , Never > ?
45+ /// Shared frame-drop / achieved-fps counters (issue #23 / M8), created per
46+ /// broadcast and handed to BOTH the capture output and the publisher so the four
47+ /// shed sites and the encoded rate land in one place. Read once per stats poll to
48+ /// fold measured fps + congestion drops into `liveStats`.
49+ @ObservationIgnored private var frameTelemetry : FrameTelemetry ?
50+ /// The previous telemetry snapshot + its capture time, so each poll derives
51+ /// achieved fps from the delta over the real elapsed window.
52+ @ObservationIgnored private var lastTelemetrySnapshot : FrameTelemetrySnapshot ?
53+ @ObservationIgnored private var lastTelemetryAt : UInt64 = 0
4554
4655 override init ( ) {
4756 super. init ( )
@@ -121,24 +130,56 @@ final class ScreenCaptureController: NSObject {
121130 // Write through INCLUDING nil: the publishers return nil during a
122131 // reconnect, which must clear the card to its "—" placeholders rather
123132 // than freezing on stale last-good telemetry while the stream is down.
124- self ? . updateLiveStats ( snapshot)
133+ self ? . applyPolledStats ( snapshot)
125134 try ? await Task . sleep ( for: . seconds( 1 ) )
126135 }
127136 }
128137 }
129138
139+ /// One stats tick: fold the measured frame telemetry (achieved fps + cumulative
140+ /// congestion drops) into the publisher's target-based snapshot, then store it.
141+ /// Advances the achieved-fps delta baseline as a side effect, so it must run
142+ /// exactly once per poll.
143+ private func applyPolledStats( _ base: LiveStats ? ) {
144+ updateLiveStats ( enrichWithTelemetry ( base) )
145+ }
146+
147+ /// Merges the shared `FrameTelemetry` into the publisher's snapshot: the measured
148+ /// achieved fps (from the encoded-frame delta over the real elapsed window) and
149+ /// the cumulative congestion drops. Returns `base` untouched when telemetry isn't
150+ /// available (pre-live / torn down). Mutates the delta baseline.
151+ private func enrichWithTelemetry( _ base: LiveStats ? ) -> LiveStats ? {
152+ guard let telemetry = frameTelemetry else { return base }
153+ let current = telemetry. snapshot ( )
154+ let now = DispatchTime . now ( ) . uptimeNanoseconds
155+ var achieved = 0
156+ if let previous = lastTelemetrySnapshot, lastTelemetryAt > 0 , now > lastTelemetryAt {
157+ let elapsed = Double ( now &- lastTelemetryAt) / 1_000_000_000
158+ achieved = FrameTelemetry . rate ( from: previous, to: current, elapsed: elapsed) . achievedFrameRate
159+ }
160+ // Advance the baseline every tick — even when `base` is nil (reconnecting) —
161+ // so the next window measures against a fresh, ~1s-old sample.
162+ lastTelemetrySnapshot = current
163+ lastTelemetryAt = now
164+ guard var stats = base else { return nil }
165+ stats. achievedFrameRate = achieved
166+ stats. droppedFrames = current. congestionDrops
167+ return stats
168+ }
169+
130170 /// Assigns the latest telemetry, skipping a redundant write (and the 1 Hz leaf
131171 /// re-render it would otherwise trigger) when the snapshot is unchanged.
132172 private func updateLiveStats( _ stats: LiveStats ? ) {
133173 if liveStats != stats { liveStats = stats }
134174 }
135175
136- private func makePublisher( for transport: StreamCore . StreamProtocol ) -> any Publisher {
176+ private func makePublisher( for transport: StreamCore . StreamProtocol ,
177+ telemetry: FrameTelemetry ) -> any Publisher {
137178 switch transport {
138179 case . rtmp, . rtmps:
139- RTMPPublisher ( )
180+ RTMPPublisher ( telemetry : telemetry )
140181 case . srt, . whip:
141- SessionPublisher ( protocol: transport)
182+ SessionPublisher ( protocol: transport, telemetry : telemetry )
142183 }
143184 }
144185
@@ -152,8 +193,15 @@ final class ScreenCaptureController: NSObject {
152193 return
153194 }
154195
155- let publisher = makePublisher ( for: settings. selectedProtocol)
156- let output = ScreenCaptureOutput ( publisher: publisher, settings: settings) { [ weak self] error in
196+ // One telemetry instance per broadcast, shared by the capture output (the
197+ // four shed sites) and the publisher (admission + encoded rate).
198+ let telemetry = FrameTelemetry ( )
199+ frameTelemetry = telemetry
200+ lastTelemetrySnapshot = nil
201+ lastTelemetryAt = 0
202+ let publisher = makePublisher ( for: settings. selectedProtocol, telemetry: telemetry)
203+ let output = ScreenCaptureOutput ( publisher: publisher, settings: settings,
204+ telemetry: telemetry) { [ weak self] error in
157205 Task { @MainActor [ weak self] in await self ? . captureDidStop ( error: error) }
158206 }
159207
@@ -228,6 +276,9 @@ final class ScreenCaptureController: NSObject {
228276 heartbeatTask = nil
229277 statsTask? . cancel ( )
230278 statsTask = nil
279+ frameTelemetry = nil
280+ lastTelemetrySnapshot = nil
281+ lastTelemetryAt = 0
231282 isLive = false
232283 thermalNotice = nil
233284 liveStats = nil
@@ -408,6 +459,10 @@ private final class ScreenCaptureOutput: NSObject, SCStreamOutput, SCStreamDeleg
408459 /// the capture pacing so a 1080p60 pick never asks a device for more than it
409460 /// can sustain. The thermal governor tightens this further at runtime.
410461 private let capability = StreamCapability . current
462+ /// Shared frame telemetry (issue #23 / M8): this output records the capture,
463+ /// pacing (PTS-deadline), backpressure (`bufferingNewest(1)`), and compositor
464+ /// (pool-exhaustion) sites on the serial sample queue.
465+ private let telemetry : FrameTelemetry
411466 private let onStopped : @Sendable ( Error ) -> Void
412467 private let micLevelMeter : ScreenCaptureMicrophoneMeter
413468 private let micLevelChannel = MicrophoneLevelChannel ( )
@@ -427,9 +482,11 @@ private final class ScreenCaptureOutput: NSObject, SCStreamOutput, SCStreamDeleg
427482
428483 init ( publisher: any Publisher ,
429484 settings: StreamSettings ,
485+ telemetry: FrameTelemetry ,
430486 onStopped: @escaping @Sendable ( Error ) -> Void ) {
431487 self . publisher = publisher
432488 self . settings = settings
489+ self . telemetry = telemetry
433490 self . onStopped = onStopped
434491 micLevelMeter = ScreenCaptureMicrophoneMeter ( gain: settings. micVolume)
435492 targetFrameInterval = CMTime ( value: 1 ,
@@ -454,22 +511,28 @@ private final class ScreenCaptureOutput: NSObject, SCStreamOutput, SCStreamDeleg
454511 await publisher. setOutputSize ( target, nativeShortEdge: min ( width, height) )
455512 targetSize = target
456513 }
457- if settings. pipEnabled, let targetSize,
458- let camera = self . facecam. latest. take ( ) ,
459- let composited = self . compositor. composite (
460- screen: image,
461- camera: camera,
462- targetSize: targetSize,
463- orientation: . up,
464- corner: settings. pipCorner,
465- scale: settings. pipScale,
466- cameraPosition: settings. cameraPosition
467- ) ,
468- let output = self . compositor. makeSampleBuffer (
469- from: composited,
470- timingSource: sampleBuffer
471- ) {
472- await publisher. appendVideo ( output)
514+ if settings. pipEnabled, let targetSize, let camera = self . facecam. latest. take ( ) {
515+ if let composited = self . compositor. composite (
516+ screen: image,
517+ camera: camera,
518+ targetSize: targetSize,
519+ orientation: . up,
520+ corner: settings. pipCorner,
521+ scale: settings. pipScale,
522+ cameraPosition: settings. cameraPosition
523+ ) ,
524+ let output = self . compositor. makeSampleBuffer (
525+ from: composited,
526+ timingSource: sampleBuffer
527+ ) {
528+ await publisher. appendVideo ( output)
529+ } else {
530+ // Pool exhausted (a slow encoder holding surfaces) or the wrap
531+ // failed: skip the overlay for this frame and send the raw
532+ // screen. The frame itself is NOT lost — the overlay is.
533+ self . telemetry. recordDrop ( . compositor)
534+ await publisher. appendVideo ( sampleBuffer)
535+ }
473536 } else {
474537 await publisher. appendVideo ( sampleBuffer)
475538 }
@@ -491,12 +554,16 @@ private final class ScreenCaptureOutput: NSObject, SCStreamOutput, SCStreamDeleg
491554 guard sampleBuffer. isValid, sampleBuffer. dataReadiness == . ready else { return }
492555 switch type {
493556 case . screen:
557+ telemetry. recordCaptured ( )
494558 // Downsample the native-refresh feed to the target frame rate by PTS
495559 // deadline: emit the first frame at/after each deadline, then advance
496560 // by one interval; re-anchor after a stall so a gap doesn't burst.
497561 let pts = sampleBuffer. presentationTimeStamp
498562 if pts. isValid {
499563 if nextVideoDeadline. isValid, CMTimeCompare ( pts, nextVideoDeadline) < 0 {
564+ // Intentional pacing shed (e.g. 120 Hz → 30 fps), not a fault —
565+ // counted separately from the congestion drops the HUD surfaces.
566+ telemetry. recordDrop ( . pacing)
500567 return // arrived before the next target-fps slot — drop it
501568 }
502569 let advanced = CMTimeAdd ( nextVideoDeadline. isValid ? nextVideoDeadline : pts,
@@ -505,7 +572,12 @@ private final class ScreenCaptureOutput: NSObject, SCStreamOutput, SCStreamDeleg
505572 ? advanced
506573 : CMTimeAdd ( pts, targetFrameInterval)
507574 }
508- videoContinuation. yield ( sampleBuffer)
575+ // `bufferingNewest(1)`: if the consumer (compositor/append) hasn't drained
576+ // the previous frame, this yield evicts it — a backpressure shed the
577+ // pipeline couldn't keep up with. `.dropped` carries that evicted frame.
578+ if case . dropped = videoContinuation. yield ( sampleBuffer) {
579+ telemetry. recordDrop ( . backpressure)
580+ }
509581 case . audio:
510582 if settings. includeAppAudio { publisher. enqueueApp ( sampleBuffer) }
511583 case . microphone:
0 commit comments