@stim-cli/server 1.10.1 → 1.11.0

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.
@@ -0,0 +1,101 @@
1
+ import AppKit
2
+ import ObjectiveC
3
+
4
+ // SimulatorKit's HID client and Indigo message builders are private. These
5
+ // signatures match the assertion strings and ObjC type encodings that Xcode 27
6
+ // ships. Touch points are fractions of the screen when the size is 1x1, and
7
+ // 0x32 is the Indigo target of the main screen's digitizer.
8
+ private typealias AllocFn = @convention(c) (AnyClass, Selector) -> Unmanaged<AnyObject>
9
+ private typealias InitFn = @convention(c) (
10
+ Unmanaged<AnyObject>, Selector, AnyObject, UnsafeMutablePointer<Unmanaged<NSError>?>?
11
+ ) -> Unmanaged<AnyObject>?
12
+ private typealias SendFn = @convention(c) (
13
+ AnyObject, Selector, UnsafeMutableRawPointer, Bool, DispatchQueue?, (@convention(block) (NSError?) -> Void)?
14
+ ) -> Void
15
+ private typealias MouseMessageFn = @convention(c) (
16
+ UnsafePointer<CGPoint>, UnsafePointer<CGPoint>?, UInt32, UInt, CGSize, UInt32
17
+ ) -> UnsafeMutableRawPointer?
18
+ private typealias KeyboardMessageFn = @convention(c) (UInt32, UInt32) -> UnsafeMutableRawPointer?
19
+ private typealias UsageForKeyCodeFn = @convention(c) (UInt32) -> UInt32
20
+
21
+ private enum SimulatorKit {
22
+ static let handle = dlopen(CoreSimulator.simulatorKitPath(CoreSimulator.developerDir), RTLD_NOW)
23
+ static let mouseMessage = symbol("IndigoHIDMessageForMouseNSEvent", MouseMessageFn.self)
24
+ static let keyboardMessage = symbol("IndigoHIDMessageForKeyboardArbitrary", KeyboardMessageFn.self)
25
+ static let usageForKeyCode = symbol("hidUsageForCGKeyCode", UsageForKeyCodeFn.self)
26
+
27
+ static func symbol<T>(_ name: String, _ type: T.Type) -> T? {
28
+ guard let handle, let pointer = dlsym(handle, name) else { return nil }
29
+ return unsafeBitCast(pointer, to: type)
30
+ }
31
+ }
32
+
33
+ enum TouchPhase {
34
+ case down, move, up
35
+
36
+ var eventType: NSEvent.EventType {
37
+ switch self {
38
+ case .down: return .leftMouseDown
39
+ case .move: return .leftMouseDragged
40
+ case .up: return .leftMouseUp
41
+ }
42
+ }
43
+ }
44
+
45
+ final class SimulatorHID {
46
+ private static let mainScreenTarget: UInt32 = 0x32
47
+ private static let keyDown: UInt32 = 1
48
+ private static let keyUp: UInt32 = 2
49
+ private static let sendSelector = NSSelectorFromString("sendWithMessage:freeWhenDone:completionQueue:completion:")
50
+
51
+ private let client: AnyObject
52
+ private let send: SendFn
53
+
54
+ init?(udid: String) {
55
+ let allocSelector = NSSelectorFromString("alloc")
56
+ let initSelector = NSSelectorFromString("initWithDevice:error:")
57
+ guard SimulatorKit.handle != nil,
58
+ let device = CoreSimulator.device(udid: udid),
59
+ let cls = NSClassFromString("_TtC12SimulatorKit24SimDeviceLegacyHIDClient"),
60
+ let metaclass = object_getClass(cls),
61
+ class_respondsToSelector(metaclass, allocSelector),
62
+ class_respondsToSelector(cls, initSelector),
63
+ class_respondsToSelector(cls, Self.sendSelector)
64
+ else { return nil }
65
+ let alloc = unsafeBitCast(class_getMethodImplementation(metaclass, allocSelector), to: AllocFn.self)
66
+ let initialize = unsafeBitCast(class_getMethodImplementation(cls, initSelector), to: InitFn.self)
67
+ guard let client = initialize(alloc(cls, allocSelector), initSelector, device, nil)?.takeRetainedValue()
68
+ else { return nil }
69
+ self.client = client
70
+ send = unsafeBitCast(class_getMethodImplementation(cls, Self.sendSelector), to: SendFn.self)
71
+ }
72
+
73
+ // The simulator's SimulatorHID addresses a display's digitizer by its screen
74
+ // ID with bit 30 set, and backboardd aborts on a target it has no service
75
+ // for. The main display keeps its legacy target.
76
+ static func digitizerTarget(screenID: UInt32) -> UInt32 {
77
+ screenID == 1 ? mainScreenTarget : 0x4000_0000 | screenID
78
+ }
79
+
80
+ /// `point` is a fraction of the screen, origin top-left.
81
+ func touch(_ phase: TouchPhase, at point: CGPoint, screenID: UInt32 = 1) {
82
+ let target = Self.digitizerTarget(screenID: screenID)
83
+ var point = point
84
+ // The builder returns nil for a drag that arrives within 16 ms of the previous message.
85
+ guard let message = SimulatorKit.mouseMessage?(
86
+ &point, nil, target, UInt(phase.eventType.rawValue), CGSize(width: 1, height: 1), 0)
87
+ else { return }
88
+ deliver(message)
89
+ }
90
+
91
+ func key(code: UInt16, down: Bool) {
92
+ guard let usage = SimulatorKit.usageForKeyCode?(UInt32(code)), usage != 0,
93
+ let message = SimulatorKit.keyboardMessage?(usage, down ? Self.keyDown : Self.keyUp)
94
+ else { return }
95
+ deliver(message)
96
+ }
97
+
98
+ private func deliver(_ message: UnsafeMutableRawPointer) {
99
+ send(client, Self.sendSelector, message, true, nil, nil)
100
+ }
101
+ }
@@ -0,0 +1,282 @@
1
+ import Accelerate
2
+ import CoreMedia
3
+ import CoreVideo
4
+ import Foundation
5
+ import VideoToolbox
6
+
7
+ /// One encoded H.264 access unit in Annex-B form. A keyframe starts with its SPS and PPS.
8
+ struct AccessUnit {
9
+ var data: Data
10
+ var keyframe: Bool
11
+ var capturedAt: Double
12
+ var width: Int
13
+ var height: Int
14
+ }
15
+
16
+ /// A low-latency hardware H.264 encoder: real time, no frame reordering, a keyframe at least every 2 s.
17
+ /// Each source frame is copied, rotated upright and scaled to fit `maxEdge` before encoding, so a source
18
+ /// surface the simulator keeps drawing into is never read after `encode` returns.
19
+ final class VideoEncoder {
20
+ static let minBitrate = 250_000
21
+ static let maxBitrate = 8_000_000
22
+
23
+ private let queue = DispatchQueue(label: "stim.video.encode")
24
+ private let output: (AccessUnit) -> Void
25
+ private var session: VTCompressionSession?
26
+ private var transfer: VTPixelTransferSession?
27
+ private var rotation: VTPixelRotationSession?
28
+ private var rotated: CVPixelBufferPool?
29
+ private var rotatedSize = (width: 0, height: 0)
30
+ private var size = (width: 0, height: 0)
31
+ private var forceKeyframe = true
32
+ private var lastTimestamp = CMTime.invalid
33
+
34
+ private var maxEdge: Int
35
+ private var fps: Int
36
+ private var bitrate: Int
37
+ private var bgra: CVPixelBufferPool?
38
+ private var bgraSize = (width: 0, height: 0)
39
+
40
+ init(maxEdge: Int, fps: Int, bitrate: Int, output: @escaping (AccessUnit) -> Void) {
41
+ self.maxEdge = maxEdge
42
+ self.fps = fps
43
+ self.bitrate = bitrate
44
+ self.output = output
45
+ }
46
+
47
+ deinit {
48
+ if let session { VTCompressionSessionInvalidate(session) }
49
+ }
50
+
51
+ /// A disabled encoder drops its session and pools; the next frame after enabling starts with a keyframe.
52
+ func configure(enabled: Bool, maxEdge: Int, fps: Int, bitrate: Int) {
53
+ queue.async {
54
+ if !enabled {
55
+ self.invalidate()
56
+ self.transfer = nil
57
+ self.rotation = nil
58
+ self.rotated = nil
59
+ self.bgra = nil
60
+ }
61
+ self.maxEdge = maxEdge
62
+ if self.fps != fps, let session = self.session {
63
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_ExpectedFrameRate, value: fps as CFNumber)
64
+ }
65
+ self.fps = fps
66
+ let clamped = min(max(bitrate, Self.minBitrate), Self.maxBitrate)
67
+ if self.bitrate != clamped, let session = self.session { Self.applyBitrate(session, clamped) }
68
+ self.bitrate = clamped
69
+ }
70
+ }
71
+
72
+ func requestKeyframe() {
73
+ queue.async { self.forceKeyframe = true }
74
+ }
75
+
76
+ /// `quarterTurns` counter-clockwise quarter turns make the source upright.
77
+ func encode(_ source: CVPixelBuffer, quarterTurns: Int, capturedAt: Double) {
78
+ queue.sync { self.encodeOnQueue(source, quarterTurns: quarterTurns, capturedAt: capturedAt) }
79
+ }
80
+
81
+ /// Encodes an upright RGBA8888 image, as the emulator's `streamScreenshot` sends it. CoreVideo has no
82
+ /// RGBA pixel format, so the bytes are swizzled into a BGRA buffer.
83
+ func encode(rgba: Data, width: Int, height: Int, capturedAt: Double) {
84
+ queue.sync {
85
+ if bgra == nil || bgraSize != (width, height) {
86
+ let attributes: [CFString: Any] = [
87
+ kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA,
88
+ kCVPixelBufferWidthKey: width,
89
+ kCVPixelBufferHeightKey: height,
90
+ kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary,
91
+ ]
92
+ bgra = nil
93
+ CVPixelBufferPoolCreate(nil, nil, attributes as CFDictionary, &bgra)
94
+ bgraSize = (width, height)
95
+ }
96
+ var buffer: CVPixelBuffer?
97
+ guard rgba.count == width * height * 4, let bgra,
98
+ CVPixelBufferPoolCreatePixelBuffer(nil, bgra, &buffer) == kCVReturnSuccess, let buffer
99
+ else { return }
100
+ CVPixelBufferLockBaseAddress(buffer, [])
101
+ let swizzled = rgba.withUnsafeBytes { bytes -> Bool in
102
+ var source = vImage_Buffer(
103
+ data: UnsafeMutableRawPointer(mutating: bytes.baseAddress!), height: vImagePixelCount(height),
104
+ width: vImagePixelCount(width), rowBytes: width * 4)
105
+ var destination = vImage_Buffer(
106
+ data: CVPixelBufferGetBaseAddress(buffer), height: vImagePixelCount(height), width: vImagePixelCount(width),
107
+ rowBytes: CVPixelBufferGetBytesPerRow(buffer))
108
+ return vImagePermuteChannels_ARGB8888(&source, &destination, [2, 1, 0, 3], vImage_Flags(kvImageNoFlags))
109
+ == kvImageNoError
110
+ }
111
+ CVPixelBufferUnlockBaseAddress(buffer, [])
112
+ if swizzled { encodeOnQueue(buffer, quarterTurns: 0, capturedAt: capturedAt) }
113
+ }
114
+ }
115
+
116
+ private func encodeOnQueue(_ source: CVPixelBuffer, quarterTurns: Int, capturedAt: Double) {
117
+ let sideways = quarterTurns % 2 != 0
118
+ let width = sideways ? CVPixelBufferGetHeight(source) : CVPixelBufferGetWidth(source)
119
+ let height = sideways ? CVPixelBufferGetWidth(source) : CVPixelBufferGetHeight(source)
120
+ let target = Self.fit(width: width, height: height, maxEdge: maxEdge)
121
+ guard let session = session(for: target), let pool = VTCompressionSessionGetPixelBufferPool(session),
122
+ let input = upright(source, quarterTurns: quarterTurns, width: width, height: height)
123
+ else { return }
124
+ var buffer: CVPixelBuffer?
125
+ guard CVPixelBufferPoolCreatePixelBuffer(nil, pool, &buffer) == kCVReturnSuccess, let buffer,
126
+ let transfer = transferSession(),
127
+ VTPixelTransferSessionTransferImage(transfer, from: input, to: buffer) == noErr
128
+ else { return }
129
+ var timestamp = CMTime(value: CMTimeValue(capturedAt * 1000), timescale: 1_000_000)
130
+ if lastTimestamp.isValid, timestamp <= lastTimestamp {
131
+ timestamp = CMTimeAdd(lastTimestamp, CMTime(value: 1, timescale: 1_000_000))
132
+ }
133
+ lastTimestamp = timestamp
134
+ let properties = forceKeyframe ? [kVTEncodeFrameOptionKey_ForceKeyFrame: true] as CFDictionary : nil
135
+ forceKeyframe = false
136
+ VTCompressionSessionEncodeFrame(
137
+ session, imageBuffer: buffer, presentationTimeStamp: timestamp, duration: .invalid,
138
+ frameProperties: properties, infoFlagsOut: nil
139
+ ) { [output, weak self] status, _, sample in
140
+ guard status == noErr, let sample, let unit = Self.annexB(sample) else {
141
+ self?.requestKeyframe()
142
+ return
143
+ }
144
+ output(
145
+ AccessUnit(
146
+ data: unit.data, keyframe: unit.keyframe, capturedAt: capturedAt, width: target.width, height: target.height))
147
+ }
148
+ }
149
+
150
+ static func fit(width: Int, height: Int, maxEdge: Int) -> (width: Int, height: Int) {
151
+ let scale = min(1, Double(maxEdge) / Double(max(width, height)))
152
+ let even = { (value: Double) in max(2, Int(value.rounded(.down)) & ~1) }
153
+ return (even(Double(width) * scale), even(Double(height) * scale))
154
+ }
155
+
156
+ private func session(for target: (width: Int, height: Int)) -> VTCompressionSession? {
157
+ if let session, size == target { return session }
158
+ invalidate()
159
+ size = target
160
+ forceKeyframe = true
161
+ let specification: [CFString: Any] = [
162
+ kVTVideoEncoderSpecification_EnableHardwareAcceleratedVideoEncoder: true,
163
+ kVTVideoEncoderSpecification_EnableLowLatencyRateControl: true,
164
+ ]
165
+ let attributes: [CFString: Any] = [
166
+ kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
167
+ kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary,
168
+ ]
169
+ var created: VTCompressionSession?
170
+ guard
171
+ VTCompressionSessionCreate(
172
+ allocator: nil, width: Int32(target.width), height: Int32(target.height), codecType: kCMVideoCodecType_H264,
173
+ encoderSpecification: specification as CFDictionary, imageBufferAttributes: attributes as CFDictionary,
174
+ compressedDataAllocator: nil, outputCallback: nil, refcon: nil, compressionSessionOut: &created) == noErr,
175
+ let created
176
+ else { return nil }
177
+ let properties: [CFString: Any] = [
178
+ kVTCompressionPropertyKey_RealTime: true,
179
+ kVTCompressionPropertyKey_AllowFrameReordering: false,
180
+ kVTCompressionPropertyKey_ProfileLevel: kVTProfileLevel_H264_Main_AutoLevel,
181
+ kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration: 2,
182
+ kVTCompressionPropertyKey_ExpectedFrameRate: fps,
183
+ ]
184
+ for (key, value) in properties { VTSessionSetProperty(created, key: key, value: value as CFTypeRef) }
185
+ Self.applyBitrate(created, bitrate)
186
+ VTCompressionSessionPrepareToEncodeFrames(created)
187
+ session = created
188
+ return created
189
+ }
190
+
191
+ private func invalidate() {
192
+ guard let session else { return }
193
+ VTCompressionSessionCompleteFrames(session, untilPresentationTimeStamp: .invalid)
194
+ VTCompressionSessionInvalidate(session)
195
+ self.session = nil
196
+ size = (0, 0)
197
+ forceKeyframe = true
198
+ }
199
+
200
+ private static func applyBitrate(_ session: VTCompressionSession, _ bitrate: Int) {
201
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AverageBitRate, value: bitrate as CFNumber)
202
+ }
203
+
204
+ private func transferSession() -> VTPixelTransferSession? {
205
+ if transfer == nil {
206
+ VTPixelTransferSessionCreate(allocator: nil, pixelTransferSessionOut: &transfer)
207
+ if let transfer {
208
+ VTSessionSetProperty(transfer, key: kVTPixelTransferPropertyKey_ScalingMode, value: kVTScalingMode_Normal)
209
+ }
210
+ }
211
+ return transfer
212
+ }
213
+
214
+ private func upright(_ source: CVPixelBuffer, quarterTurns: Int, width: Int, height: Int) -> CVPixelBuffer? {
215
+ let turns = ((quarterTurns % 4) + 4) % 4
216
+ if turns == 0 { return source }
217
+ if rotation == nil { VTPixelRotationSessionCreate(nil, &rotation) }
218
+ guard let rotation else { return nil }
219
+ let angle = [kVTRotation_0, kVTRotation_CCW90, kVTRotation_180, kVTRotation_CW90][turns]
220
+ VTSessionSetProperty(rotation, key: kVTPixelRotationPropertyKey_Rotation, value: angle)
221
+ if rotated == nil || rotatedSize != (width, height) {
222
+ let attributes: [CFString: Any] = [
223
+ kCVPixelBufferPixelFormatTypeKey: CVPixelBufferGetPixelFormatType(source),
224
+ kCVPixelBufferWidthKey: width,
225
+ kCVPixelBufferHeightKey: height,
226
+ kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary,
227
+ ]
228
+ rotated = nil
229
+ CVPixelBufferPoolCreate(nil, nil, attributes as CFDictionary, &rotated)
230
+ rotatedSize = (width, height)
231
+ }
232
+ var buffer: CVPixelBuffer?
233
+ guard let rotated, CVPixelBufferPoolCreatePixelBuffer(nil, rotated, &buffer) == kCVReturnSuccess, let buffer,
234
+ VTPixelRotationSessionRotateImage(rotation, source, buffer) == noErr
235
+ else { return nil }
236
+ return buffer
237
+ }
238
+
239
+ private static let startCode: [UInt8] = [0, 0, 0, 1]
240
+
241
+ /// Converts the encoder's length-prefixed (AVCC) sample to Annex B, with the SPS and PPS before a keyframe.
242
+ static func annexB(_ sample: CMSampleBuffer) -> (data: Data, keyframe: Bool)? {
243
+ guard let block = CMSampleBufferGetDataBuffer(sample) else { return nil }
244
+ let attachments = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: false) as? [[CFString: Any]]
245
+ let keyframe = !(attachments?.first?[kCMSampleAttachmentKey_NotSync] as? Bool ?? false)
246
+ var out = Data()
247
+ if keyframe, let format = CMSampleBufferGetFormatDescription(sample) {
248
+ var count = 0
249
+ CMVideoFormatDescriptionGetH264ParameterSetAtIndex(
250
+ format, parameterSetIndex: 0, parameterSetPointerOut: nil, parameterSetSizeOut: nil,
251
+ parameterSetCountOut: &count, nalUnitHeaderLengthOut: nil)
252
+ for index in 0..<count {
253
+ var pointer: UnsafePointer<UInt8>?
254
+ var length = 0
255
+ guard
256
+ CMVideoFormatDescriptionGetH264ParameterSetAtIndex(
257
+ format, parameterSetIndex: index, parameterSetPointerOut: &pointer, parameterSetSizeOut: &length,
258
+ parameterSetCountOut: nil, nalUnitHeaderLengthOut: nil) == noErr, let pointer
259
+ else { return nil }
260
+ out.append(contentsOf: startCode)
261
+ out.append(pointer, count: length)
262
+ }
263
+ }
264
+ let total = CMBlockBufferGetDataLength(block)
265
+ guard total > 0 else { return nil }
266
+ var bytes = Data(count: total)
267
+ let copied = bytes.withUnsafeMutableBytes {
268
+ CMBlockBufferCopyDataBytes(block, atOffset: 0, dataLength: total, destination: $0.baseAddress!)
269
+ }
270
+ guard copied == noErr else { return nil }
271
+ var offset = 0
272
+ while offset + 4 <= bytes.count {
273
+ let length = bytes[offset..<(offset + 4)].reduce(0) { $0 << 8 | Int($1) }
274
+ offset += 4
275
+ guard length > 0, offset + length <= bytes.count else { return nil }
276
+ out.append(contentsOf: startCode)
277
+ out.append(bytes[offset..<(offset + length)])
278
+ offset += length
279
+ }
280
+ return (out, keyframe)
281
+ }
282
+ }