serve-sim 0.1.49-beta.136.1 → 0.1.49-beta.138.1

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
@@ -113,6 +113,9 @@ serve-sim exec <command...> [-d udid] Run a host shell command through a server
113
113
  several are quoted as an argv
114
114
  serve-sim doctor [-d udid] Collect redacted diagnostics for bug reports
115
115
  (--json, --out <file>)
116
+ serve-sim record [-d udid] Record the screen to an MP4 (native resolution,
117
+ 60 fps H.264); Ctrl+C stops. -o <file>,
118
+ -t <duration>, --codec h264|hevc, --fps, --bitrate
116
119
 
117
120
  serve-sim camera <bundle-id> [-d udid] [source-options]
118
121
  Inject a synthetic camera feed and (re)launch the app
@@ -227,6 +230,17 @@ Sources:
227
230
  - **file** — image (PNG/JPEG/HEIC/…) or video (mp4/mov/m4v/webm/…). The CLI sniffs the kind from the extension and falls back to magic bytes for files without an extension.
228
231
  - **webcam** — live `AVCaptureDevice` (built-in, Continuity, external).
229
232
 
233
+ ### Screen recording
234
+
235
+ The record button in the preview toolbar and `serve-sim record` both drive a native recorder in the server: it encodes the simulator framebuffer directly (full native resolution, constant frame rate, hardware H.264/HEVC), independent of the stream codec the viewer uses. Stopping streams the finished MP4 to the client that stopped it (a browser download, or the `-o` file) and deletes the server's temp copy. One recording runs per device; any client may stop it, and a `serve-sim record` whose recording is stopped elsewhere exits with an error instead of saving.
236
+
237
+ A recording is capped at 30 minutes. At the cap the server finalizes the file and holds it for the client that started it, which collects it automatically. If that client has gone away (tab closed, `serve-sim record` killed), the hold lapses after 5 minutes: then any client may collect it with Stop, and starting a new recording discards it.
238
+
239
+ Limitations:
240
+
241
+ - **Rotation is fixed at start.** The file's orientation comes from the simulator's orientation when recording starts; rotating mid-recording leaves the rest of the footage sideways. Stop and start a new recording after rotating.
242
+ - **Size changes freeze the video.** A track can't change dimensions, so if the screen size changes mid-recording (e.g. switching the Duo's active panel), frames at the new size are dropped and the rest of the video repeats the last frame at the original size.
243
+
230
244
  ## Connectors
231
245
 
232
246
  `serve-sim` can be used with dev servers, browser, and AI editors for more seamless integration.
@@ -17,6 +17,10 @@ struct Frame: Identifiable {
17
17
  /// (no pixel consumer was attached, so the private copy was skipped). Such
18
18
  /// frames are only good for their dimensions.
19
19
  var isPrivateCopy = true
20
+ /// Monotonic capture time (`DispatchTime` uptime, seconds) taken when
21
+ /// FrameCapture read the surface; screen recordings are timed from this
22
+ /// rather than from when the frame reached a consumer.
23
+ var captureTime: Double = Double(DispatchTime.now().uptimeNanoseconds) / 1e9
20
24
  }
21
25
 
22
26
  protocol FrameEncoder {
@@ -144,8 +148,16 @@ actor CaptureEngine {
144
148
  bufferingPolicy: .bufferingNewest(1)
145
149
  )
146
150
  do {
147
- try await frameCapture.start(deviceUDID: deviceUDID, pixelConsumers: pixelConsumers) { pixelBuffer, _, isCopy in
148
- frameContinuation.yield(Frame(pixelBuffer: pixelBuffer, isPrivateCopy: isCopy))
151
+ try await frameCapture.start(deviceUDID: deviceUDID, pixelConsumers: pixelConsumers) { [recorderTap] pixelBuffer, timestamp, isCopy in
152
+ let frame = Frame(
153
+ pixelBuffer: pixelBuffer,
154
+ isPrivateCopy: isCopy,
155
+ captureTime: timestamp.seconds
156
+ )
157
+ // The recorder needs every frame, so it's fed here rather
158
+ // than through the newest-only stream the live encoders share.
159
+ recorderTap.recorder?.handleFrame(frame)
160
+ frameContinuation.yield(frame)
149
161
  }
150
162
  } catch {
151
163
  // Preserve retryability after an ordinary start failure, but never
@@ -268,9 +280,55 @@ actor CaptureEngine {
268
280
  }
269
281
  }
270
282
 
283
+ // MARK: - Recording
284
+
285
+ /// The active recorder, read by FrameCapture's frame callback.
286
+ private let recorderTap = RecorderTap()
287
+ private var recording: ScreenRecorder?
288
+
289
+ var isRecording: Bool { recording != nil }
290
+
291
+ /// Start recording the screen to an MP4 at `options.path`. One recording
292
+ /// per capture session; it counts as a pixel consumer, so frames are
293
+ /// copied (and timestamped) for it even when no viewer is attached.
294
+ func startRecording(_ options: ScreenRecorder.Options) throws {
295
+ guard phase == .running else { throw RecordingErrors.notRunning }
296
+ guard recording == nil else { throw RecordingErrors.alreadyRecording }
297
+ let recorder = ScreenRecorder(options: options)
298
+ pixelConsumers.increment()
299
+ recording = recorder
300
+ recorderTap.recorder = recorder
301
+ Task { [frameCapture] in await frameCapture.requestFrame() }
302
+ }
303
+
304
+ /// Finish the active recording; returns its JSON summary.
305
+ func stopRecording() async throws -> String {
306
+ guard let recorder = recording else { throw RecordingErrors.notRecording }
307
+ recording = nil
308
+ recorderTap.recorder = nil
309
+ pixelConsumers.decrement()
310
+ return try await recorder.finish()
311
+ }
312
+
313
+ enum RecordingErrors: Error, CustomStringConvertible {
314
+ case notRunning, alreadyRecording, notRecording
315
+ var description: String {
316
+ switch self {
317
+ case .notRunning: return "Capture is not running"
318
+ case .alreadyRecording: return "A recording is already in progress"
319
+ case .notRecording: return "No recording in progress"
320
+ }
321
+ }
322
+ }
323
+
271
324
  func stop() {
272
325
  if phase == .stopped { return }
273
326
  phase = .stopped
327
+ if let recorder = recording {
328
+ recorderTap.recorder = nil
329
+ recorder.cancel()
330
+ recording = nil
331
+ }
274
332
  Task { [frameCapture] in await frameCapture.stop() }
275
333
  consumers.removeAll()
276
334
  pixelConsumers.reset()
@@ -331,3 +389,16 @@ actor AVCCEncoder: FrameEncoder {
331
389
  Task { [h264Encoder] in await h264Encoder.stop() }
332
390
  }
333
391
  }
392
+
393
+ /// Lock-protected handle on the active `ScreenRecorder`, shared between the
394
+ /// engine actor (which sets it) and FrameCapture's frame callback (which
395
+ /// hands it every captured frame without an actor hop).
396
+ private final class RecorderTap: @unchecked Sendable {
397
+ private let lock = NSLock()
398
+ private var _recorder: ScreenRecorder?
399
+
400
+ var recorder: ScreenRecorder? {
401
+ get { lock.withLock { _recorder } }
402
+ set { lock.withLock { _recorder = newValue } }
403
+ }
404
+ }
@@ -54,7 +54,7 @@ actor FrameCapture {
54
54
  private var sourceCache = FramebufferSourceCache<FramebufferSource>(maxAge: 1.0)
55
55
  private var recheckGeneration: UInt64 = 0
56
56
 
57
- /// `onFrame(buffer, timestamp, isPrivateCopy)`: frames are deep-copied only
57
+ /// `onFrame(buffer, captureTime, isPrivateCopy)`: frames are deep-copied only
58
58
  /// while `pixelConsumers` is non-empty; otherwise the buffer wraps the live
59
59
  /// framebuffer surface and is only good for its dimensions.
60
60
  func start(
@@ -370,7 +370,8 @@ actor FrameCapture {
370
370
 
371
371
  lastCaptureTime = .now
372
372
  frameCount += 1
373
- let timestamp = CMTime(value: CMTimeValue(frameCount), timescale: 60)
373
+ // Monotonic capture time (uptime ns): recordings place frames by it.
374
+ let timestamp = CMTime(value: CMTimeValue(DispatchTime.now().uptimeNanoseconds), timescale: 1_000_000_000)
374
375
  if pixelConsumers?.isEmpty ?? false {
375
376
  onFrame?(pb, timestamp, false)
376
377
  return true
@@ -54,8 +54,12 @@ struct Photocopier {
54
54
  let dstStride = CVPixelBufferGetBytesPerRow(dst)
55
55
  let rows = CVPixelBufferGetHeight(source)
56
56
  let copyBytes = min(srcStride, dstStride)
57
- for row in 0..<rows {
58
- memcpy(dstAddr + row * dstStride, srcAddr + row * srcStride, copyBytes)
57
+ if srcStride == dstStride {
58
+ memcpy(dstAddr, srcAddr, srcStride * rows)
59
+ } else {
60
+ for row in 0..<rows {
61
+ memcpy(dstAddr + row * dstStride, srcAddr + row * srcStride, copyBytes)
62
+ }
59
63
  }
60
64
  return dst
61
65
  }
@@ -0,0 +1,300 @@
1
+ import Foundation
2
+ import AVFoundation
3
+ import CoreMedia
4
+ import CoreVideo
5
+ import SimNativeSupport
6
+
7
+ /// Records the captured simulator screen straight to an MP4 with
8
+ /// AVAssetWriter (VideoToolbox hardware H.264/HEVC).
9
+ ///
10
+ /// Frames are the private BGRA copies FrameCapture already makes for the live
11
+ /// encoders: full native resolution, no MJPEG/stream re-encode, no scaling.
12
+ /// Each frame is placed on a constant `fps` grid by its capture time
13
+ /// (`RecordingFrameScheduler`), repeating the previous frame across static
14
+ /// stretches, so the file is true CFR whose motion timing follows the
15
+ /// simulator rather than the encoder or JS event loop. Static slots are filled
16
+ /// as time passes by a frame-interval timer on `queue` that never waits on the
17
+ /// encoder, so the first frames of new motion don't queue up behind seconds of
18
+ /// repeats (and get shed by the backlog cap).
19
+ ///
20
+ /// All writer state is confined to `queue`. `handleFrame` only enqueues, so a
21
+ /// slow encoder never stalls capture or the live stream consumers; the
22
+ /// backlog is capped (`maxPendingFrames`) so a stalled encoder can't pin an
23
+ /// unbounded number of full-resolution buffers.
24
+ final class ScreenRecorder: CaptureConsuming, @unchecked Sendable {
25
+ struct Options {
26
+ var path: String
27
+ var codec: AVVideoCodecType = .h264
28
+ var fps: Int = 60
29
+ /// Clockwise display rotation (0, 90, 180, 270/-90) stored as the
30
+ /// track transform. The framebuffer itself is always native portrait.
31
+ var rotationDegrees: Int = 0
32
+ /// Average bitrate (bits/s); nil scales with the pixel rate.
33
+ var bitrate: Int?
34
+ }
35
+
36
+ enum Errors: Error, CustomStringConvertible {
37
+ case noFrames
38
+ case writerFailed(String)
39
+
40
+ var description: String {
41
+ switch self {
42
+ case .noFrames: return "No frames were captured"
43
+ case .writerFailed(let message): return "Recording failed: \(message)"
44
+ }
45
+ }
46
+ }
47
+
48
+ /// ~200 ms at 60 fps; each pending frame holds a full-res BGRA copy.
49
+ static let maxPendingFrames = 12
50
+
51
+ private let queue = DispatchQueue(label: "screen-recorder", qos: .userInitiated)
52
+ private let pendingFrames = PendingFrameLimiter(limit: ScreenRecorder.maxPendingFrames)
53
+ private let options: Options
54
+ private let startedAt = Date()
55
+
56
+ // queue-confined
57
+ private var writer: AVAssetWriter?
58
+ private var input: AVAssetWriterInput?
59
+ private var adaptor: AVAssetWriterInputPixelBufferAdaptor?
60
+ private var scheduler: RecordingFrameScheduler
61
+ private var dimensions: Dimensions?
62
+ private var lastBuffer: CVPixelBuffer?
63
+ private var fillTimer: DispatchSourceTimer?
64
+ private var bitrate = 0
65
+ private var finished = false
66
+ private var failure: String?
67
+ private var captured = 0
68
+ private var encoded = 0
69
+ private var repeated = 0
70
+ private var dropped = 0
71
+ private var encoderBusy = 0
72
+
73
+ init(options: Options) {
74
+ self.options = options
75
+ self.scheduler = RecordingFrameScheduler(fps: options.fps)
76
+ }
77
+
78
+ func handleFrame(_ frame: Frame) {
79
+ guard frame.isPrivateCopy else { return }
80
+ // Over the cap the frame is dropped here (counted in `backlogDropped`);
81
+ // the CFR scheduler repeats the previous frame across the gap.
82
+ guard pendingFrames.tryAcquire() else { return }
83
+ queue.async {
84
+ defer { self.pendingFrames.release() }
85
+ self.append(frame)
86
+ }
87
+ }
88
+
89
+ /// Pad to "now", close the file, and return a JSON summary.
90
+ func finish() async throws -> String {
91
+ let stopTime = Self.now()
92
+ return try await withCheckedThrowingContinuation { continuation in
93
+ queue.async {
94
+ self.finish(at: stopTime) { result in continuation.resume(with: result) }
95
+ }
96
+ }
97
+ }
98
+
99
+ /// Abandon the recording and remove the partial file.
100
+ func cancel() {
101
+ queue.async {
102
+ self.finished = true
103
+ self.stopFillTimer()
104
+ if let writer = self.writer, writer.status == .writing { writer.cancelWriting() }
105
+ try? FileManager.default.removeItem(atPath: self.options.path)
106
+ }
107
+ }
108
+
109
+ // MARK: - queue
110
+
111
+ private func append(_ frame: Frame) {
112
+ guard !finished, failure == nil else { return }
113
+ captured += 1
114
+ let size = frame.pixelBuffer.dimensions
115
+ if let dimensions {
116
+ // A mid-recording size change (Duo panel switch, Device Hub
117
+ // resize) can't go in the same track; keep the original size.
118
+ guard size == dimensions else { dropped += 1; return }
119
+ } else {
120
+ do { try startWriter(size) } catch {
121
+ failure = String(describing: error)
122
+ return
123
+ }
124
+ }
125
+ guard let placement = scheduler.place(captureTime: frame.captureTime) else {
126
+ dropped += 1
127
+ return
128
+ }
129
+ // Normally empty: the fill timer already covered the static stretch.
130
+ if let lastBuffer {
131
+ for slot in placement.repeats where write(lastBuffer, slot: slot) { repeated += 1 }
132
+ }
133
+ if write(frame.pixelBuffer, slot: placement.slot) { encoded += 1 }
134
+ lastBuffer = frame.pixelBuffer
135
+ }
136
+
137
+ private func startWriter(_ size: Dimensions) throws {
138
+ let url = URL(fileURLWithPath: options.path)
139
+ try? FileManager.default.removeItem(at: url)
140
+ let writer = try AVAssetWriter(outputURL: url, fileType: .mp4)
141
+ // moov up front so browsers can play the download progressively.
142
+ writer.shouldOptimizeForNetworkUse = true
143
+ let bitrate = options.bitrate ?? RecordingBitrate.average(
144
+ width: size.width, height: size.height, fps: options.fps
145
+ )
146
+ var compression: [String: Any] = [
147
+ AVVideoAverageBitRateKey: bitrate,
148
+ AVVideoExpectedSourceFrameRateKey: options.fps,
149
+ AVVideoMaxKeyFrameIntervalKey: options.fps * 2,
150
+ AVVideoAllowFrameReorderingKey: true,
151
+ ]
152
+ if options.codec == .h264 {
153
+ compression[AVVideoProfileLevelKey] = AVVideoProfileLevelH264HighAutoLevel
154
+ }
155
+ let settings: [String: Any] = [
156
+ AVVideoCodecKey: options.codec,
157
+ AVVideoWidthKey: size.width,
158
+ AVVideoHeightKey: size.height,
159
+ AVVideoCompressionPropertiesKey: compression,
160
+ // The simulator renders Display P3 with the sRGB curve; tag the
161
+ // file the same way `simctl io recordVideo` does so colors match.
162
+ AVVideoColorPropertiesKey: [
163
+ AVVideoColorPrimariesKey: AVVideoColorPrimaries_P3_D65,
164
+ // AVVideoTransferFunction_IEC_sRGB (macOS 15+ symbol; same value).
165
+ AVVideoTransferFunctionKey: "IEC_sRGB",
166
+ AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_709_2,
167
+ ],
168
+ ]
169
+ let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
170
+ input.expectsMediaDataInRealTime = true
171
+ input.transform = Self.transform(rotationDegrees: options.rotationDegrees)
172
+ let adaptor = AVAssetWriterInputPixelBufferAdaptor(
173
+ assetWriterInput: input,
174
+ sourcePixelBufferAttributes: [
175
+ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
176
+ kCVPixelBufferWidthKey as String: size.width,
177
+ kCVPixelBufferHeightKey as String: size.height,
178
+ ]
179
+ )
180
+ guard writer.canAdd(input) else { throw Errors.writerFailed("cannot add video input") }
181
+ writer.add(input)
182
+ guard writer.startWriting() else {
183
+ throw Errors.writerFailed(writer.error.map { String(describing: $0) } ?? "startWriting failed")
184
+ }
185
+ writer.startSession(atSourceTime: .zero)
186
+ self.writer = writer
187
+ self.input = input
188
+ self.adaptor = adaptor
189
+ self.dimensions = size
190
+ self.bitrate = bitrate
191
+ startFillTimer()
192
+ print("[record] Recording \(size.width)x\(size.height) @ \(options.fps) fps \(options.codec.rawValue) \(bitrate / 1000) kbps -> \(options.path)")
193
+ }
194
+
195
+ private static func now() -> Double {
196
+ Double(DispatchTime.now().uptimeNanoseconds) / 1e9
197
+ }
198
+
199
+ private func startFillTimer() {
200
+ let interval = DispatchTimeInterval.nanoseconds(Int(1_000_000_000 / options.fps))
201
+ let timer = DispatchSource.makeTimerSource(queue: queue)
202
+ timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .milliseconds(1))
203
+ timer.setEventHandler { [weak self] in self?.fillStaticSlots() }
204
+ timer.resume()
205
+ fillTimer = timer
206
+ }
207
+
208
+ private func stopFillTimer() {
209
+ fillTimer?.cancel()
210
+ fillTimer = nil
211
+ }
212
+
213
+ /// Repeat the last frame into the slots that have gone by with no new
214
+ /// frame. Usually one slot per tick. Never waits on the encoder: a slot it
215
+ /// can't write yet stays owed to the next tick (or the next frame's
216
+ /// placement), so the queue stays free for captured frames. Skipped while
217
+ /// frames wait behind a slow encoder: their slots must stay open.
218
+ private func fillStaticSlots() {
219
+ guard !finished, failure == nil, let lastBuffer else { return }
220
+ for slot in scheduler.repeatsDue(at: Self.now(), framesPending: pendingFrames.pending) {
221
+ guard write(lastBuffer, slot: slot, waitForEncoder: false) else { break }
222
+ scheduler.didFill(through: slot)
223
+ repeated += 1
224
+ }
225
+ }
226
+
227
+ @discardableResult
228
+ private func write(_ buffer: CVPixelBuffer, slot: Int64, waitForEncoder: Bool = true) -> Bool {
229
+ guard let writer, let input, let adaptor, failure == nil else { return false }
230
+ // We're on the recorder's own queue, so waiting here only delays the
231
+ // recording, never capture or the live stream. Real-time inputs may
232
+ // briefly refuse data while the hardware encoder catches up.
233
+ var waitedMs = 0
234
+ while waitForEncoder, !input.isReadyForMoreMediaData, waitedMs < 250, writer.status == .writing {
235
+ usleep(1000)
236
+ waitedMs += 1
237
+ }
238
+ guard input.isReadyForMoreMediaData else {
239
+ if waitForEncoder { encoderBusy += 1 }
240
+ return false
241
+ }
242
+ let pts = CMTime(value: slot, timescale: CMTimeScale(options.fps))
243
+ guard adaptor.append(buffer, withPresentationTime: pts) else {
244
+ failure = writer.error.map { String(describing: $0) } ?? "append failed"
245
+ return false
246
+ }
247
+ return true
248
+ }
249
+
250
+ private func finish(at stopTime: Double, completion: @escaping (Result<String, Error>) -> Void) {
251
+ guard !finished else { return completion(.failure(Errors.writerFailed("already finished"))) }
252
+ finished = true
253
+ stopFillTimer()
254
+ guard let writer, let input, let lastBuffer, let end = scheduler.finish(at: stopTime) else {
255
+ if let writer, writer.status == .writing { writer.cancelWriting() }
256
+ try? FileManager.default.removeItem(atPath: options.path)
257
+ return completion(.failure(failure.map(Errors.writerFailed) ?? Errors.noFrames))
258
+ }
259
+ for slot in end.repeats where write(lastBuffer, slot: slot) { repeated += 1 }
260
+ input.markAsFinished()
261
+ let fps = options.fps
262
+ writer.endSession(atSourceTime: CMTime(value: end.endSlot, timescale: CMTimeScale(fps)))
263
+ let summary: [String: Any] = [
264
+ "path": options.path,
265
+ "width": dimensions?.width ?? 0,
266
+ "height": dimensions?.height ?? 0,
267
+ "fps": fps,
268
+ "codec": options.codec == .hevc ? "hevc" : "h264",
269
+ "bitrate": bitrate,
270
+ "rotation": options.rotationDegrees,
271
+ "frames": Int(end.endSlot),
272
+ "duration": Double(end.endSlot) / Double(fps),
273
+ "captured": captured,
274
+ "encoded": encoded,
275
+ "repeated": repeated,
276
+ "dropped": dropped + pendingFrames.dropped,
277
+ "backlogDropped": pendingFrames.dropped,
278
+ "encoderBusy": encoderBusy,
279
+ "startedAt": isoTimestamp(startedAt),
280
+ ]
281
+ writer.finishWriting {
282
+ if writer.status == .completed {
283
+ print("[record] Saved \(self.options.path) (\(summary["frames"] ?? 0) frames, \(self.captured) captured, \(self.repeated) repeated, \(self.dropped + self.pendingFrames.dropped) dropped)")
284
+ completion(.success(jsonString(summary)))
285
+ } else {
286
+ let message = writer.error.map { String(describing: $0) } ?? "status \(writer.status.rawValue)"
287
+ completion(.failure(Errors.writerFailed(message)))
288
+ }
289
+ }
290
+ }
291
+
292
+ private static func transform(rotationDegrees: Int) -> CGAffineTransform {
293
+ switch ((rotationDegrees % 360) + 360) % 360 {
294
+ case 90: return CGAffineTransform(rotationAngle: .pi / 2)
295
+ case 180: return CGAffineTransform(rotationAngle: .pi)
296
+ case 270: return CGAffineTransform(rotationAngle: -.pi / 2)
297
+ default: return .identity
298
+ }
299
+ }
300
+ }
@@ -203,6 +203,25 @@ private func u32(_ v: Int) -> UInt32 {
203
203
  await engine.diagnostics()
204
204
  }
205
205
 
206
+ /// Start an MP4 screen recording at `path`. `codec` is "h264" or "hevc";
207
+ /// `rotation` is the clockwise display rotation stored as the track
208
+ /// transform; `bitrate` <= 0 picks the resolution-scaled default.
209
+ @NodeMethod func startRecording(
210
+ _ path: String, _ codec: String, _ fps: Int, _ rotation: Int, _ bitrate: Int
211
+ ) async throws {
212
+ var options = ScreenRecorder.Options(path: path)
213
+ options.codec = codec == "hevc" ? .hevc : .h264
214
+ options.fps = min(max(fps, 1), 120)
215
+ options.rotationDegrees = rotation
216
+ options.bitrate = bitrate > 0 ? bitrate : nil
217
+ try await engine.startRecording(options)
218
+ }
219
+
220
+ /// Finish the active recording; resolves with a JSON summary string.
221
+ @NodeMethod func stopRecording() async throws -> String {
222
+ try await engine.stopRecording()
223
+ }
224
+
206
225
  @NodeMethod func stop() async {
207
226
  await engine.stop()
208
227
  }
@@ -0,0 +1,47 @@
1
+ import Foundation
2
+
3
+ /// Thread-safe cap on frames queued for a slow consumer (the screen
4
+ /// recorder's writer queue). Each queued frame pins a full-resolution pixel
5
+ /// buffer, so when the encoder falls behind we drop new frames rather than
6
+ /// let the backlog grow without bound.
7
+ public final class PendingFrameLimiter: @unchecked Sendable {
8
+ public let limit: Int
9
+ private let lock = NSLock()
10
+ private var _pending = 0
11
+ private var _dropped = 0
12
+
13
+ public init(limit: Int) {
14
+ self.limit = max(1, limit)
15
+ }
16
+
17
+ /// Reserve a slot for one frame; false (and counted as dropped) when full.
18
+ public func tryAcquire() -> Bool {
19
+ lock.lock()
20
+ defer { lock.unlock() }
21
+ guard _pending < limit else {
22
+ _dropped += 1
23
+ return false
24
+ }
25
+ _pending += 1
26
+ return true
27
+ }
28
+
29
+ /// Free the slot once the frame has been handled.
30
+ public func release() {
31
+ lock.lock()
32
+ _pending = max(0, _pending - 1)
33
+ lock.unlock()
34
+ }
35
+
36
+ public var pending: Int {
37
+ lock.lock()
38
+ defer { lock.unlock() }
39
+ return _pending
40
+ }
41
+
42
+ public var dropped: Int {
43
+ lock.lock()
44
+ defer { lock.unlock() }
45
+ return _dropped
46
+ }
47
+ }
@@ -0,0 +1,17 @@
1
+ /// Average bitrate for screen recordings.
2
+ ///
3
+ /// Recordings are archival, not latency-bound, so they get twice the live
4
+ /// stream's 0.1 bits/pixel (~38 Mbps for an iPhone Pro at 60 fps). That is a
5
+ /// ceiling, not a constant rate: static stretches are repeated frames that
6
+ /// encode to skip blocks, so typical UI recordings land far below it.
7
+ public enum RecordingBitrate {
8
+ public static let minimum = 8_000_000
9
+ public static let maximum = 80_000_000
10
+ static let bitsPerPixel = 0.2
11
+
12
+ public static func average(width: Int, height: Int, fps: Int) -> Int {
13
+ guard width > 0, height > 0, fps > 0 else { return minimum }
14
+ let target = Int(Double(width * height * fps) * bitsPerPixel)
15
+ return min(max(target, minimum), maximum)
16
+ }
17
+ }
@@ -0,0 +1,111 @@
1
+ /// Places captured simulator frames on a constant-frame-rate grid for screen
2
+ /// recordings.
3
+ ///
4
+ /// SimulatorKit only fires a frame callback when the screen changes, so the
5
+ /// raw capture is variable-rate: ~60 fps while scrolling, nothing at all while
6
+ /// the screen is static, and jittered by a few ms either way. A recording
7
+ /// stamped with raw capture times plays back fine in some players, but editors
8
+ /// and browsers handle sparse VFR poorly and tooling reports a meaningless
9
+ /// frame rate. Instead each frame is snapped to the nearest slot of a fixed
10
+ /// `fps` grid measured from the first frame's capture time, and empty slots
11
+ /// are filled by repeating the previous frame (cheap: H.264/HEVC encode an
12
+ /// unchanged frame as skip blocks). The result is true CFR whose motion timing
13
+ /// still follows the actual capture clock, not frame arrival order.
14
+ ///
15
+ /// Jitter handling: SimulatorKit often delivers frames in bursts (two or
16
+ /// three distinct frames a couple of ms apart after a compositor hiccup), so a
17
+ /// frame whose rounded slot is already taken moves to the next free slot as
18
+ /// long as that keeps it less than `maxLag` frame intervals behind its real
19
+ /// capture time. A few frames of lag is invisible in a recording and catches
20
+ /// up at the next pause; a dropped frame is a visible stutter. Only a source
21
+ /// that sustains more than `fps` (e.g. a 120 Hz burst) falls further behind,
22
+ /// and those frames are dropped rather than letting the timeline drift.
23
+ ///
24
+ /// Static fill: the recorder doesn't wait for the next frame to repeat the
25
+ /// previous one across a static stretch (that would make the first frame of
26
+ /// new motion write seconds of repeats first while the frames behind it pile
27
+ /// up). A frame-interval timer instead fills `repeatsDue(at:framesPending:)` as time passes,
28
+ /// leaving `fillHeadroom` slots open for a frame that was captured but not yet
29
+ /// delivered, and commits what it wrote with `didFill(through:)`.
30
+ public struct RecordingFrameScheduler: Sendable {
31
+ /// Where to append a captured frame.
32
+ public struct Placement: Equatable, Sendable {
33
+ /// Slots to fill with the previously appended frame first, in order.
34
+ public let repeats: Range<Int64>
35
+ /// Slot (presentation time = slot / fps) for the new frame.
36
+ public let slot: Int64
37
+ }
38
+
39
+ /// Frame intervals a frame may be placed after its capture time.
40
+ public static let defaultMaxLag = 3.0
41
+
42
+ /// Slots the static fill leaves open behind "now".
43
+ public static let defaultFillHeadroom: Int64 = 2
44
+
45
+ public let fps: Int
46
+ public let maxLag: Double
47
+ public let fillHeadroom: Int64
48
+ private var origin: Double?
49
+ private var lastSlot: Int64 = -1
50
+
51
+ public init(fps: Int, maxLag: Double = defaultMaxLag, fillHeadroom: Int64 = defaultFillHeadroom) {
52
+ precondition(fps > 0, "fps must be positive")
53
+ precondition(maxLag >= 1, "maxLag must allow at least the next slot")
54
+ precondition(fillHeadroom >= 1, "fillHeadroom must leave the current slot open")
55
+ self.fps = fps
56
+ self.maxLag = maxLag
57
+ self.fillHeadroom = fillHeadroom
58
+ }
59
+
60
+ /// Number of slots written so far (the next free slot).
61
+ public var slotCount: Int64 { lastSlot + 1 }
62
+
63
+ /// Place a frame captured at `time` (seconds on a monotonic clock). Returns
64
+ /// nil when the frame should be dropped.
65
+ public mutating func place(captureTime time: Double) -> Placement? {
66
+ guard let origin else {
67
+ self.origin = time
68
+ lastSlot = 0
69
+ return Placement(repeats: 0..<0, slot: 0)
70
+ }
71
+ let exact = (time - origin) * Double(fps)
72
+ var slot = Int64(exact.rounded())
73
+ if slot <= lastSlot {
74
+ // Nudge a jittered/bursty frame into the next slot, but never
75
+ // more than `maxLag` intervals after it was really captured.
76
+ let next = lastSlot + 1
77
+ guard Double(next) - exact < maxLag else { return nil }
78
+ slot = next
79
+ }
80
+ let repeats = (lastSlot + 1)..<slot
81
+ lastSlot = slot
82
+ return Placement(repeats: repeats, slot: slot)
83
+ }
84
+
85
+ /// Static slots to repeat the last frame into by `time`: everything up to
86
+ /// `fillHeadroom` slots behind it. Empty before the first frame, and while
87
+ /// captured frames are still queued for the writer (`framesPending > 0`):
88
+ /// under encoder backlog "now" can run past those frames' slots, and the
89
+ /// next one placed covers the gap with its own repeats anyway.
90
+ public func repeatsDue(at time: Double, framesPending: Int = 0) -> Range<Int64> {
91
+ guard let origin, framesPending == 0 else { return lastSlot + 1..<lastSlot + 1 }
92
+ let target = Int64(((time - origin) * Double(fps)).rounded(.down)) - fillHeadroom
93
+ return (lastSlot + 1)..<max(lastSlot + 1, target + 1)
94
+ }
95
+
96
+ /// Record that the static fill wrote every slot through `slot`.
97
+ public mutating func didFill(through slot: Int64) {
98
+ lastSlot = max(lastSlot, slot)
99
+ }
100
+
101
+ /// Slots to fill with the last frame so the recording runs until `time`,
102
+ /// plus the end time (in slots) to close the session at. Nil when no frame
103
+ /// was ever placed.
104
+ public mutating func finish(at time: Double) -> (repeats: Range<Int64>, endSlot: Int64)? {
105
+ guard let origin else { return nil }
106
+ let target = Int64(((time - origin) * Double(fps)).rounded(.down))
107
+ let repeats = (lastSlot + 1)..<max(lastSlot + 1, target + 1)
108
+ if let last = repeats.last { lastSlot = last }
109
+ return (repeats, lastSlot + 1)
110
+ }
111
+ }