@stim-cli/server 1.10.0 → 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,389 @@
1
+ import CoreImage
2
+ import CoreVideo
3
+ import Foundation
4
+ import ImageIO
5
+ import IOSurface
6
+
7
+ // stim-frames streams one device's screen as JPEG frames for stim-server.
8
+ //
9
+ // stim-frames ios <udid>
10
+ // stim-frames android <serial>
11
+ //
12
+ // stdin takes one JSON object per line: {"fps": n, "maxEdge": px, "quality": 0-1,
13
+ // "jpeg": bool, "jpegFps": n, "video": bool, "bitrate": bits per second}, or
14
+ // {"keyframe": true} to make the next video frame a keyframe.
15
+ // stdout carries messages framed as a 4-byte big-endian length, then a kind byte:
16
+ // 1 is a frame (2-byte width, 2-byte height, JPEG bytes), 2 is a JSON notice
17
+ // ({"error": message} before a failed exit), 3 is an H.264 access unit (1-byte
18
+ // flags with bit 0 set on a keyframe, 8-byte big-endian float capture time in
19
+ // milliseconds since the epoch, 2-byte width, 2-byte height, Annex-B bytes).
20
+ // The helper exits when stdin closes.
21
+
22
+ struct Config: Equatable {
23
+ var fps = 5.0
24
+ var maxEdge = 1280
25
+ var quality = 0.7
26
+ var jpeg = true
27
+ var jpegFps: Double?
28
+ var video = false
29
+ var bitrate = 3_000_000
30
+ }
31
+
32
+ enum Output {
33
+ private static let writer = DispatchQueue(label: "stim.frames.output")
34
+ private static let lock = NSLock()
35
+ private static var writing = false
36
+ private static var pendingVideo = 0
37
+ private static var videoNeedsKeyframe = false
38
+ private static var keyframeRequested = false
39
+ private static let maxPendingVideo = 30
40
+ static var requestKeyframe: () -> Void = {}
41
+
42
+ static func frame(jpeg: Data, width: Int, height: Int) {
43
+ lock.lock()
44
+ defer { lock.unlock() }
45
+ guard !writing else { return }
46
+ writing = true
47
+ var body = Data([1, UInt8(width >> 8), UInt8(width & 0xff), UInt8(height >> 8), UInt8(height & 0xff)])
48
+ body += jpeg
49
+ writer.async {
50
+ write(body)
51
+ lock.lock()
52
+ writing = false
53
+ lock.unlock()
54
+ }
55
+ }
56
+
57
+ static func video(_ unit: AccessUnit) {
58
+ lock.lock()
59
+ defer { lock.unlock() }
60
+ if videoNeedsKeyframe && !unit.keyframe { return }
61
+ guard pendingVideo < maxPendingVideo else {
62
+ videoNeedsKeyframe = true
63
+ return
64
+ }
65
+ videoNeedsKeyframe = false
66
+ keyframeRequested = false
67
+ pendingVideo += 1
68
+ var body = Data([3, unit.keyframe ? 1 : 0])
69
+ withUnsafeBytes(of: unit.capturedAt.bitPattern.bigEndian) { body.append(contentsOf: $0) }
70
+ body += Data([UInt8(unit.width >> 8), UInt8(unit.width & 0xff), UInt8(unit.height >> 8), UInt8(unit.height & 0xff)])
71
+ body += unit.data
72
+ writer.async {
73
+ write(body)
74
+ lock.lock()
75
+ pendingVideo -= 1
76
+ let ask = videoNeedsKeyframe && !keyframeRequested
77
+ if ask { keyframeRequested = true }
78
+ lock.unlock()
79
+ if ask { requestKeyframe() }
80
+ }
81
+ }
82
+
83
+ static func notice(_ object: [String: String]) {
84
+ guard let json = try? JSONSerialization.data(withJSONObject: object) else { return }
85
+ writer.sync { write(Data([2]) + json) }
86
+ }
87
+
88
+ private static func write(_ body: Data) {
89
+ let length = UInt32(body.count)
90
+ var message = Data([UInt8(length >> 24), UInt8((length >> 16) & 0xff), UInt8((length >> 8) & 0xff), UInt8(length & 0xff)])
91
+ message += body
92
+ FileHandle.standardOutput.write(message)
93
+ }
94
+ }
95
+
96
+ func fail(_ message: String) -> Never {
97
+ Output.notice(["error": message])
98
+ exit(1)
99
+ }
100
+
101
+ let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
102
+ let context = CIContext(options: [.cacheIntermediates: false])
103
+
104
+ func jpeg(_ image: CIImage, config: Config) -> (Data, Int, Int)? {
105
+ let extent = image.extent
106
+ let scale = min(1, Double(config.maxEdge) / max(extent.width, extent.height))
107
+ let scaled = scale < 1 ? image.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) : image
108
+ let size = scaled.extent.integral
109
+ let cropped = scaled.transformed(by: CGAffineTransform(translationX: -size.minX, y: -size.minY))
110
+ guard size.width > 0, size.height > 0,
111
+ let data = context.jpegRepresentation(
112
+ of: cropped.cropped(to: CGRect(origin: .zero, size: size.size)), colorSpace: colorSpace,
113
+ options: [CIImageRepresentationOption(rawValue: kCGImageDestinationLossyCompressionQuality as String): config.quality])
114
+ else { return nil }
115
+ return (data, Int(size.width), Int(size.height))
116
+ }
117
+
118
+ final class Pacer {
119
+ let queue = DispatchQueue(label: "stim.frames.pacer")
120
+ var config = Config()
121
+ private var dirty = false
122
+ private var scheduled = false
123
+ private var last = DispatchTime(uptimeNanoseconds: 0)
124
+ private let render: (Config) -> Void
125
+
126
+ init(render: @escaping (Config) -> Void) {
127
+ self.render = render
128
+ }
129
+
130
+ func changed() {
131
+ queue.async {
132
+ self.dirty = true
133
+ self.schedule()
134
+ }
135
+ }
136
+
137
+ private func schedule() {
138
+ guard dirty, !scheduled else { return }
139
+ let next = last + .nanoseconds(Int(1_000_000_000 / max(config.fps, 0.1)))
140
+ scheduled = true
141
+ queue.asyncAfter(deadline: max(next, .now())) {
142
+ self.scheduled = false
143
+ guard self.dirty else { return }
144
+ self.dirty = false
145
+ self.last = .now()
146
+ self.render(self.config)
147
+ }
148
+ }
149
+ }
150
+
151
+ func videoEncoder() -> VideoEncoder {
152
+ VideoEncoder(maxEdge: Config().maxEdge, fps: Int(Config().fps), bitrate: Config().bitrate, output: Output.video)
153
+ }
154
+
155
+ /// Keeps JPEG at `jpegFps` while video renders faster. A frame it skips is rendered again once the
156
+ /// interval passes, so the last frame of a burst still reaches JPEG subscribers. Used on the pacer queue.
157
+ final class JpegGate {
158
+ private var last = 0.0
159
+ private var retrying = false
160
+
161
+ func admit(_ config: Config, pacer: Pacer) -> Bool {
162
+ let now = CFAbsoluteTimeGetCurrent()
163
+ let wait = last + 1 / max(config.jpegFps ?? config.fps, 0.1) - now
164
+ if wait <= 0 {
165
+ last = now
166
+ return true
167
+ }
168
+ if !retrying {
169
+ retrying = true
170
+ pacer.queue.asyncAfter(deadline: .now() + wait) {
171
+ self.retrying = false
172
+ pacer.changed()
173
+ }
174
+ }
175
+ return false
176
+ }
177
+ }
178
+
179
+ func now() -> Double { Date().timeIntervalSince1970 * 1000 }
180
+
181
+ final class SimulatorSource {
182
+ private let udid: String
183
+ private let pacer: Pacer
184
+ private var display: SimDisplay?
185
+ private let callbackID = NSUUID()
186
+ private let video = videoEncoder()
187
+ private let jpegGate = JpegGate()
188
+
189
+ init(udid: String) {
190
+ self.udid = udid
191
+ var render: (Config) -> Void = { _ in }
192
+ pacer = Pacer { render($0) }
193
+ render = { [unowned self] config in self.render(config) }
194
+ }
195
+
196
+ var queue: DispatchQueue { pacer.queue }
197
+
198
+ func start(deadline: Date = Date().addingTimeInterval(30)) {
199
+ guard let display = CoreSimulator.displays(udid: udid).first else {
200
+ if Date() > deadline { fail("Simulator \(udid) has no display with a framebuffer. Is it booted?") }
201
+ queue.asyncAfter(deadline: .now() + 1) { self.start(deadline: deadline) }
202
+ return
203
+ }
204
+ self.display = display
205
+ display.registerDamageCallback(callbackID) { [weak self] _ in self?.pacer.changed() }
206
+ display.registerSurfacesCallback(callbackID) { [weak self] _ in self?.pacer.changed() }
207
+ display.registerPropertiesCallback(callbackID) { [weak self] _ in self?.pacer.changed() }
208
+ pacer.changed()
209
+ }
210
+
211
+ func configure(_ config: Config) {
212
+ video.configure(enabled: config.video, maxEdge: config.maxEdge, fps: Int(config.fps), bitrate: config.bitrate)
213
+ queue.async {
214
+ self.pacer.config = config
215
+ self.pacer.changed()
216
+ }
217
+ }
218
+
219
+ func keyframe() {
220
+ video.requestKeyframe()
221
+ pacer.changed()
222
+ }
223
+
224
+ // uiOrientation is a UIInterfaceOrientation; the framebuffer stays in the
225
+ // display's native portrait orientation, so the image is turned upright.
226
+ private func render(_ config: Config) {
227
+ guard let surface = display?.framebufferSurface else { return }
228
+ let orientation: CGImagePropertyOrientation
229
+ let quarterTurns: Int
230
+ switch display?.screenProperties?.uiOrientation ?? 1 {
231
+ case 2: (orientation, quarterTurns) = (.down, 2)
232
+ case 3: (orientation, quarterTurns) = (.right, 3)
233
+ case 4: (orientation, quarterTurns) = (.left, 1)
234
+ default: (orientation, quarterTurns) = (.up, 0)
235
+ }
236
+ let ioSurface = unsafeBitCast(surface, to: IOSurfaceRef.self)
237
+ if config.video {
238
+ var buffer: Unmanaged<CVPixelBuffer>?
239
+ CVPixelBufferCreateWithIOSurface(nil, ioSurface, nil, &buffer)
240
+ if let pixels = buffer?.takeRetainedValue() {
241
+ video.encode(pixels, quarterTurns: quarterTurns, capturedAt: now())
242
+ }
243
+ }
244
+ guard config.jpeg, jpegGate.admit(config, pacer: pacer) else { return }
245
+ let image = CIImage(ioSurface: ioSurface).oriented(orientation)
246
+ guard let (data, width, height) = jpeg(image, config: config) else { return }
247
+ Output.frame(jpeg: data, width: width, height: height)
248
+ }
249
+ }
250
+
251
+ final class EmulatorSource {
252
+ private let serial: String
253
+ private let queue = DispatchQueue(label: "stim.frames.emulator")
254
+ private var stream: ScreenshotStream?
255
+ private var generation = 0
256
+ private var config = Config()
257
+ private var latest: EmulatorFrame?
258
+ private var pacer: Pacer!
259
+ private let video = videoEncoder()
260
+ private let jpegGate = JpegGate()
261
+
262
+ init(serial: String) {
263
+ self.serial = serial
264
+ pacer = Pacer { [unowned self] config in
265
+ guard let frame = self.queue.sync(execute: { self.latest }) else { return }
266
+ self.render(frame, config: config)
267
+ }
268
+ }
269
+
270
+ func start() {
271
+ queue.async { self.connect() }
272
+ }
273
+
274
+ func configure(_ config: Config) {
275
+ video.configure(enabled: config.video, maxEdge: config.maxEdge, fps: Int(config.fps), bitrate: config.bitrate)
276
+ queue.async {
277
+ let resized = config.maxEdge != self.config.maxEdge
278
+ self.config = config
279
+ self.pacer.queue.async {
280
+ self.pacer.config = config
281
+ self.pacer.changed()
282
+ }
283
+ if resized, let stream = self.stream {
284
+ self.stream = nil
285
+ stream.cancel()
286
+ self.connect()
287
+ }
288
+ }
289
+ }
290
+
291
+ func keyframe() {
292
+ video.requestKeyframe()
293
+ pacer.changed()
294
+ }
295
+
296
+ private func connect() {
297
+ guard let endpoint = EmulatorDiscovery.endpoint(serial: serial) else {
298
+ fail("\(serial) has no gRPC endpoint. Frames appear after Stim next boots this emulator.")
299
+ }
300
+ generation += 1
301
+ let current = generation
302
+ let stream = ScreenshotStream(
303
+ endpoint: endpoint, width: config.maxEdge, height: config.maxEdge,
304
+ onFrame: { [weak self] frame in
305
+ guard let self else { return }
306
+ self.queue.async { self.latest = frame }
307
+ self.pacer.changed()
308
+ },
309
+ onEnd: { [weak self] in
310
+ guard let self else { return }
311
+ self.queue.async {
312
+ if self.generation == current { fail("The emulator \(self.serial) ended its screenshot stream.") }
313
+ }
314
+ })
315
+ self.stream = stream
316
+ stream.start()
317
+ }
318
+
319
+ private func render(_ frame: EmulatorFrame, config: Config) {
320
+ if config.video { video.encode(rgba: frame.rgba, width: frame.width, height: frame.height, capturedAt: now()) }
321
+ guard config.jpeg, jpegGate.admit(config, pacer: pacer), let provider = CGDataProvider(data: frame.rgba as CFData),
322
+ let image = CGImage(
323
+ width: frame.width, height: frame.height, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: frame.width * 4,
324
+ space: colorSpace, bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue),
325
+ provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent),
326
+ let (data, width, height) = jpeg(CIImage(cgImage: image), config: config)
327
+ else { return }
328
+ Output.frame(jpeg: data, width: width, height: height)
329
+ }
330
+ }
331
+
332
+ func parseConfig(_ line: Substring, base: Config) -> Config? {
333
+ guard let object = try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any] else { return nil }
334
+ var config = base
335
+ if let fps = object["fps"] as? Double, fps > 0 { config.fps = min(fps, 60) }
336
+ if let edge = object["maxEdge"] as? Int, edge > 0 { config.maxEdge = min(edge, 4096) }
337
+ if let quality = object["quality"] as? Double, quality > 0, quality <= 1 { config.quality = quality }
338
+ if let jpeg = object["jpeg"] as? Bool { config.jpeg = jpeg }
339
+ if let jpegFps = object["jpegFps"] as? Double, jpegFps > 0 { config.jpegFps = min(jpegFps, 60) }
340
+ if let video = object["video"] as? Bool { config.video = video }
341
+ if let bitrate = object["bitrate"] as? Int, bitrate > 0 { config.bitrate = bitrate }
342
+ return config
343
+ }
344
+
345
+ func readCommands(_ apply: @escaping (Config) -> Void, keyframe: @escaping () -> Void) {
346
+ Thread.detachNewThread {
347
+ var buffer = Data()
348
+ var config = Config()
349
+ while true {
350
+ let chunk = FileHandle.standardInput.availableData
351
+ if chunk.isEmpty { exit(0) }
352
+ buffer += chunk
353
+ while let newline = buffer.firstIndex(of: 0x0a) {
354
+ let line = String(decoding: buffer[buffer.startIndex..<newline], as: UTF8.self)
355
+ buffer.removeSubrange(buffer.startIndex...newline)
356
+ if line.contains("\"keyframe\"") {
357
+ if (try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any])?["keyframe"] as? Bool == true {
358
+ keyframe()
359
+ }
360
+ } else if let parsed = parseConfig(Substring(line), base: config) {
361
+ config = parsed
362
+ apply(config)
363
+ }
364
+ }
365
+ }
366
+ }
367
+ }
368
+
369
+ setvbuf(stdout, nil, _IONBF, 0)
370
+ signal(SIGPIPE, SIG_IGN)
371
+ let arguments = CommandLine.arguments
372
+ guard arguments.count == 3 else { fail("usage: stim-frames ios <udid> | android <serial>") }
373
+ switch arguments[1] {
374
+ case "ios":
375
+ CoreSimulator.developerDir = CoreSimulator.selectedDeveloperDir()
376
+ guard CoreSimulator.deviceSet != nil else { fail("CoreSimulator could not be loaded from \(CoreSimulator.developerDir).") }
377
+ let source = SimulatorSource(udid: arguments[2])
378
+ Output.requestKeyframe = source.keyframe
379
+ readCommands({ source.configure($0) }, keyframe: source.keyframe)
380
+ source.queue.async { source.start() }
381
+ case "android":
382
+ let source = EmulatorSource(serial: arguments[2])
383
+ Output.requestKeyframe = source.keyframe
384
+ readCommands({ source.configure($0) }, keyframe: source.keyframe)
385
+ source.start()
386
+ default:
387
+ fail("usage: stim-frames ios <udid> | android <serial>")
388
+ }
389
+ dispatchMain()