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 +4 -0
- package/Sources/SimNative/sim-capture.swift +89 -6
- package/Sources/SimNative/sim-module.swift +20 -2
- package/Sources/SimStreamHelper/H264Encoder.swift +25 -3
- package/Sources/SimStreamHelper/WebRTCPublisher.swift +36 -6
- package/dist/middleware.js +33 -33
- package/dist/native/serve-sim-native.node +0 -0
- package/dist/serve-sim.js +64 -64
- package/package.json +1 -1
- package/src/middleware.ts +26 -9
- package/src/native.ts +20 -1
- package/src/state.ts +100 -11
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
|
|
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
|
|
61
|
-
private
|
|
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(
|
|
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
|
|
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
|
|
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 ||
|