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 +14 -0
- package/Sources/SimNative/CaptureEngine.swift +73 -2
- package/Sources/SimNative/FrameCapture.swift +3 -2
- package/Sources/SimNative/PixelBufferUtils.swift +6 -2
- package/Sources/SimNative/ScreenRecorder.swift +300 -0
- package/Sources/SimNative/sim-module.swift +19 -0
- package/Sources/SimNativeSupport/PendingFrameLimiter.swift +47 -0
- package/Sources/SimNativeSupport/RecordingBitrate.swift +17 -0
- package/Sources/SimNativeSupport/RecordingFrameScheduler.swift +111 -0
- package/Tests/SimNativeSupportTests/PendingFrameLimiterTests.swift +34 -0
- package/Tests/SimNativeSupportTests/RecordingBitrateTests.swift +23 -0
- package/Tests/SimNativeSupportTests/RecordingFrameSchedulerTests.swift +193 -0
- package/dist/middleware.js +49 -49
- package/dist/native/serve-sim-native.node +0 -0
- package/dist/serve-sim.js +88 -88
- package/package.json +3 -1
- package/src/client/simulator/orientation.ts +201 -0
- package/src/device-session.ts +278 -1
- package/src/middleware.ts +4 -0
- package/src/native.ts +61 -0
- package/src/recording.ts +143 -0
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,
|
|
148
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
memcpy(dstAddr
|
|
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
|
+
}
|