serve-sim-sjchmiela 0.1.39 → 0.1.41

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.
@@ -15,6 +15,13 @@ import CoreMedia
15
15
  typealias SimFrameCallback = (Int32, Data, Int32, Int32, Int32) -> Void
16
16
  typealias SimInputCallback = (Data) -> Void
17
17
 
18
+ struct CaptureEngineOptions {
19
+ let mjpegFps: Int
20
+ let mjpegQuality: Double
21
+ let h264Fps: Int
22
+ let h264Bitrate: Int
23
+ }
24
+
18
25
  final class CaptureEngine {
19
26
  static let codecMJPEG: Int32 = 0
20
27
  static let codecAVCC: Int32 = 1
@@ -26,8 +33,8 @@ final class CaptureEngine {
26
33
  private let webRTCPublisher = WebRTCPublisher()
27
34
 
28
35
  private let frameCapture = FrameCapture()
29
- private let videoEncoder = VideoEncoder(quality: 0.7)
30
- private let h264Encoder = H264Encoder(fps: 60)
36
+ private let videoEncoder: VideoEncoder
37
+ private let h264Encoder: H264Encoder
31
38
  private let encodeQueue = DispatchQueue(label: "napi.encode", qos: .userInteractive)
32
39
  private let h264Queue = DispatchQueue(label: "napi.encode.h264", qos: .userInteractive)
33
40
  private static let h264EncodeTimeoutMs = 500
@@ -42,26 +49,55 @@ final class CaptureEngine {
42
49
  private var forceKeyframe = false
43
50
  private var avccActive = false
44
51
  private var h264FrameToken: UInt64 = 0
52
+ private var h264ReservedCount: Int64 = 0
53
+ private var h264SubmittedCount: Int64 = 0
54
+ private var h264BackpressureSkips: Int64 = 0
55
+ private var mjpegThrottleSkips: Int64 = 0
56
+ private var h264ThrottleSkips: Int64 = 0
57
+ private var avccNativeEmitCount: Int64 = 0
58
+ private var lastMjpegReservedAtNs: UInt64 = 0
59
+ private var lastH264ReservedAtNs: UInt64 = 0
60
+ private let mjpegMinFrameIntervalNs: UInt64
61
+ private let h264MinFrameIntervalNs: UInt64
45
62
  private var started = false
46
63
  private var stopped = false
47
64
 
48
- init(deviceUDID: String, onFrame: @escaping SimFrameCallback, onWebRTCInput: @escaping SimInputCallback) {
65
+ init(
66
+ deviceUDID: String,
67
+ options: CaptureEngineOptions,
68
+ onFrame: @escaping SimFrameCallback,
69
+ onWebRTCInput: @escaping SimInputCallback
70
+ ) {
49
71
  self.deviceUDID = deviceUDID
50
72
  self.onFrame = onFrame
73
+ let mjpegFps = max(1, options.mjpegFps)
74
+ let h264Fps = max(1, options.h264Fps)
75
+ let h264Bitrate = max(1, options.h264Bitrate)
76
+ videoEncoder = VideoEncoder(quality: CGFloat(min(max(options.mjpegQuality, 0.0), 1.0)))
77
+ h264Encoder = H264Encoder(fps: h264Fps, bitrate: h264Bitrate)
78
+ mjpegMinFrameIntervalNs = UInt64(1_000_000_000 / mjpegFps)
79
+ h264MinFrameIntervalNs = UInt64(1_000_000_000 / h264Fps)
51
80
  webRTCPublisher.onInput = onWebRTCInput
52
81
 
53
82
  h264Encoder.onEncoded = { [weak self] encoded in
54
83
  guard let self else { return }
84
+ self.avccNativeEmitCount += 1
85
+ let emitCount = self.avccNativeEmitCount
55
86
  if let description = encoded.description {
87
+ streamLog("[stream:avcc] native emit description bytes=\(description.count)")
56
88
  self.emit(codec: Self.codecAVCC,
57
89
  data: AVCCEnvelope.description(avcc: description),
58
90
  flags: Self.flagDescription)
59
91
  }
60
92
  switch encoded.kind {
61
93
  case .keyframe:
94
+ streamLog("[stream:avcc] native emit keyframe bytes=\(encoded.avcc.count)")
62
95
  self.emit(codec: Self.codecAVCC, data: AVCCEnvelope.keyframe(avcc: encoded.avcc),
63
96
  flags: Self.flagKeyframe)
64
97
  case .delta:
98
+ if streamShouldLog(emitCount) {
99
+ streamLog("[stream:avcc] native emit delta #\(emitCount) bytes=\(encoded.avcc.count)")
100
+ }
65
101
  self.emit(codec: Self.codecAVCC, data: AVCCEnvelope.delta(avcc: encoded.avcc), flags: 0)
66
102
  }
67
103
  }
@@ -100,11 +136,12 @@ final class CaptureEngine {
100
136
 
101
137
  let h264Request = reserveH264EncodeIfNeeded()
102
138
  let shouldSendWebRTC = webRTCPublisher.isActive
103
- let shouldEncodeJpeg = encoderReady && !encoding
139
+ let shouldEncodeJpeg = encoderReady && !encoding && reserveMjpegEncodeIfNeeded()
104
140
  if !shouldEncodeJpeg && h264Request == nil && !shouldSendWebRTC { return }
105
141
 
106
142
  guard let stableFrame = copyPixelBuffer(pixelBuffer) else {
107
143
  if let h264Request {
144
+ streamLog("[stream:avcc] failed to copy capture frame for H.264 token=\(h264Request.token)")
108
145
  finishH264Encode(token: h264Request.token, restoreKeyframe: h264Request.forceKeyframe)
109
146
  }
110
147
  return
@@ -128,6 +165,14 @@ final class CaptureEngine {
128
165
  if let h264Request {
129
166
  h264Queue.async { [weak self] in
130
167
  guard let self else { return }
168
+ self.h264SubmittedCount += 1
169
+ let submitted = self.h264SubmittedCount
170
+ if streamShouldLog(submitted) || h264Request.forceKeyframe {
171
+ streamLog(
172
+ "[stream:avcc] send frame to H264Encoder #\(submitted) token=\(h264Request.token) " +
173
+ "forceKeyframe=\(h264Request.forceKeyframe)"
174
+ )
175
+ }
131
176
  self.h264Encoder.encode(stableFrame, forceKeyframe: h264Request.forceKeyframe) {
132
177
  self.finishH264Encode(token: h264Request.token)
133
178
  }
@@ -136,6 +181,19 @@ final class CaptureEngine {
136
181
  }
137
182
  }
138
183
 
184
+ private func reserveMjpegEncodeIfNeeded() -> Bool {
185
+ let now = DispatchTime.now().uptimeNanoseconds
186
+ if lastMjpegReservedAtNs != 0 && now - lastMjpegReservedAtNs < mjpegMinFrameIntervalNs {
187
+ mjpegThrottleSkips += 1
188
+ if streamShouldLog(mjpegThrottleSkips) {
189
+ streamLog("[stream:mjpeg] skip frame: fps throttle")
190
+ }
191
+ return false
192
+ }
193
+ lastMjpegReservedAtNs = now
194
+ return true
195
+ }
196
+
139
197
  /// Copy the live Simulator IOSurface immediately on the capture queue. The
140
198
  /// encoders run later and SimulatorKit recycles/mutates that IOSurface in
141
199
  /// place, so passing the wrapper CVPixelBuffer across queues can encode a
@@ -176,12 +234,32 @@ final class CaptureEngine {
176
234
 
177
235
  private func reserveH264EncodeIfNeeded() -> (forceKeyframe: Bool, token: UInt64)? {
178
236
  h264Queue.sync {
179
- guard avccActive, !h264Encoding else { return nil }
237
+ guard avccActive else { return nil }
238
+ let now = DispatchTime.now().uptimeNanoseconds
239
+ if !forceKeyframe && lastH264ReservedAtNs != 0 && now - lastH264ReservedAtNs < h264MinFrameIntervalNs {
240
+ h264ThrottleSkips += 1
241
+ if streamShouldLog(h264ThrottleSkips) {
242
+ streamLog("[stream:avcc] skip H.264 frame: fps throttle")
243
+ }
244
+ return nil
245
+ }
246
+ guard !h264Encoding else {
247
+ h264BackpressureSkips += 1
248
+ if streamShouldLog(h264BackpressureSkips) {
249
+ streamLog("[stream:avcc] skip H.264 frame: encode pending token=\(h264FrameToken)")
250
+ }
251
+ return nil
252
+ }
180
253
  h264Encoding = true
181
254
  h264FrameToken &+= 1
182
255
  let token = h264FrameToken
183
256
  let force = forceKeyframe
184
257
  forceKeyframe = false
258
+ lastH264ReservedAtNs = now
259
+ h264ReservedCount += 1
260
+ if streamShouldLog(h264ReservedCount) || force {
261
+ streamLog("[stream:avcc] reserved H.264 frame #\(h264ReservedCount) token=\(token) forceKeyframe=\(force)")
262
+ }
185
263
  return (forceKeyframe: force, token: token)
186
264
  }
187
265
  }
@@ -196,8 +274,10 @@ final class CaptureEngine {
196
274
 
197
275
  private func scheduleH264EncodeTimeout(token: UInt64) {
198
276
  h264Queue.asyncAfter(deadline: .now().advanced(by: .milliseconds(Self.h264EncodeTimeoutMs))) { [weak self] in
199
- guard let self, self.h264FrameToken == token else { return }
277
+ guard let self, self.h264FrameToken == token, self.h264Encoding else { return }
200
278
  self.h264Encoding = false
279
+ self.h264Encoder.handleEncodeTimeout()
280
+ streamLog("[stream:avcc] H.264 encode timed out token=\(token)")
201
281
  }
202
282
  }
203
283
 
@@ -207,12 +287,18 @@ final class CaptureEngine {
207
287
  h264Queue.async { [weak self] in
208
288
  guard let self else { return }
209
289
  if active && !self.avccActive { self.forceKeyframe = true }
290
+ if active != self.avccActive {
291
+ streamLog("[stream:avcc] active=\(active) forceKeyframe=\(self.forceKeyframe)")
292
+ }
210
293
  self.avccActive = active
211
294
  }
212
295
  }
213
296
 
214
297
  func requestKeyframe() {
215
- h264Queue.async { [weak self] in self?.forceKeyframe = true }
298
+ h264Queue.async { [weak self] in
299
+ self?.forceKeyframe = true
300
+ streamLog("[stream:avcc] keyframe requested")
301
+ }
216
302
  }
217
303
 
218
304
  func handleWebRTCOffer(_ offerJson: String) throws -> String {
@@ -99,7 +99,15 @@ private func u32(_ v: Int) -> UInt32 {
99
99
  private let queue: NodeAsyncQueue
100
100
  private let inputQueue: NodeAsyncQueue
101
101
 
102
- @NodeConstructor init(_ udid: String, _ onFrame: NodeFunction, _ onWebRTCInput: NodeFunction) throws {
102
+ @NodeConstructor init(
103
+ _ udid: String,
104
+ _ onFrame: NodeFunction,
105
+ _ onWebRTCInput: NodeFunction,
106
+ _ mjpegFps: Int,
107
+ _ mjpegQuality: Double,
108
+ _ h264Fps: Int,
109
+ _ h264Bitrate: Int
110
+ ) throws {
103
111
  // unref'd by NodeAsyncQueue's init, so the frame pipeline alone won't
104
112
  // keep the event loop alive. Bounded queue + blocking AVCC preserves
105
113
  // inter-frame ordering; MJPEG is nonblocking and drops under backpressure.
@@ -112,7 +120,12 @@ private func u32(_ v: Int) -> UInt32 {
112
120
 
113
121
  // Capture the locals (not self) so the closure can be built before the
114
122
  // engine property is initialized, and so it holds no strong ref to self.
115
- engine = CaptureEngine(deviceUDID: udid, onFrame: { codec, data, w, h, flags in
123
+ engine = CaptureEngine(deviceUDID: udid, options: CaptureEngineOptions(
124
+ mjpegFps: mjpegFps,
125
+ mjpegQuality: mjpegQuality,
126
+ h264Fps: h264Fps,
127
+ h264Bitrate: h264Bitrate
128
+ ), onFrame: { codec, data, w, h, flags in
116
129
  // Runs on a native encode thread. AVCC is inter-frame H.264 — dropping
117
130
  // a delta corrupts the decoder until the next IDR — so deliver it
118
131
  // blocking; MJPEG is stateless and safe to drop. We copy the bytes
@@ -33,6 +33,10 @@ final class H264Encoder {
33
33
  private let stateQueue = DispatchQueue(label: "H264Encoder.state")
34
34
  private var emittedDescription = false
35
35
  private var frameCount: Int64 = 0
36
+ private var encodedCount: Int64 = 0
37
+ private var lowLatencyEnabled = true
38
+ private var forceKeyframeAfterReset = false
39
+ private var retiredSessions: [VTCompressionSession] = []
36
40
 
37
41
  init(fps: Int = 60, bitrate: Int = 6_000_000) {
38
42
  self.fps = Int32(fps)
@@ -41,6 +45,7 @@ final class H264Encoder {
41
45
 
42
46
  deinit {
43
47
  if let session { VTCompressionSessionInvalidate(session) }
48
+ for session in retiredSessions { VTCompressionSessionInvalidate(session) }
44
49
  }
45
50
 
46
51
  /// Submit a frame. Returns immediately; `onEncoded` fires on VT's queue.
@@ -53,17 +58,33 @@ final class H264Encoder {
53
58
  height = h
54
59
  rebuildSession()
55
60
  }
56
- guard let session, let copy = copyBuffer(source) else {
61
+ guard let session else {
62
+ streamLog("[stream:h264] drop frame: VTCompressionSession unavailable size=\(w)x\(h)")
63
+ lock.unlock()
64
+ completion?()
65
+ return
66
+ }
67
+ guard let copy = copyBuffer(source) else {
68
+ streamLog("[stream:h264] drop frame: failed to copy pixel buffer size=\(w)x\(h)")
57
69
  lock.unlock()
58
70
  completion?()
59
71
  return
60
72
  }
61
73
 
62
74
  frameCount += 1
75
+ let submittedFrame = frameCount
63
76
  let pts = CMTime(value: frameCount, timescale: fps)
64
- let frameProps: NSDictionary? = forceKeyframe
77
+ let effectiveForceKeyframe = forceKeyframe || forceKeyframeAfterReset
78
+ forceKeyframeAfterReset = false
79
+ let frameProps: NSDictionary? = effectiveForceKeyframe
65
80
  ? [kVTEncodeFrameOptionKey_ForceKeyFrame: kCFBooleanTrue!] as NSDictionary
66
81
  : nil
82
+ if streamShouldLog(submittedFrame) || effectiveForceKeyframe {
83
+ streamLog(
84
+ "[stream:h264] submit frame #\(submittedFrame) size=\(w)x\(h) " +
85
+ "forceKeyframe=\(effectiveForceKeyframe)"
86
+ )
87
+ }
67
88
  lock.unlock()
68
89
 
69
90
  let status = VTCompressionSessionEncodeFrame(
@@ -75,14 +96,42 @@ final class H264Encoder {
75
96
  infoFlagsOut: nil
76
97
  ) { [weak self] status, _, sampleBuffer in
77
98
  defer { completion?() }
78
- guard let self, status == noErr, let sb = sampleBuffer else { return }
79
- if let encoded = self.extract(from: sb) { self.onEncoded?(encoded) }
99
+ guard let self else { return }
100
+ guard status == noErr else {
101
+ streamLog("[stream:h264] encode callback failed frame #\(submittedFrame) status=\(status)")
102
+ self.fallbackFromLowLatency(reason: "callback status=\(status)")
103
+ return
104
+ }
105
+ guard let sb = sampleBuffer else {
106
+ streamLog("[stream:h264] encode callback missing sample frame #\(submittedFrame)")
107
+ self.fallbackFromLowLatency(reason: "callback missing sample")
108
+ return
109
+ }
110
+ guard let encoded = self.extract(from: sb) else {
111
+ streamLog("[stream:h264] encode callback produced unextractable sample frame #\(submittedFrame)")
112
+ return
113
+ }
114
+ let encodedFrame = self.nextEncodedCount()
115
+ if streamShouldLog(encodedFrame) || encoded.description != nil || encoded.kind == .keyframe {
116
+ let kind = encoded.kind == .keyframe ? "keyframe" : "delta"
117
+ streamLog(
118
+ "[stream:h264] encoded #\(encodedFrame) kind=\(kind) bytes=\(encoded.avcc.count) " +
119
+ "descriptionBytes=\(encoded.description?.count ?? 0)"
120
+ )
121
+ }
122
+ self.onEncoded?(encoded)
80
123
  }
81
124
  if status != noErr {
125
+ streamLog("[stream:h264] VTCompressionSessionEncodeFrame failed frame #\(submittedFrame) status=\(status)")
126
+ fallbackFromLowLatency(reason: "submit status=\(status)")
82
127
  completion?()
83
128
  }
84
129
  }
85
130
 
131
+ func handleEncodeTimeout() {
132
+ fallbackFromLowLatency(reason: "encode timeout")
133
+ }
134
+
86
135
  func stop() {
87
136
  lock.lock()
88
137
  defer { lock.unlock() }
@@ -90,6 +139,8 @@ final class H264Encoder {
90
139
  VTCompressionSessionInvalidate(session)
91
140
  self.session = nil
92
141
  }
142
+ for session in retiredSessions { VTCompressionSessionInvalidate(session) }
143
+ retiredSessions.removeAll()
93
144
  pool = nil
94
145
  }
95
146
 
@@ -150,17 +201,29 @@ final class H264Encoder {
150
201
  compressionSessionOut: &sess
151
202
  )
152
203
  }
153
- var status = create(spec: lowLatencySpec)
154
- if status != noErr || sess == nil {
204
+ let preferredSpec: CFDictionary?
205
+ if lowLatencyEnabled {
206
+ preferredSpec = lowLatencySpec
207
+ } else {
208
+ preferredSpec = nil
209
+ }
210
+ var status = create(spec: preferredSpec)
211
+ if lowLatencyEnabled && (status != noErr || sess == nil) {
212
+ streamLog("[stream:h264] low-latency VT session create failed status=\(status); retrying default")
213
+ lowLatencyEnabled = false
214
+ forceKeyframeAfterReset = true
155
215
  sess = nil
156
216
  status = create(spec: nil)
157
217
  }
158
- guard status == noErr, let sess else { return }
218
+ guard status == noErr, let sess else {
219
+ streamLog("[stream:h264] VT session create failed status=\(status) size=\(width)x\(height)")
220
+ return
221
+ }
159
222
 
160
223
  let props: [(CFString, Any)] = [
161
224
  (kVTCompressionPropertyKey_RealTime, kCFBooleanTrue!),
162
225
  (kVTCompressionPropertyKey_ProfileLevel, kVTProfileLevel_H264_High_AutoLevel),
163
- (kVTCompressionPropertyKey_AllowFrameReordering, kCFBooleanFalse!),
226
+ (kVTCompressionPropertyKey_AllowFrameReordering, kCFBooleanTrue!),
164
227
  (kVTCompressionPropertyKey_AverageBitRate, NSNumber(value: bitrate)),
165
228
  (kVTCompressionPropertyKey_ExpectedFrameRate, NSNumber(value: fps)),
166
229
  // 5s keyframe interval: IDRs are far larger than P-frames, so
@@ -169,12 +232,19 @@ final class H264Encoder {
169
232
  (kVTCompressionPropertyKey_MaxKeyFrameInterval, NSNumber(value: fps * 5)),
170
233
  ]
171
234
  for (key, value) in props {
172
- VTSessionSetProperty(sess, key: key, value: value as CFTypeRef)
235
+ let propStatus = VTSessionSetProperty(sess, key: key, value: value as CFTypeRef)
236
+ if propStatus != noErr {
237
+ streamLog("[stream:h264] VTSessionSetProperty failed key=\(key) status=\(propStatus)")
238
+ }
239
+ }
240
+ let prepareStatus = VTCompressionSessionPrepareToEncodeFrames(sess)
241
+ if prepareStatus != noErr {
242
+ streamLog("[stream:h264] VTCompressionSessionPrepareToEncodeFrames status=\(prepareStatus)")
173
243
  }
174
- VTCompressionSessionPrepareToEncodeFrames(sess)
175
244
  session = sess
176
245
  stateQueue.sync {
177
246
  emittedDescription = false
247
+ encodedCount = 0
178
248
  }
179
249
 
180
250
  // Pool feeding the deep-copy; BGRA matches the framebuffer surface.
@@ -185,10 +255,41 @@ final class H264Encoder {
185
255
  kCVPixelBufferIOSurfacePropertiesKey as String: [:],
186
256
  ]
187
257
  var newPool: CVPixelBufferPool?
188
- CVPixelBufferPoolCreate(kCFAllocatorDefault, nil, attrs as CFDictionary, &newPool)
258
+ let poolStatus = CVPixelBufferPoolCreate(kCFAllocatorDefault, nil, attrs as CFDictionary, &newPool)
259
+ if poolStatus != kCVReturnSuccess || newPool == nil {
260
+ streamLog("[stream:h264] pixel buffer pool create failed status=\(poolStatus) size=\(width)x\(height)")
261
+ } else {
262
+ let mode = lowLatencyEnabled ? "low-latency" : "default"
263
+ streamLog("[stream:h264] VT session ready mode=\(mode) size=\(width)x\(height) fps=\(fps) bitrate=\(bitrate)")
264
+ }
189
265
  pool = newPool
190
266
  }
191
267
 
268
+ private func fallbackFromLowLatency(reason: String) {
269
+ lock.lock()
270
+ defer { lock.unlock() }
271
+ guard lowLatencyEnabled else { return }
272
+ streamLog("[stream:h264] low-latency encoder failed (\(reason)); default VT session will be used")
273
+ lowLatencyEnabled = false
274
+ forceKeyframeAfterReset = true
275
+ if let session {
276
+ retiredSessions.append(session)
277
+ self.session = nil
278
+ }
279
+ pool = nil
280
+ stateQueue.sync {
281
+ emittedDescription = false
282
+ encodedCount = 0
283
+ }
284
+ }
285
+
286
+ private func nextEncodedCount() -> Int64 {
287
+ stateQueue.sync {
288
+ encodedCount += 1
289
+ return encodedCount
290
+ }
291
+ }
292
+
192
293
  private func extract(from sample: CMSampleBuffer) -> Encoded? {
193
294
  let isKeyframe = !notSync(sample)
194
295
  guard let dataBuf = CMSampleBufferGetDataBuffer(sample) else { return nil }
@@ -1,5 +1,21 @@
1
1
  import Foundation
2
2
 
3
+ /// Opt-in AVCC/H.264 diagnostics for staging stream failures. This path is hot
4
+ /// during playback, so keep it off unless explicitly requested.
5
+ let streamDebugEnabled =
6
+ ProcessInfo.processInfo.environment["SERVE_SIM_DEBUG_STREAM"] != nil ||
7
+ ProcessInfo.processInfo.environment["SERVE_SIM_DEBUG_AVCC"] != nil
8
+
9
+ @inline(__always)
10
+ func streamLog(_ message: @autoclosure () -> String) {
11
+ if streamDebugEnabled { print(message()) }
12
+ }
13
+
14
+ @inline(__always)
15
+ func streamShouldLog(_ count: Int64, first: Int64 = 5, every: Int64 = 120) -> Bool {
16
+ count <= first || count % every == 0
17
+ }
18
+
3
19
  /// Wire format a viewer can request for the screen stream.
4
20
  ///
5
21
  /// - `mjpeg`: stateless JPEG-per-frame inside a `multipart/x-mixed-replace`
@@ -12,6 +12,7 @@ struct WebRTCIceServerPayload: Codable {
12
12
  struct WebRTCOfferPayload: Codable {
13
13
  let type: String
14
14
  let sdp: String
15
+ let codec: String?
15
16
  let iceServers: [WebRTCIceServerPayload]?
16
17
  }
17
18
 
@@ -103,7 +104,11 @@ final class WebRTCPublisher {
103
104
  return
104
105
  }
105
106
 
106
- _ = peerConnection.add(videoTrack, streamIds: ["stream0"])
107
+ if let transceiver = peerConnection.addTransceiver(with: videoTrack) {
108
+ applyVideoCodecPreference(request.codec, to: transceiver)
109
+ } else {
110
+ _ = peerConnection.add(videoTrack, streamIds: ["stream0"])
111
+ }
107
112
  let session = WebRTCSession(peerConnection: peerConnection, delegate: delegate)
108
113
  self.session?.close()
109
114
  self.session = session
@@ -154,6 +159,25 @@ final class WebRTCPublisher {
154
159
  }
155
160
  }
156
161
 
162
+ private func applyVideoCodecPreference(_ codec: String?, to transceiver: LKRTCRtpTransceiver) {
163
+ let preferredName = codec == "vp8" ? "VP8" : "H264"
164
+ let capabilities = factory.rtpSenderCapabilities(forKind: "video")
165
+ let preferredCodecs = capabilities.codecs.filter {
166
+ $0.name.caseInsensitiveCompare(preferredName) == .orderedSame ||
167
+ $0.mimeType.caseInsensitiveCompare("video/\(preferredName)") == .orderedSame
168
+ }
169
+ guard !preferredCodecs.isEmpty else {
170
+ print("[webrtc] No sender codec capability found for \(preferredName); using default order")
171
+ return
172
+ }
173
+ let remainingCodecs = capabilities.codecs.filter { capability in
174
+ !preferredCodecs.contains { $0 === capability }
175
+ }
176
+ let orderedCodecs = preferredCodecs + remainingCodecs
177
+ transceiver.codecPreferences = orderedCodecs
178
+ print("[webrtc] Preferred video codec: \(preferredName)")
179
+ }
180
+
157
181
  private func makeError(_ message: String) -> Error {
158
182
  NSError(domain: "serve-sim.webrtc", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
159
183
  }