@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.
- package/README.md +194 -31
- package/dist/{protocol-dn2_TUeH.mjs → protocol-CmlG7X7_.mjs} +272 -11
- package/dist/protocol.d.mts +190 -11
- package/dist/protocol.mjs +2 -2
- package/dist/protocol.schema.json +484 -5
- package/dist/stim-frames/EmulatorDiscovery.swift +49 -0
- package/dist/stim-frames/EmulatorInput.swift +141 -0
- package/dist/stim-frames/GrpcFraming.swift +85 -0
- package/dist/stim-frames/InputMessages.swift +73 -0
- package/dist/stim-frames/ScreenshotMessages.swift +129 -0
- package/dist/stim-frames/ScreenshotStream.swift +130 -0
- package/dist/stim-frames/SimulatorDisplay.swift +116 -0
- package/dist/stim-frames/SimulatorHID.swift +101 -0
- package/dist/stim-frames/VideoEncoder.swift +282 -0
- package/dist/stim-frames/main.swift +389 -0
- package/dist/stim-server.mjs +1394 -124
- package/package.json +4 -4
|
@@ -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()
|