serve-sim-sjchmiela 0.1.41 → 0.1.43

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.
package/README.md CHANGED
@@ -71,6 +71,10 @@ Options:
71
71
  --codec <codec> Stream codec for the preview UI: 'auto' (H.264 when the
72
72
  browser can decode it) or 'mjpeg' (force software JPEG —
73
73
  e.g. on VMs without H.264 encode)
74
+ --transport <http|webrtc>
75
+ Stream transport (default: http)
76
+ --webrtc-codec <vp8|vp9|h264>
77
+ WebRTC video codec (default: h264)
74
78
  --list [device] List running streams
75
79
  --kill [device] Kill running stream(s)
76
80
 
@@ -1,6 +1,7 @@
1
1
  import Foundation
2
2
  import CoreVideo
3
3
  import CoreMedia
4
+ import CoreImage
4
5
 
5
6
  // The capture + encode engine, reused verbatim from SimStreamHelper. Replicates
6
7
  // main.swift's frameHandler: MJPEG always encodes while clients exist; H.264 runs
@@ -20,6 +21,7 @@ struct CaptureEngineOptions {
20
21
  let mjpegQuality: Double
21
22
  let h264Fps: Int
22
23
  let h264Bitrate: Int
24
+ let maxDimension: Int
23
25
  }
24
26
 
25
27
  final class CaptureEngine {
@@ -33,8 +35,9 @@ final class CaptureEngine {
33
35
  private let webRTCPublisher = WebRTCPublisher()
34
36
 
35
37
  private let frameCapture = FrameCapture()
36
- private let videoEncoder: VideoEncoder
38
+ private var videoEncoder: VideoEncoder
37
39
  private let h264Encoder: H264Encoder
40
+ private let ciContext = CIContext(options: [.workingColorSpace: NSNull()])
38
41
  private let encodeQueue = DispatchQueue(label: "napi.encode", qos: .userInteractive)
39
42
  private let h264Queue = DispatchQueue(label: "napi.encode.h264", qos: .userInteractive)
40
43
  private static let h264EncodeTimeoutMs = 500
@@ -43,6 +46,8 @@ final class CaptureEngine {
43
46
  // encode queues. Benign races (same pattern as the standalone helper).
44
47
  private var screenWidth = 0
45
48
  private var screenHeight = 0
49
+ private var encodeWidth = 0
50
+ private var encodeHeight = 0
46
51
  private var encoderReady = false
47
52
  private var encoding = false // MJPEG backpressure
48
53
  private var h264Encoding = false // H.264 backpressure
@@ -57,8 +62,9 @@ final class CaptureEngine {
57
62
  private var avccNativeEmitCount: Int64 = 0
58
63
  private var lastMjpegReservedAtNs: UInt64 = 0
59
64
  private var lastH264ReservedAtNs: UInt64 = 0
60
- private let mjpegMinFrameIntervalNs: UInt64
61
- private let h264MinFrameIntervalNs: UInt64
65
+ private var mjpegMinFrameIntervalNs: UInt64
66
+ private var h264MinFrameIntervalNs: UInt64
67
+ private var maxDimension: Int
62
68
  private var started = false
63
69
  private var stopped = false
64
70
 
@@ -77,6 +83,7 @@ final class CaptureEngine {
77
83
  h264Encoder = H264Encoder(fps: h264Fps, bitrate: h264Bitrate)
78
84
  mjpegMinFrameIntervalNs = UInt64(1_000_000_000 / mjpegFps)
79
85
  h264MinFrameIntervalNs = UInt64(1_000_000_000 / h264Fps)
86
+ maxDimension = max(0, options.maxDimension)
80
87
  webRTCPublisher.onInput = onWebRTCInput
81
88
 
82
89
  h264Encoder.onEncoded = { [weak self] encoded in
@@ -123,12 +130,15 @@ final class CaptureEngine {
123
130
  private func handleFrame(_ pixelBuffer: CVPixelBuffer, timestamp: CMTime) {
124
131
  let w = CVPixelBufferGetWidth(pixelBuffer)
125
132
  let h = CVPixelBufferGetHeight(pixelBuffer)
133
+ let encodeSize = encodedSize(width: w, height: h)
126
134
 
127
- if !encoderReady || w != screenWidth || h != screenHeight {
135
+ if !encoderReady || w != screenWidth || h != screenHeight || encodeSize.width != encodeWidth || encodeSize.height != encodeHeight {
128
136
  screenWidth = w
129
137
  screenHeight = h
138
+ encodeWidth = encodeSize.width
139
+ encodeHeight = encodeSize.height
130
140
  videoEncoder.stop()
131
- videoEncoder.setup(width: Int32(w), height: Int32(h), fps: 60) { [weak self] jpeg in
141
+ videoEncoder.setup(width: Int32(encodeSize.width), height: Int32(encodeSize.height), fps: 60) { [weak self] jpeg in
132
142
  self?.emit(codec: Self.codecMJPEG, data: jpeg, flags: 0)
133
143
  }
134
144
  encoderReady = true
@@ -139,7 +149,7 @@ final class CaptureEngine {
139
149
  let shouldEncodeJpeg = encoderReady && !encoding && reserveMjpegEncodeIfNeeded()
140
150
  if !shouldEncodeJpeg && h264Request == nil && !shouldSendWebRTC { return }
141
151
 
142
- guard let stableFrame = copyPixelBuffer(pixelBuffer) else {
152
+ guard let stableFrame = copyPixelBuffer(pixelBuffer, targetWidth: encodeSize.width, targetHeight: encodeSize.height) else {
143
153
  if let h264Request {
144
154
  streamLog("[stream:avcc] failed to copy capture frame for H.264 token=\(h264Request.token)")
145
155
  finishH264Encode(token: h264Request.token, restoreKeyframe: h264Request.forceKeyframe)
@@ -232,6 +242,50 @@ final class CaptureEngine {
232
242
  return dst
233
243
  }
234
244
 
245
+ private func encodedSize(width: Int, height: Int) -> (width: Int, height: Int) {
246
+ guard maxDimension > 0, max(width, height) > maxDimension else {
247
+ return (width, height)
248
+ }
249
+ let scale = Double(maxDimension) / Double(max(width, height))
250
+ return (evenDimension(width, scale: scale), evenDimension(height, scale: scale))
251
+ }
252
+
253
+ private func evenDimension(_ value: Int, scale: Double) -> Int {
254
+ let scaled = max(2, Int((Double(value) * scale).rounded()))
255
+ return scaled.isMultiple(of: 2) ? scaled : max(2, scaled - 1)
256
+ }
257
+
258
+ private func copyPixelBuffer(_ source: CVPixelBuffer, targetWidth: Int, targetHeight: Int) -> CVPixelBuffer? {
259
+ let width = CVPixelBufferGetWidth(source)
260
+ let height = CVPixelBufferGetHeight(source)
261
+ if width == targetWidth && height == targetHeight {
262
+ return copyPixelBuffer(source)
263
+ }
264
+
265
+ let attrs: [String: Any] = [
266
+ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
267
+ kCVPixelBufferWidthKey as String: targetWidth,
268
+ kCVPixelBufferHeightKey as String: targetHeight,
269
+ kCVPixelBufferCGImageCompatibilityKey as String: true,
270
+ kCVPixelBufferCGBitmapContextCompatibilityKey as String: true,
271
+ ]
272
+ var out: CVPixelBuffer?
273
+ guard CVPixelBufferCreate(
274
+ kCFAllocatorDefault, targetWidth, targetHeight, kCVPixelFormatType_32BGRA, attrs as CFDictionary, &out
275
+ ) == kCVReturnSuccess, let dst = out else { return nil }
276
+
277
+ let scaleX = CGFloat(targetWidth) / CGFloat(width)
278
+ let scaleY = CGFloat(targetHeight) / CGFloat(height)
279
+ let image = CIImage(cvPixelBuffer: source).transformed(by: CGAffineTransform(scaleX: scaleX, y: scaleY))
280
+ ciContext.render(
281
+ image,
282
+ to: dst,
283
+ bounds: CGRect(x: 0, y: 0, width: targetWidth, height: targetHeight),
284
+ colorSpace: CGColorSpaceCreateDeviceRGB()
285
+ )
286
+ return dst
287
+ }
288
+
235
289
  private func reserveH264EncodeIfNeeded() -> (forceKeyframe: Bool, token: UInt64)? {
236
290
  h264Queue.sync {
237
291
  guard avccActive else { return nil }
@@ -301,6 +355,35 @@ final class CaptureEngine {
301
355
  }
302
356
  }
303
357
 
358
+ func updateSettings(mjpegFps: Int, mjpegQuality: Double, h264Fps: Int, h264Bitrate: Int, maxDimension: Int) {
359
+ let normalizedMjpegFps = max(1, mjpegFps)
360
+ let normalizedQuality = CGFloat(min(max(mjpegQuality, 0.0), 1.0))
361
+ let normalizedH264Fps = max(1, h264Fps)
362
+ let normalizedBitrate = max(1, h264Bitrate)
363
+ let normalizedMaxDimension = max(0, maxDimension)
364
+
365
+ encodeQueue.sync {
366
+ self.mjpegMinFrameIntervalNs = UInt64(1_000_000_000 / normalizedMjpegFps)
367
+ self.maxDimension = normalizedMaxDimension
368
+ self.videoEncoder.stop()
369
+ self.videoEncoder = VideoEncoder(quality: normalizedQuality)
370
+ self.encoderReady = false
371
+ self.encoding = false
372
+ self.lastMjpegReservedAtNs = 0
373
+ }
374
+ h264Queue.sync {
375
+ self.h264MinFrameIntervalNs = UInt64(1_000_000_000 / normalizedH264Fps)
376
+ self.h264Encoder.update(fps: normalizedH264Fps, bitrate: normalizedBitrate)
377
+ self.forceKeyframe = true
378
+ self.h264Encoding = false
379
+ self.lastH264ReservedAtNs = 0
380
+ }
381
+ streamLog(
382
+ "[stream] settings updated mjpegFps=\(normalizedMjpegFps) mjpegQuality=\(normalizedQuality) " +
383
+ "h264Fps=\(normalizedH264Fps) h264Bitrate=\(normalizedBitrate) maxDimension=\(normalizedMaxDimension)"
384
+ )
385
+ }
386
+
304
387
  func handleWebRTCOffer(_ offerJson: String) throws -> String {
305
388
  let request = try JSONDecoder().decode(WebRTCOfferPayload.self, from: Data(offerJson.utf8))
306
389
  let answer = try webRTCPublisher.handleOffer(request)
@@ -106,7 +106,8 @@ private func u32(_ v: Int) -> UInt32 {
106
106
  _ mjpegFps: Int,
107
107
  _ mjpegQuality: Double,
108
108
  _ h264Fps: Int,
109
- _ h264Bitrate: Int
109
+ _ h264Bitrate: Int,
110
+ _ maxDimension: Int
110
111
  ) throws {
111
112
  // unref'd by NodeAsyncQueue's init, so the frame pipeline alone won't
112
113
  // keep the event loop alive. Bounded queue + blocking AVCC preserves
@@ -124,7 +125,8 @@ private func u32(_ v: Int) -> UInt32 {
124
125
  mjpegFps: mjpegFps,
125
126
  mjpegQuality: mjpegQuality,
126
127
  h264Fps: h264Fps,
127
- h264Bitrate: h264Bitrate
128
+ h264Bitrate: h264Bitrate,
129
+ maxDimension: maxDimension
128
130
  ), onFrame: { codec, data, w, h, flags in
129
131
  // Runs on a native encode thread. AVCC is inter-frame H.264 — dropping
130
132
  // a delta corrupts the decoder until the next IDR — so deliver it
@@ -158,6 +160,22 @@ private func u32(_ v: Int) -> UInt32 {
158
160
  engine.requestKeyframe()
159
161
  }
160
162
 
163
+ @NodeMethod func updateStreamSettings(
164
+ _ mjpegFps: Int,
165
+ _ mjpegQuality: Double,
166
+ _ h264Fps: Int,
167
+ _ h264Bitrate: Int,
168
+ _ maxDimension: Int
169
+ ) {
170
+ engine.updateSettings(
171
+ mjpegFps: mjpegFps,
172
+ mjpegQuality: mjpegQuality,
173
+ h264Fps: h264Fps,
174
+ h264Bitrate: h264Bitrate,
175
+ maxDimension: maxDimension
176
+ )
177
+ }
178
+
161
179
  @NodeMethod func handleWebRTCOffer(_ offerJson: String) throws -> String {
162
180
  try engine.handleWebRTCOffer(offerJson)
163
181
  }
@@ -28,7 +28,7 @@ final class H264Encoder {
28
28
  private var pool: CVPixelBufferPool?
29
29
  private var width: Int32 = 0
30
30
  private var height: Int32 = 0
31
- private let fps: Int32
31
+ private var fps: Int32
32
32
  private var bitrate: Int
33
33
  private let stateQueue = DispatchQueue(label: "H264Encoder.state")
34
34
  private var emittedDescription = false
@@ -39,8 +39,8 @@ final class H264Encoder {
39
39
  private var retiredSessions: [VTCompressionSession] = []
40
40
 
41
41
  init(fps: Int = 60, bitrate: Int = 6_000_000) {
42
- self.fps = Int32(fps)
43
- self.bitrate = bitrate
42
+ self.fps = Int32(max(1, fps))
43
+ self.bitrate = max(1, bitrate)
44
44
  }
45
45
 
46
46
  deinit {
@@ -132,6 +132,28 @@ final class H264Encoder {
132
132
  fallbackFromLowLatency(reason: "encode timeout")
133
133
  }
134
134
 
135
+ func update(fps nextFps: Int, bitrate nextBitrate: Int) {
136
+ lock.lock()
137
+ defer { lock.unlock() }
138
+ let normalizedFps = Int32(max(1, nextFps))
139
+ let normalizedBitrate = max(1, nextBitrate)
140
+ guard fps != normalizedFps || bitrate != normalizedBitrate else { return }
141
+ fps = normalizedFps
142
+ bitrate = normalizedBitrate
143
+ forceKeyframeAfterReset = true
144
+ frameCount = 0
145
+ if let session {
146
+ retiredSessions.append(session)
147
+ self.session = nil
148
+ }
149
+ pool = nil
150
+ stateQueue.sync {
151
+ emittedDescription = false
152
+ encodedCount = 0
153
+ }
154
+ streamLog("[stream:h264] settings updated fps=\(fps) bitrate=\(bitrate)")
155
+ }
156
+
135
157
  func stop() {
136
158
  lock.lock()
137
159
  defer { lock.unlock() }
@@ -104,11 +104,6 @@ final class WebRTCPublisher {
104
104
  return
105
105
  }
106
106
 
107
- if let transceiver = peerConnection.addTransceiver(with: videoTrack) {
108
- applyVideoCodecPreference(request.codec, to: transceiver)
109
- } else {
110
- _ = peerConnection.add(videoTrack, streamIds: ["stream0"])
111
- }
112
107
  let session = WebRTCSession(peerConnection: peerConnection, delegate: delegate)
113
108
  self.session?.close()
114
109
  self.session = session
@@ -119,6 +114,7 @@ final class WebRTCPublisher {
119
114
  completion(.failure(error))
120
115
  return
121
116
  }
117
+ self.attachVideoTrack(to: peerConnection, codec: request.codec)
122
118
  peerConnection.answer(for: constraints) { answer, error in
123
119
  if let error {
124
120
  completion(.failure(error))
@@ -145,6 +141,32 @@ final class WebRTCPublisher {
145
141
  }
146
142
  }
147
143
 
144
+ private func attachVideoTrack(to peerConnection: LKRTCPeerConnection, codec: String?) {
145
+ let transceiver = peerConnection.transceivers.first { $0.mediaType == .video }
146
+ ?? createFallbackVideoTransceiver(on: peerConnection)
147
+ guard let transceiver else {
148
+ _ = peerConnection.add(videoTrack, streamIds: ["stream0"])
149
+ print("[webrtc] Could not find or create video transceiver; fell back to addTrack")
150
+ return
151
+ }
152
+
153
+ transceiver.sender.track = videoTrack
154
+ transceiver.sender.streamIds = ["stream0"]
155
+ var directionError: NSError?
156
+ transceiver.setDirection(.sendOnly, error: &directionError)
157
+ if let directionError {
158
+ print("[webrtc] Failed to set video transceiver direction: \(directionError.localizedDescription)")
159
+ }
160
+ applyVideoCodecPreference(codec, to: transceiver)
161
+ }
162
+
163
+ private func createFallbackVideoTransceiver(on peerConnection: LKRTCPeerConnection) -> LKRTCRtpTransceiver? {
164
+ let initOptions = LKRTCRtpTransceiverInit()
165
+ initOptions.direction = .sendOnly
166
+ initOptions.streamIds = ["stream0"]
167
+ return peerConnection.addTransceiver(with: videoTrack, init: initOptions)
168
+ }
169
+
148
170
  private func iceServers(from payload: [WebRTCIceServerPayload]?) -> [LKRTCIceServer] {
149
171
  let servers = payload ?? [
150
172
  WebRTCIceServerPayload(urls: ["stun:stun.l.google.com:19302"], username: nil, credential: nil),
@@ -160,7 +182,15 @@ final class WebRTCPublisher {
160
182
  }
161
183
 
162
184
  private func applyVideoCodecPreference(_ codec: String?, to transceiver: LKRTCRtpTransceiver) {
163
- let preferredName = codec == "vp8" ? "VP8" : "H264"
185
+ let preferredName: String
186
+ switch codec?.lowercased() {
187
+ case "vp8":
188
+ preferredName = "VP8"
189
+ case "vp9":
190
+ preferredName = "VP9"
191
+ default:
192
+ preferredName = "H264"
193
+ }
164
194
  let capabilities = factory.rtpSenderCapabilities(forKind: "video")
165
195
  let preferredCodecs = capabilities.codecs.filter {
166
196
  $0.name.caseInsensitiveCompare(preferredName) == .orderedSame ||