serve-sim-sjchmiela 0.1.49 → 0.1.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -58,11 +58,17 @@ final class CaptureEngine {
58
58
  private var h264BackpressureSkips: Int64 = 0
59
59
  private var mjpegThrottleSkips: Int64 = 0
60
60
  private var h264ThrottleSkips: Int64 = 0
61
+ private var webRTCReservedCount: Int64 = 0
62
+ private var webRTCThrottleSkips: Int64 = 0
63
+ private var webRTCCopyFailures: Int64 = 0
61
64
  private var avccNativeEmitCount: Int64 = 0
62
65
  private var lastMjpegReservedAtNs: UInt64 = 0
63
66
  private var lastH264ReservedAtNs: UInt64 = 0
67
+ private var lastWebRTCReservedAtNs: UInt64 = 0
64
68
  private var mjpegMinFrameIntervalNs: UInt64
65
69
  private var h264MinFrameIntervalNs: UInt64
70
+ private var webRTCMinFrameIntervalNs: UInt64
71
+ private var webRTCMaxFps: Int
66
72
  private var maxDimension: Int
67
73
  private var started = false
68
74
  private var stopped = false
@@ -82,8 +88,17 @@ final class CaptureEngine {
82
88
  h264Encoder = H264Encoder(fps: h264Fps, bitrate: h264Bitrate)
83
89
  mjpegMinFrameIntervalNs = UInt64(1_000_000_000 / mjpegFps)
84
90
  h264MinFrameIntervalNs = UInt64(1_000_000_000 / h264Fps)
91
+ webRTCMinFrameIntervalNs = UInt64(1_000_000_000 / h264Fps)
92
+ webRTCMaxFps = h264Fps
85
93
  maxDimension = max(0, options.maxDimension)
86
94
  webRTCPublisher.onInput = onWebRTCInput
95
+ webRTCPublisher.onActiveChanged = { [weak self] active in
96
+ guard let self else { return }
97
+ self.lastWebRTCReservedAtNs = 0
98
+ self.frameCapture.setIdleRefreshFps(active ? self.webRTCMaxFps : 5)
99
+ streamLog("[webrtc] active=\(active) idleRefreshFps=\(active ? self.webRTCMaxFps : 5)")
100
+ }
101
+ webRTCPublisher.updateSettings(maxFps: h264Fps, bitrate: h264Bitrate)
87
102
 
88
103
  h264Encoder.onEncoded = { [weak self] encoded in
89
104
  guard let self else { return }
@@ -144,7 +159,7 @@ final class CaptureEngine {
144
159
  }
145
160
 
146
161
  let h264Request = reserveH264EncodeIfNeeded()
147
- let shouldSendWebRTC = webRTCPublisher.isActive
162
+ let shouldSendWebRTC = reserveWebRTCFrameIfNeeded()
148
163
  let shouldEncodeJpeg = encoderReady && !encoding && reserveMjpegEncodeIfNeeded()
149
164
  if !shouldEncodeJpeg && h264Request == nil && !shouldSendWebRTC { return }
150
165
 
@@ -153,6 +168,12 @@ final class CaptureEngine {
153
168
  streamLog("[stream:avcc] failed to copy capture frame for H.264 token=\(h264Request.token)")
154
169
  finishH264Encode(token: h264Request.token, restoreKeyframe: h264Request.forceKeyframe)
155
170
  }
171
+ if shouldSendWebRTC {
172
+ webRTCCopyFailures += 1
173
+ if streamShouldLog(webRTCCopyFailures) {
174
+ streamLog("[webrtc] failed to copy capture frame for WebRTC")
175
+ }
176
+ }
156
177
  return
157
178
  }
158
179
 
@@ -336,6 +357,24 @@ final class CaptureEngine {
336
357
  }
337
358
  }
338
359
 
360
+ private func reserveWebRTCFrameIfNeeded() -> Bool {
361
+ guard webRTCPublisher.isActive else { return false }
362
+ let now = DispatchTime.now().uptimeNanoseconds
363
+ if lastWebRTCReservedAtNs != 0 && now - lastWebRTCReservedAtNs < webRTCMinFrameIntervalNs {
364
+ webRTCThrottleSkips += 1
365
+ if streamShouldLog(webRTCThrottleSkips) {
366
+ streamLog("[webrtc] skip frame: fps throttle")
367
+ }
368
+ return false
369
+ }
370
+ lastWebRTCReservedAtNs = now
371
+ webRTCReservedCount += 1
372
+ if streamShouldLog(webRTCReservedCount) {
373
+ streamLog("[webrtc] reserved frame #\(webRTCReservedCount)")
374
+ }
375
+ return true
376
+ }
377
+
339
378
  private func finishH264Encode(token: UInt64, restoreKeyframe: Bool = false) {
340
379
  h264Queue.async { [weak self] in
341
380
  guard let self, self.h264FrameToken == token else { return }
@@ -391,10 +430,17 @@ final class CaptureEngine {
391
430
  }
392
431
  h264Queue.sync {
393
432
  self.h264MinFrameIntervalNs = UInt64(1_000_000_000 / normalizedH264Fps)
433
+ self.webRTCMinFrameIntervalNs = UInt64(1_000_000_000 / normalizedH264Fps)
434
+ self.webRTCMaxFps = normalizedH264Fps
394
435
  self.h264Encoder.update(fps: normalizedH264Fps, bitrate: normalizedBitrate)
436
+ self.webRTCPublisher.updateSettings(maxFps: normalizedH264Fps, bitrate: normalizedBitrate)
437
+ if self.webRTCPublisher.isActive {
438
+ self.frameCapture.setIdleRefreshFps(normalizedH264Fps)
439
+ }
395
440
  self.forceKeyframe = true
396
441
  self.h264Encoding = false
397
442
  self.lastH264ReservedAtNs = 0
443
+ self.lastWebRTCReservedAtNs = 0
398
444
  }
399
445
  streamLog(
400
446
  "[stream] settings updated mjpegFps=\(normalizedMjpegFps) mjpegQuality=\(normalizedQuality) " +
@@ -405,6 +451,7 @@ final class CaptureEngine {
405
451
  func handleWebRTCOffer(_ offerJson: String) throws -> String {
406
452
  let request = try JSONDecoder().decode(WebRTCOfferPayload.self, from: Data(offerJson.utf8))
407
453
  let answer = try webRTCPublisher.handleOffer(request)
454
+ lastWebRTCReservedAtNs = 0
408
455
  let data = try JSONEncoder().encode(answer)
409
456
  return String(decoding: data, as: UTF8.self)
410
457
  }
@@ -20,6 +20,7 @@ final class FrameCapture {
20
20
  private var idleTimer: DispatchSourceTimer?
21
21
  private let captureQueue = DispatchQueue(label: "frame-capture", qos: .userInteractive)
22
22
  private var lastCaptureTimeMs: UInt64 = 0
23
+ private var idleIntervalMs: UInt64 = 200
23
24
  private var lastSeeds: [ObjectIdentifier: UInt32] = [:]
24
25
  private var rewireTickCount: Int = 0
25
26
  /// Interval at which the idle timer re-emits the current frame even when
@@ -32,7 +33,8 @@ final class FrameCapture {
32
33
  /// one subscriber is due for it — a late-joining relay subscriber on an
33
34
  /// idle sim never gets a cached frame to show.
34
35
  /// Re-emitting at ~5 fps fixes both without meaningful CPU cost.
35
- private static let idleIntervalMs: UInt64 = 200
36
+ private static let defaultIdleRefreshFps = 5
37
+ private static let idleTimerTickMs: UInt64 = 33
36
38
 
37
39
  private var descriptors: [NSObject] = []
38
40
  private var callbackUUIDs: [ObjectIdentifier: NSUUID] = [:]
@@ -61,6 +63,16 @@ final class FrameCapture {
61
63
  print("[capture] Frame callbacks registered (event-driven) + 5fps idle floor")
62
64
  }
63
65
 
66
+ func setIdleRefreshFps(_ fps: Int) {
67
+ let normalizedFps = max(Self.defaultIdleRefreshFps, min(120, fps))
68
+ let nextIntervalMs = max(1, UInt64(1_000 / normalizedFps))
69
+ captureQueue.async { [weak self] in
70
+ guard let self, self.idleIntervalMs != nextIntervalMs else { return }
71
+ self.idleIntervalMs = nextIntervalMs
72
+ streamLog("[capture] Idle refresh fps=\(normalizedFps) intervalMs=\(nextIntervalMs)")
73
+ }
74
+ }
75
+
64
76
  /// Find all framebuffer display descriptors, register callbacks on each,
65
77
  /// and cache them. Safe to re-call if the cached descriptors become stale.
66
78
  ///
@@ -191,12 +203,12 @@ final class FrameCapture {
191
203
 
192
204
  private func startIdleTimer() {
193
205
  let timer = DispatchSource.makeTimerSource(queue: captureQueue)
194
- timer.schedule(deadline: .now().advanced(by: .milliseconds(Int(Self.idleIntervalMs))),
195
- repeating: .milliseconds(Int(Self.idleIntervalMs)))
206
+ timer.schedule(deadline: .now().advanced(by: .milliseconds(Int(Self.idleTimerTickMs))),
207
+ repeating: .milliseconds(Int(Self.idleTimerTickMs)))
196
208
  timer.setEventHandler { [weak self] in
197
209
  guard let self else { return }
198
210
  let nowMs = DispatchTime.now().uptimeNanoseconds / 1_000_000
199
- if (nowMs - self.lastCaptureTimeMs) >= Self.idleIntervalMs {
211
+ if (nowMs - self.lastCaptureTimeMs) >= self.idleIntervalMs {
200
212
  self.captureFrame()
201
213
  }
202
214
  // Self-heal: if we've never captured a frame, the cached descriptor
@@ -236,7 +248,7 @@ final class FrameCapture {
236
248
  let nowMs = DispatchTime.now().uptimeNanoseconds / 1_000_000
237
249
  let sinceLastMs = nowMs &- lastCaptureTimeMs
238
250
  let seedChanged = lastSeeds[key] != seed
239
- let idleRefreshDue = frameCount > 0 && sinceLastMs >= Self.idleIntervalMs
251
+ let idleRefreshDue = frameCount > 0 && sinceLastMs >= idleIntervalMs
240
252
  if frameCount > 0, !seedChanged, !idleRefreshDue { return }
241
253
  lastSeeds[key] = seed
242
254
 
@@ -260,7 +272,13 @@ final class FrameCapture {
260
272
 
261
273
  lastCaptureTimeMs = nowMs
262
274
  frameCount += 1
263
- let timestamp = CMTime(value: CMTimeValue(frameCount), timescale: 60)
275
+ let nowNs = DispatchTime.now().uptimeNanoseconds
276
+ let timestamp = CMTime(value: CMTimeValue(nowNs), timescale: 1_000_000_000)
277
+ if streamShouldLog(Int64(frameCount)) {
278
+ let reason = seedChanged ? "changed" : "idle"
279
+ let interval = frameCount == 1 ? 0 : sinceLastMs
280
+ streamLog("[capture] frame #\(frameCount) reason=\(reason) intervalMs=\(interval) size=\(w)x\(h)")
281
+ }
264
282
  onFrame?(pb, timestamp)
265
283
  }
266
284
 
@@ -23,9 +23,10 @@ struct WebRTCAnswerPayload: Codable {
23
23
 
24
24
  final class WebRTCPublisher {
25
25
  var onInput: ((Data) -> Void)?
26
+ var onActiveChanged: ((Bool) -> Void)?
26
27
 
27
28
  private let queue = DispatchQueue(label: "webrtc-publisher")
28
- private let factory = LKRTCPeerConnectionFactory()
29
+ private let factory: LKRTCPeerConnectionFactory
29
30
  private let videoSource: LKRTCVideoSource
30
31
  private let videoTrack: LKRTCVideoTrack
31
32
  private let capturer: LKRTCVideoCapturer
@@ -34,16 +35,49 @@ final class WebRTCPublisher {
34
35
  private var lastOutputHeight = 0
35
36
  private var sentFrameCount: Int64 = 0
36
37
  private var lastFrameTimestampNs: Int64 = 0
38
+ private var loggedInputFormat = false
39
+ private var maxFps = 30
40
+ private var targetBitrate = 6_000_000
37
41
  var isActive: Bool {
38
42
  queue.sync { session != nil }
39
43
  }
40
44
 
41
45
  init() {
46
+ let encoderFactory = LKRTCDefaultVideoEncoderFactory()
47
+ let decoderFactory = LKRTCDefaultVideoDecoderFactory()
48
+ factory = LKRTCPeerConnectionFactory(
49
+ encoderFactory: encoderFactory,
50
+ decoderFactory: decoderFactory
51
+ )
42
52
  videoSource = factory.videoSource(forScreenCast: true)
43
53
  videoTrack = factory.videoTrack(with: videoSource, trackId: "simulator-video")
44
54
  videoTrack.isEnabled = true
45
55
  capturer = LKRTCVideoCapturer(delegate: videoSource)
46
- print("[webrtc] Publisher ready (factory + screen-cast video source)")
56
+ print(
57
+ "[webrtc] Publisher ready (default codec factory + screen-cast video source) " +
58
+ "senderCodecs=\(senderCodecSummary())"
59
+ )
60
+ }
61
+
62
+ func updateSettings(maxFps: Int, bitrate: Int) {
63
+ let normalizedFps = max(1, min(120, maxFps))
64
+ let normalizedBitrate = max(100_000, bitrate)
65
+ queue.async {
66
+ guard self.maxFps != normalizedFps || self.targetBitrate != normalizedBitrate else { return }
67
+ self.maxFps = normalizedFps
68
+ self.targetBitrate = normalizedBitrate
69
+ if self.lastOutputWidth > 0, self.lastOutputHeight > 0 {
70
+ self.videoSource.adaptOutputFormat(
71
+ toWidth: Int32(self.lastOutputWidth),
72
+ height: Int32(self.lastOutputHeight),
73
+ fps: Int32(self.maxFps)
74
+ )
75
+ }
76
+ if let session = self.session {
77
+ self.applyBitrateSettings(to: session)
78
+ }
79
+ print("[webrtc] Settings updated fps=\(normalizedFps) bitrate=\(normalizedBitrate)")
80
+ }
47
81
  }
48
82
 
49
83
  func handleOffer(_ request: WebRTCOfferPayload) throws -> WebRTCAnswerPayload {
@@ -67,19 +101,36 @@ final class WebRTCPublisher {
67
101
  if width != self.lastOutputWidth || height != self.lastOutputHeight {
68
102
  self.lastOutputWidth = width
69
103
  self.lastOutputHeight = height
70
- self.videoSource.adaptOutputFormat(toWidth: Int32(width), height: Int32(height), fps: 30)
71
- print("[webrtc] Video source output format: \(width)x\(height) @ 30fps")
104
+ self.videoSource.adaptOutputFormat(
105
+ toWidth: Int32(width),
106
+ height: Int32(height),
107
+ fps: Int32(self.maxFps)
108
+ )
109
+ print("[webrtc] Video source output format: \(width)x\(height) @ \(self.maxFps)fps")
110
+ }
111
+ if !self.loggedInputFormat {
112
+ self.loggedInputFormat = true
113
+ let pixelFormat = CVPixelBufferGetPixelFormatType(pixelBuffer)
114
+ let supported = LKRTCCVPixelBuffer.supportedPixelFormats()
115
+ .contains(NSNumber(value: UInt32(pixelFormat)))
116
+ print("[webrtc] Input pixel format: \(pixelFormat) cvPixelBufferSupported=\(supported); forwarding as I420")
72
117
  }
73
118
  let timeNs = self.nextFrameTimestampNs(timestamp)
74
- let frame = LKRTCVideoFrame(
119
+ let cvFrame = LKRTCVideoFrame(
75
120
  buffer: LKRTCCVPixelBuffer(pixelBuffer: pixelBuffer),
76
121
  rotation: ._0,
77
122
  timeStampNs: timeNs
78
123
  )
124
+ let convertStartNs = DispatchTime.now().uptimeNanoseconds
125
+ let frame = cvFrame.newI420()
79
126
  self.videoSource.capturer(self.capturer, didCapture: frame)
127
+ let convertDurationMs = Double(DispatchTime.now().uptimeNanoseconds - convertStartNs) / 1_000_000.0
80
128
  self.sentFrameCount += 1
81
129
  if self.shouldLogFrame(self.sentFrameCount) {
82
- print("[webrtc] Sent video frame #\(self.sentFrameCount) size=\(width)x\(height) timestampNs=\(timeNs)")
130
+ print(
131
+ "[webrtc] Sent video frame #\(self.sentFrameCount) size=\(width)x\(height) " +
132
+ "timestampNs=\(timeNs) i420Ms=\(String(format: "%.2f", convertDurationMs))"
133
+ )
83
134
  }
84
135
  }
85
136
  }
@@ -98,6 +149,7 @@ final class WebRTCPublisher {
98
149
  queue.sync {
99
150
  session?.close()
100
151
  session = nil
152
+ onActiveChanged?(false)
101
153
  }
102
154
  }
103
155
 
@@ -127,6 +179,8 @@ final class WebRTCPublisher {
127
179
  )
128
180
  let delegate = WebRTCSessionDelegate(onInput: { [weak self] data in
129
181
  self?.onInput?(data)
182
+ }, onClosed: { [weak self] peerConnection in
183
+ self?.clearSession(peerConnection)
130
184
  })
131
185
  guard let peerConnection = factory.peerConnection(
132
186
  with: config,
@@ -140,6 +194,7 @@ final class WebRTCPublisher {
140
194
  let session = WebRTCSession(peerConnection: peerConnection, delegate: delegate)
141
195
  self.session?.close()
142
196
  self.session = session
197
+ onActiveChanged?(true)
143
198
 
144
199
  let remoteDescription = LKRTCSessionDescription(type: .offer, sdp: request.sdp)
145
200
  peerConnection.setRemoteDescription(remoteDescription) { error in
@@ -207,6 +262,11 @@ final class WebRTCPublisher {
207
262
  print("[webrtc] Failed to set video transceiver direction: \(directionError.localizedDescription)")
208
263
  }
209
264
  applyVideoCodecPreference(codec, to: transceiver)
265
+ let session = self.session
266
+ session?.videoSender = transceiver.sender
267
+ if let session {
268
+ applyBitrateSettings(to: session)
269
+ }
210
270
  }
211
271
 
212
272
  private func createFallbackVideoTransceiver(on peerConnection: LKRTCPeerConnection) -> LKRTCRtpTransceiver? {
@@ -414,6 +474,51 @@ final class WebRTCPublisher {
414
474
  print("[webrtc] Preferred video codec: \(preferredName)")
415
475
  }
416
476
 
477
+ private func applyBitrateSettings(to session: WebRTCSession) {
478
+ guard let sender = session.videoSender else { return }
479
+ let parameters = sender.parameters
480
+ let encodings = parameters.encodings.isEmpty
481
+ ? [LKRTCRtpEncodingParameters()]
482
+ : parameters.encodings
483
+ let maxBitrate = NSNumber(value: targetBitrate)
484
+ let minBitrate = NSNumber(value: max(100_000, targetBitrate / 4))
485
+ let fps = NSNumber(value: maxFps)
486
+ for encoding in encodings {
487
+ encoding.isActive = true
488
+ encoding.maxBitrateBps = maxBitrate
489
+ encoding.minBitrateBps = minBitrate
490
+ encoding.maxFramerate = fps
491
+ }
492
+ parameters.encodings = encodings
493
+ sender.parameters = parameters
494
+ let bweUpdated = session.peerConnection.setBweMinBitrateBps(
495
+ minBitrate,
496
+ currentBitrateBps: maxBitrate,
497
+ maxBitrateBps: maxBitrate
498
+ )
499
+ print(
500
+ "[webrtc] Sender parameters fps=\(maxFps) minBitrate=\(minBitrate) " +
501
+ "maxBitrate=\(maxBitrate) bweUpdated=\(bweUpdated)"
502
+ )
503
+ }
504
+
505
+ private func clearSession(_ peerConnection: LKRTCPeerConnection?) {
506
+ queue.async {
507
+ guard let session = self.session, session.peerConnection === peerConnection else { return }
508
+ session.close()
509
+ self.session = nil
510
+ self.onActiveChanged?(false)
511
+ print("[webrtc] Peer connection closed; publisher inactive")
512
+ }
513
+ }
514
+
515
+ private func senderCodecSummary() -> String {
516
+ let names = factory.rtpSenderCapabilities(forKind: "video").codecs.map { capability in
517
+ capability.mimeType.isEmpty ? capability.name : capability.mimeType
518
+ }
519
+ return names.joined(separator: ",")
520
+ }
521
+
417
522
  private func makeError(_ message: String) -> Error {
418
523
  NSError(domain: "serve-sim.webrtc", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
419
524
  }
@@ -426,6 +531,7 @@ final class WebRTCPublisher {
426
531
  private final class WebRTCSession {
427
532
  let peerConnection: LKRTCPeerConnection
428
533
  let delegate: WebRTCSessionDelegate
534
+ var videoSender: LKRTCRtpSender?
429
535
  private let iceGatheringTimeout: DispatchTimeInterval = .milliseconds(3_000)
430
536
 
431
537
  init(peerConnection: LKRTCPeerConnection, delegate: WebRTCSessionDelegate) {
@@ -466,14 +572,16 @@ private final class WebRTCSession {
466
572
 
467
573
  private final class WebRTCSessionDelegate: NSObject, LKRTCPeerConnectionDelegate, LKRTCDataChannelDelegate {
468
574
  private let onInput: (Data) -> Void
575
+ private let onClosed: (LKRTCPeerConnection) -> Void
469
576
  private let iceGatheringCompleteHandlerLock = NSLock()
470
577
  private var iceGatheringCompleteHandler: (() -> Void)?
471
578
  private let candidatesLock = NSLock()
472
579
  private var generatedCandidates: [LKRTCIceCandidate] = []
473
580
  private var statsScheduled = false
474
581
 
475
- init(onInput: @escaping (Data) -> Void) {
582
+ init(onInput: @escaping (Data) -> Void, onClosed: @escaping (LKRTCPeerConnection) -> Void) {
476
583
  self.onInput = onInput
584
+ self.onClosed = onClosed
477
585
  }
478
586
 
479
587
  func peerConnection(_ peerConnection: LKRTCPeerConnection, didChange stateChanged: LKRTCSignalingState) {}
@@ -484,6 +592,14 @@ private final class WebRTCSessionDelegate: NSObject, LKRTCPeerConnectionDelegate
484
592
  print("[webrtc] ICE connection state: \(newState.rawValue)")
485
593
  if newState == .connected || newState == .completed {
486
594
  scheduleOutboundStats(peerConnection)
595
+ } else if newState == .failed || newState == .closed {
596
+ onClosed(peerConnection)
597
+ }
598
+ }
599
+ func peerConnection(_ peerConnection: LKRTCPeerConnection, didChange newState: LKRTCPeerConnectionState) {
600
+ print("[webrtc] Peer connection state: \(newState.rawValue)")
601
+ if newState == .failed || newState == .closed {
602
+ onClosed(peerConnection)
487
603
  }
488
604
  }
489
605
  func peerConnection(_ peerConnection: LKRTCPeerConnection, didChange newState: LKRTCIceGatheringState) {