@expo/build-tools 20.4.0 → 21.0.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.
Files changed (69) hide show
  1. package/dist/android/gradle.d.ts +5 -0
  2. package/dist/android/gradle.js +13 -33
  3. package/dist/common/installDependencies.js +21 -18
  4. package/dist/common/projectSources.js +1 -1
  5. package/dist/context.d.ts +2 -2
  6. package/dist/context.js +31 -1
  7. package/dist/ios/fastlane.d.ts +1 -1
  8. package/dist/steps/easFunctions.js +18 -0
  9. package/dist/steps/functions/capturePosthogEvent.d.ts +2 -0
  10. package/dist/steps/functions/capturePosthogEvent.js +79 -0
  11. package/dist/steps/functions/createPosthogAnnotation.d.ts +2 -0
  12. package/dist/steps/functions/createPosthogAnnotation.js +75 -0
  13. package/dist/steps/functions/createSubmissionEntity.js +25 -1
  14. package/dist/steps/functions/downloadArtifact.js +13 -3
  15. package/dist/steps/functions/finishIosSimulatorRecordings.d.ts +2 -0
  16. package/dist/steps/functions/finishIosSimulatorRecordings.js +24 -0
  17. package/dist/steps/functions/repack.js +12 -7
  18. package/dist/steps/functions/restoreCache.js +2 -1
  19. package/dist/steps/functions/rolloutPosthogFlag.d.ts +2 -0
  20. package/dist/steps/functions/rolloutPosthogFlag.js +208 -0
  21. package/dist/steps/functions/saveCache.js +3 -2
  22. package/dist/steps/functions/startAgentDeviceRemoteSession.js +21 -8
  23. package/dist/steps/functions/startArgentRemoteSession.d.ts +14 -0
  24. package/dist/steps/functions/startArgentRemoteSession.js +105 -44
  25. package/dist/steps/functions/startIosSimulator.js +19 -0
  26. package/dist/steps/functions/startIosSimulatorRecordings.d.ts +2 -0
  27. package/dist/steps/functions/startIosSimulatorRecordings.js +20 -0
  28. package/dist/steps/functions/startServeSimRemoteSession.js +7 -5
  29. package/dist/steps/functions/uploadDeviceRunSessionScreenRecordings.d.ts +3 -0
  30. package/dist/steps/functions/uploadDeviceRunSessionScreenRecordings.js +91 -0
  31. package/dist/steps/functions/uploadPosthogSourcemaps.d.ts +2 -0
  32. package/dist/steps/functions/uploadPosthogSourcemaps.js +83 -0
  33. package/dist/steps/functions/waitForPosthogMetric.d.ts +2 -0
  34. package/dist/steps/functions/waitForPosthogMetric.js +135 -0
  35. package/dist/steps/functions/waitForPosthogQuery.d.ts +2 -0
  36. package/dist/steps/functions/waitForPosthogQuery.js +95 -0
  37. package/dist/steps/utils/IosSimulatorRecordingUtils.d.ts +17 -0
  38. package/dist/steps/utils/IosSimulatorRecordingUtils.js +218 -0
  39. package/dist/steps/utils/PosthogClient.d.ts +46 -0
  40. package/dist/steps/utils/PosthogClient.js +156 -0
  41. package/dist/steps/utils/PosthogUtils.d.ts +41 -0
  42. package/dist/steps/utils/PosthogUtils.js +65 -0
  43. package/dist/steps/utils/agentDeviceArtifacts.d.ts +27 -0
  44. package/dist/steps/utils/agentDeviceArtifacts.js +133 -0
  45. package/dist/steps/utils/android/gradle.d.ts +5 -0
  46. package/dist/steps/utils/android/gradle.js +13 -62
  47. package/dist/steps/utils/argentArtifacts.d.ts +5 -3
  48. package/dist/steps/utils/argentArtifacts.js +95 -23
  49. package/dist/steps/utils/deviceRunSessionArtifacts.d.ts +3 -1
  50. package/dist/steps/utils/deviceRunSessionArtifacts.js +6 -2
  51. package/dist/steps/utils/remoteDeviceRunSession.d.ts +8 -0
  52. package/dist/steps/utils/remoteDeviceRunSession.js +62 -1
  53. package/dist/utils/AndroidEmulatorUtils.js +3 -0
  54. package/dist/utils/IosSimulatorUtils.d.ts +5 -0
  55. package/dist/utils/IosSimulatorUtils.js +81 -2
  56. package/dist/utils/expoUpdates.js +6 -1
  57. package/dist/utils/expoUpdatesEmbedded.js +4 -0
  58. package/dist/utils/processes.d.ts +1 -0
  59. package/dist/utils/processes.js +23 -0
  60. package/package.json +9 -8
  61. package/resources/record-sim/Package.swift +28 -0
  62. package/resources/record-sim/README.md +79 -0
  63. package/resources/record-sim/Sources/RecordSim/FramebufferDisplaySource.swift +192 -0
  64. package/resources/record-sim/Sources/RecordSim/PrivateSimulatorServices.swift +96 -0
  65. package/resources/record-sim/Sources/RecordSim/RecordingModels.swift +70 -0
  66. package/resources/record-sim/Sources/RecordSim/RecordingOutputWriter.swift +136 -0
  67. package/resources/record-sim/Sources/RecordSim/SimulatorRecorder.swift +632 -0
  68. package/resources/record-sim/Sources/RecordSim/Utilities.swift +60 -0
  69. package/resources/record-sim/Sources/record-sim/main.swift +143 -0
@@ -0,0 +1,632 @@
1
+ import AVFoundation
2
+ import CoreVideo
3
+ import Foundation
4
+ import IOSurface
5
+ import UniformTypeIdentifiers
6
+
7
+ public final class SimulatorRecorder {
8
+ public var onSegment: ((SegmentOutput) -> Void)?
9
+ public var onSimulatorStopped: ((String) -> Void)?
10
+
11
+ private static let callbackStalenessTimeout: TimeInterval = 5
12
+ private static let firstFrameRewireInterval: TimeInterval = 1
13
+ private let configuration: SimulatorRecordingConfiguration
14
+ private let callbackQueue = DispatchQueue(
15
+ label: "record-sim.frame-callbacks", qos: .userInteractive)
16
+ private let writerQueue = DispatchQueue(label: "record-sim.asset-writer", qos: .userInitiated)
17
+ private let eventQueue = DispatchQueue(label: "record-sim.events", qos: .utility)
18
+ private let pendingLock = NSLock()
19
+ private var pendingFrames = 0
20
+ private var displaySource: FramebufferDisplaySource?
21
+ private var outputWriter: RecordingOutputWriter?
22
+ private var writer: AVAssetWriter?
23
+ private var input: AVAssetWriterInput?
24
+ private var adaptor: AVAssetWriterInputPixelBufferAdaptor?
25
+ private var boundaryTimer: DispatchSourceTimer?
26
+ private var monotonicClock = MonotonicClock()
27
+ private var firstAcceptedCaptureTime: CMTime?
28
+ private var firstAcceptedWallClock: Date?
29
+ private var lastPTS: CMTime?
30
+ private var lastSeed: UInt32?
31
+ private var lastFrameCallbackElapsed: TimeInterval?
32
+ private var lastFirstFrameRewireElapsed: TimeInterval = 0
33
+ private var lastAppendedPixelBuffer: CVPixelBuffer?
34
+ private var nextBoundaryElapsed: TimeInterval = 0
35
+ private var simulatorStoppedReason: String?
36
+ private var stopped = false
37
+ private var firstFrameReady = false
38
+ private let firstFrameSemaphore = DispatchSemaphore(value: 0)
39
+ private var firstError: Error?
40
+
41
+ public init(configuration: SimulatorRecordingConfiguration) {
42
+ self.configuration = configuration
43
+ }
44
+
45
+ public func start() throws {
46
+ try validateConfiguration()
47
+
48
+ let outputWriter = RecordingOutputWriter(
49
+ rootDirectory: configuration.outputDirectory,
50
+ onSegment: { [weak self] segment in
51
+ self?.onSegment?(segment)
52
+ })
53
+ try outputWriter.prepare(
54
+ overwrite: configuration.overwrite, segmented: configuration.segmentDuration > 0)
55
+ self.outputWriter = outputWriter
56
+
57
+ monotonicClock = MonotonicClock()
58
+ nextBoundaryElapsed = configuration.segmentDuration
59
+ lastFirstFrameRewireElapsed = 0
60
+ stopped = false
61
+
62
+ let source = FramebufferDisplaySource(
63
+ deviceUDID: configuration.deviceUDID,
64
+ callbackQueue: callbackQueue,
65
+ onFrame: { [weak self] in
66
+ self?.captureFrame(force: false, reason: .callback)
67
+ },
68
+ onSurfaceChange: { [weak self] in
69
+ self?.captureFrame(force: true, reason: .surfaceChange)
70
+ }
71
+ )
72
+ try source.start()
73
+ displaySource = source
74
+
75
+ startBoundaryTimer()
76
+ }
77
+
78
+ public func waitUntilFirstFrame(timeoutSeconds: TimeInterval = 15) throws {
79
+ let alreadyReady = try writerQueue.sync {
80
+ if let firstError {
81
+ throw firstError
82
+ }
83
+ return firstFrameReady
84
+ }
85
+ if alreadyReady {
86
+ return
87
+ }
88
+ if firstFrameSemaphore.wait(timeout: .now() + timeoutSeconds) == .timedOut {
89
+ throw RecorderError.make(20, "Timed out waiting for first frame")
90
+ }
91
+ try writerQueue.sync {
92
+ if let firstError {
93
+ throw firstError
94
+ }
95
+ if !firstFrameReady {
96
+ throw RecorderError.make(21, "Recorder stopped before first frame")
97
+ }
98
+ }
99
+ }
100
+
101
+ public func simulatorStopReason() -> String? {
102
+ callbackQueue.sync {
103
+ simulatorStoppedReason
104
+ }
105
+ }
106
+
107
+ @discardableResult
108
+ public func stop() throws -> RecordingManifest {
109
+ callbackQueue.sync {
110
+ stopped = true
111
+ boundaryTimer?.cancel()
112
+ boundaryTimer = nil
113
+ displaySource?.stop()
114
+ displaySource = nil
115
+ }
116
+
117
+ return try writerQueue.sync {
118
+ if let firstError {
119
+ throw firstError
120
+ }
121
+ guard let writer, let input, let outputWriter else {
122
+ let error = RecorderError.make(22, "No frames were captured")
123
+ firstError = error
124
+ signalFirstFrameReadyIfNeeded()
125
+ throw error
126
+ }
127
+ try appendTailFrameIfNeeded(finalPTS: monotonicClock.elapsedTime())
128
+ input.markAsFinished()
129
+ let semaphore = DispatchSemaphore(value: 0)
130
+ writer.finishWriting {
131
+ semaphore.signal()
132
+ }
133
+ if semaphore.wait(timeout: .now() + 60) == .timedOut {
134
+ throw RecorderError.make(23, "Timed out finishing AVAssetWriter")
135
+ }
136
+ if writer.status == .failed {
137
+ throw writer.error ?? RecorderError.make(24, "AVAssetWriter failed")
138
+ }
139
+ if let error = outputWriter.error {
140
+ throw error
141
+ }
142
+ guard let firstAcceptedWallClock else {
143
+ throw RecorderError.make(25, "Missing first frame wall-clock timestamp")
144
+ }
145
+ return try outputWriter.writeManifest(
146
+ configuration: configuration,
147
+ firstFrameWallClock: firstAcceptedWallClock
148
+ )
149
+ }
150
+ }
151
+
152
+ private enum CaptureReason {
153
+ case callback
154
+ case surfaceChange
155
+ case boundary
156
+ case healthProbe
157
+ }
158
+
159
+ private func validateConfiguration() throws {
160
+ guard !configuration.deviceUDID.isEmpty else {
161
+ throw RecorderError.make(30, "deviceUDID must not be empty")
162
+ }
163
+ guard configuration.fps > 0, configuration.fps <= 120 else {
164
+ throw RecorderError.make(31, "fps must be between 1 and 120")
165
+ }
166
+ guard configuration.bitrate > 0 else {
167
+ throw RecorderError.make(32, "bitrate must be positive")
168
+ }
169
+ guard configuration.segmentDuration >= 0 else {
170
+ throw RecorderError.make(33, "segmentDuration must be non-negative")
171
+ }
172
+ guard configuration.maxPendingFrames > 0 else {
173
+ throw RecorderError.make(34, "maxPendingFrames must be positive")
174
+ }
175
+ }
176
+
177
+ private func startBoundaryTimer() {
178
+ let timer = DispatchSource.makeTimerSource(queue: callbackQueue)
179
+ timer.schedule(deadline: .now() + 1, repeating: 1, leeway: .milliseconds(100))
180
+ timer.setEventHandler { [weak self] in
181
+ self?.probeSimulatorState()
182
+ self?.probeFramebufferHealth()
183
+ self?.appendBoundaryFrameIfNeeded()
184
+ }
185
+ timer.resume()
186
+ boundaryTimer = timer
187
+ }
188
+
189
+ private func appendBoundaryFrameIfNeeded() {
190
+ if stopped {
191
+ return
192
+ }
193
+ guard configuration.segmentDuration > 0 else {
194
+ return
195
+ }
196
+ writerQueue.sync {
197
+ guard firstAcceptedCaptureTime != nil else {
198
+ return
199
+ }
200
+ let currentPTSSeconds = CMTimeGetSeconds(
201
+ normalizedPresentationTime(for: monotonicClock.elapsedTime()))
202
+ guard currentPTSSeconds >= nextBoundaryElapsed else {
203
+ return
204
+ }
205
+ let boundary = nextBoundaryElapsed
206
+ guard let lastPTS else {
207
+ return
208
+ }
209
+
210
+ do {
211
+ if CMTimeGetSeconds(lastPTS) < boundary {
212
+ let boundaryPTS = CMTime(seconds: boundary, preferredTimescale: 1_000_000_000)
213
+ guard try appendHeldFrame(at: boundaryPTS) else {
214
+ return
215
+ }
216
+ }
217
+ nextBoundaryElapsed = boundary + configuration.segmentDuration
218
+ } catch {
219
+ firstError = error
220
+ signalFirstFrameReadyIfNeeded()
221
+ }
222
+ }
223
+ }
224
+
225
+ private func captureFrame(force: Bool, reason: CaptureReason) {
226
+ if stopped {
227
+ return
228
+ }
229
+ if reason == .callback {
230
+ lastFrameCallbackElapsed = monotonicClock.elapsedSeconds()
231
+ }
232
+ guard let snapshot = displaySource?.surfaceSnapshot() else {
233
+ return
234
+ }
235
+ let surface = snapshot.surface
236
+ let surfaceWidth = snapshot.width
237
+ let surfaceHeight = snapshot.height
238
+ guard surfaceWidth > 0, surfaceHeight > 0 else {
239
+ return
240
+ }
241
+
242
+ let seed = snapshot.seed
243
+ let matchesLastAppendedSeed = writerQueue.sync { lastSeed == seed }
244
+ if !force, matchesLastAppendedSeed {
245
+ return
246
+ }
247
+
248
+ guard reservePendingFrame() else {
249
+ return
250
+ }
251
+
252
+ let capturedAt = monotonicClock.elapsedTime()
253
+ let capturedAtWallClock = Date()
254
+ do {
255
+ let pixelBuffer = try copySurfaceToOwnedPixelBuffer(
256
+ surface,
257
+ width: surfaceWidth,
258
+ height: surfaceHeight
259
+ )
260
+ appendOwned(
261
+ pixelBuffer: pixelBuffer,
262
+ seed: seed,
263
+ capturedAt: capturedAt,
264
+ capturedAtWallClock: capturedAtWallClock
265
+ )
266
+ } catch {
267
+ releasePendingFrame()
268
+ writerQueue.async {
269
+ self.firstError = error
270
+ self.signalFirstFrameReadyIfNeeded()
271
+ }
272
+ }
273
+ }
274
+
275
+ private func probeFramebufferHealth() {
276
+ guard !stopped, let displaySource else {
277
+ return
278
+ }
279
+ let elapsed = monotonicClock.elapsedSeconds()
280
+ if !isFirstFrameReady(),
281
+ elapsed - lastFirstFrameRewireElapsed >= Self.firstFrameRewireInterval
282
+ {
283
+ lastFirstFrameRewireElapsed = elapsed
284
+ rewireFramebuffer()
285
+ captureFrame(force: true, reason: .healthProbe)
286
+ return
287
+ }
288
+
289
+ guard let snapshot = displaySource.surfaceSnapshot(),
290
+ snapshot.width > 0,
291
+ snapshot.height > 0
292
+ else {
293
+ rewireFramebuffer()
294
+ return
295
+ }
296
+
297
+ let observedSeed = snapshot.seed
298
+ let lastAppendedSeed = writerQueue.sync { lastSeed }
299
+ guard let lastAppendedSeed, observedSeed != lastAppendedSeed else {
300
+ return
301
+ }
302
+
303
+ let lastCallback = lastFrameCallbackElapsed ?? 0
304
+ if elapsed - lastCallback >= Self.callbackStalenessTimeout {
305
+ captureFrame(force: false, reason: .healthProbe)
306
+ rewireFramebuffer()
307
+ }
308
+ }
309
+
310
+ private func probeSimulatorState() {
311
+ guard !stopped, simulatorStoppedReason == nil else {
312
+ return
313
+ }
314
+ let state = SimulatorDeviceLookup.stateString(udid: configuration.deviceUDID) ?? "missing"
315
+ guard state != "Booted" else {
316
+ return
317
+ }
318
+ simulatorStoppedReason = state
319
+ let error = RecorderError.make(26, "Simulator stopped before first frame (state: \(state))")
320
+ writerQueue.async {
321
+ if self.firstError == nil, !self.firstFrameReady {
322
+ self.firstError = error
323
+ self.signalFirstFrameReadyIfNeeded()
324
+ }
325
+ }
326
+ let onSimulatorStopped = onSimulatorStopped
327
+ eventQueue.async {
328
+ onSimulatorStopped?(state)
329
+ }
330
+ }
331
+
332
+ private func rewireFramebuffer() {
333
+ guard let displaySource else {
334
+ return
335
+ }
336
+ do {
337
+ try displaySource.rewireFramebuffer()
338
+ } catch {
339
+ writerQueue.async {
340
+ self.firstError = error
341
+ self.signalFirstFrameReadyIfNeeded()
342
+ }
343
+ }
344
+ }
345
+
346
+ private func reservePendingFrame() -> Bool {
347
+ pendingLock.lock()
348
+ defer { pendingLock.unlock() }
349
+ if pendingFrames >= configuration.maxPendingFrames {
350
+ return false
351
+ }
352
+ pendingFrames += 1
353
+ return true
354
+ }
355
+
356
+ private func releasePendingFrame() {
357
+ pendingLock.lock()
358
+ pendingFrames = max(0, pendingFrames - 1)
359
+ pendingLock.unlock()
360
+ }
361
+
362
+ private func copySurfaceToOwnedPixelBuffer(
363
+ _ surface: IOSurface,
364
+ width: Int,
365
+ height: Int
366
+ ) throws -> CVPixelBuffer {
367
+ let pool = try writerQueue.sync {
368
+ if let firstError {
369
+ throw firstError
370
+ }
371
+ if writer == nil {
372
+ try startWriter(width: width, height: height)
373
+ }
374
+ guard let pool = adaptor?.pixelBufferPool else {
375
+ throw RecorderError.make(40, "AVAssetWriter pixel buffer pool is unavailable")
376
+ }
377
+ return pool
378
+ }
379
+
380
+ var output: CVPixelBuffer?
381
+ let status = CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &output)
382
+ guard status == kCVReturnSuccess, let destination = output else {
383
+ throw RecorderError.make(41, "Failed to allocate pixel buffer from pool: \(status)")
384
+ }
385
+
386
+ let destinationLockStatus = CVPixelBufferLockBaseAddress(destination, [])
387
+ guard destinationLockStatus == kCVReturnSuccess else {
388
+ throw RecorderError.make(50, "Failed to lock pixel buffer: \(destinationLockStatus)")
389
+ }
390
+ let surfaceLockStatus = IOSurfaceLock(surface, .readOnly, nil)
391
+ guard surfaceLockStatus == KERN_SUCCESS else {
392
+ CVPixelBufferUnlockBaseAddress(destination, [])
393
+ throw RecorderError.make(49, "Failed to lock IOSurface: \(surfaceLockStatus)")
394
+ }
395
+ defer {
396
+ IOSurfaceUnlock(surface, .readOnly, nil)
397
+ CVPixelBufferUnlockBaseAddress(destination, [])
398
+ }
399
+
400
+ guard let destinationAddress = CVPixelBufferGetBaseAddress(destination) else {
401
+ throw RecorderError.make(42, "Pixel buffer has no base address")
402
+ }
403
+ let sourceAddress = IOSurfaceGetBaseAddress(surface)
404
+ let sourceStride = IOSurfaceGetBytesPerRow(surface)
405
+ let destinationStride = CVPixelBufferGetBytesPerRow(destination)
406
+ let copyWidth = min(width, IOSurfaceGetWidth(surface), CVPixelBufferGetWidth(destination))
407
+ let copyHeight = min(
408
+ height, IOSurfaceGetHeight(surface), CVPixelBufferGetHeight(destination))
409
+ let copyBytes = min(copyWidth * 4, sourceStride, destinationStride)
410
+ for row in 0..<copyHeight {
411
+ memcpy(
412
+ destinationAddress.advanced(by: row * destinationStride),
413
+ sourceAddress.advanced(by: row * sourceStride),
414
+ copyBytes
415
+ )
416
+ }
417
+ return destination
418
+ }
419
+
420
+ private func appendOwned(
421
+ pixelBuffer: CVPixelBuffer,
422
+ seed: UInt32,
423
+ capturedAt: CMTime,
424
+ capturedAtWallClock: Date
425
+ ) {
426
+ writerQueue.async {
427
+ defer { self.releasePendingFrame() }
428
+ if self.firstError != nil {
429
+ return
430
+ }
431
+ do {
432
+ try self.appendOnWriterQueue(
433
+ pixelBuffer: pixelBuffer,
434
+ seed: seed,
435
+ capturedAt: capturedAt,
436
+ capturedAtWallClock: capturedAtWallClock
437
+ )
438
+ } catch {
439
+ self.firstError = error
440
+ self.signalFirstFrameReadyIfNeeded()
441
+ }
442
+ }
443
+ }
444
+
445
+ private func appendOnWriterQueue(
446
+ pixelBuffer: CVPixelBuffer,
447
+ seed: UInt32,
448
+ capturedAt: CMTime,
449
+ capturedAtWallClock: Date
450
+ ) throws {
451
+ guard let writer, let input, let adaptor else {
452
+ throw RecorderError.make(43, "AVAssetWriter is not initialized")
453
+ }
454
+ let isFirstFrame = firstAcceptedCaptureTime == nil
455
+ let pts = isFirstFrame ? .zero : normalizedPresentationTime(for: capturedAt)
456
+ if let lastPTS, CMTimeCompare(pts, lastPTS) <= 0 {
457
+ return
458
+ }
459
+ guard try appendPendingBoundaryFrames(before: pts) else {
460
+ return
461
+ }
462
+ guard input.isReadyForMoreMediaData else {
463
+ return
464
+ }
465
+ if !adaptor.append(pixelBuffer, withPresentationTime: pts) {
466
+ throw writer.error ?? RecorderError.make(44, "Failed to append pixel buffer")
467
+ }
468
+ if isFirstFrame {
469
+ firstAcceptedCaptureTime = capturedAt
470
+ firstAcceptedWallClock = capturedAtWallClock
471
+ }
472
+ lastPTS = pts
473
+ lastSeed = seed
474
+ lastAppendedPixelBuffer = pixelBuffer
475
+ signalFirstFrameReadyIfNeeded()
476
+ }
477
+
478
+ private func appendTailFrameIfNeeded(finalPTS: CMTime) throws {
479
+ let deadline = Date().addingTimeInterval(5)
480
+ while true {
481
+ if try appendTailFrame(finalPTS: finalPTS) {
482
+ return
483
+ }
484
+ if Date() >= deadline {
485
+ throw RecorderError.make(45, "Timed out waiting to append tail frame")
486
+ }
487
+ Thread.sleep(forTimeInterval: 0.05)
488
+ }
489
+ }
490
+
491
+ private func appendTailFrame(finalPTS: CMTime) throws -> Bool {
492
+ let pts = normalizedPresentationTime(for: finalPTS)
493
+ return try appendHeldFrame(at: pts)
494
+ }
495
+
496
+ private func appendPendingBoundaryFrames(before pts: CMTime) throws -> Bool {
497
+ guard configuration.segmentDuration > 0,
498
+ firstAcceptedCaptureTime != nil
499
+ else {
500
+ return true
501
+ }
502
+
503
+ while CMTimeGetSeconds(pts) > nextBoundaryElapsed {
504
+ if let lastPTS, CMTimeGetSeconds(lastPTS) >= nextBoundaryElapsed {
505
+ nextBoundaryElapsed += configuration.segmentDuration
506
+ continue
507
+ }
508
+
509
+ let boundaryPTS = CMTime(
510
+ seconds: nextBoundaryElapsed, preferredTimescale: 1_000_000_000)
511
+ guard try appendHeldFrame(at: boundaryPTS) else {
512
+ return false
513
+ }
514
+ nextBoundaryElapsed += configuration.segmentDuration
515
+ }
516
+ return true
517
+ }
518
+
519
+ private func appendHeldFrame(at pts: CMTime) throws -> Bool {
520
+ guard let input,
521
+ let adaptor,
522
+ let lastPTS,
523
+ let lastAppendedPixelBuffer
524
+ else {
525
+ return true
526
+ }
527
+ if CMTimeCompare(pts, lastPTS) <= 0 {
528
+ return true
529
+ }
530
+ guard input.isReadyForMoreMediaData else {
531
+ return false
532
+ }
533
+ if !adaptor.append(lastAppendedPixelBuffer, withPresentationTime: pts) {
534
+ throw writer?.error ?? RecorderError.make(45, "Failed to append held frame")
535
+ }
536
+ self.lastPTS = pts
537
+ return true
538
+ }
539
+
540
+ private func normalizedPresentationTime(for capturedAt: CMTime) -> CMTime {
541
+ let start = firstAcceptedCaptureTime ?? capturedAt
542
+ return CMTimeSubtract(capturedAt, start)
543
+ }
544
+
545
+ private func startWriter(width: Int, height: Int) throws {
546
+ guard let outputWriter else {
547
+ throw RecorderError.make(46, "Missing output writer")
548
+ }
549
+
550
+ let writer: AVAssetWriter
551
+ if configuration.segmentDuration > 0 {
552
+ writer = AVAssetWriter(contentType: .mpeg4Movie)
553
+ writer.delegate = outputWriter
554
+ writer.outputFileTypeProfile = .mpeg4AppleHLS
555
+ writer.preferredOutputSegmentInterval = CMTime(
556
+ seconds: configuration.segmentDuration,
557
+ preferredTimescale: 600
558
+ )
559
+ } else {
560
+ writer = try AVAssetWriter(outputURL: outputWriter.singleRecordingURL, fileType: .mp4)
561
+ }
562
+ writer.initialSegmentStartTime = .zero
563
+
564
+ let input = AVAssetWriterInput(
565
+ mediaType: .video,
566
+ outputSettings: assetWriterSettings(width: width, height: height)
567
+ )
568
+ input.expectsMediaDataInRealTime = true
569
+
570
+ let attributes: [String: Any] = [
571
+ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
572
+ kCVPixelBufferWidthKey as String: width,
573
+ kCVPixelBufferHeightKey as String: height,
574
+ kCVPixelBufferIOSurfacePropertiesKey as String: [:],
575
+ ]
576
+ let adaptor = AVAssetWriterInputPixelBufferAdaptor(
577
+ assetWriterInput: input,
578
+ sourcePixelBufferAttributes: attributes
579
+ )
580
+
581
+ guard writer.canAdd(input) else {
582
+ throw RecorderError.make(47, "AVAssetWriter cannot add video input")
583
+ }
584
+ writer.add(input)
585
+ guard writer.startWriting() else {
586
+ throw writer.error ?? RecorderError.make(48, "AVAssetWriter failed to start")
587
+ }
588
+ writer.startSession(atSourceTime: .zero)
589
+
590
+ self.writer = writer
591
+ self.input = input
592
+ self.adaptor = adaptor
593
+ }
594
+
595
+ private func assetWriterSettings(width: Int, height: Int) -> [String: Any] {
596
+ let codecType: AVVideoCodecType = configuration.codec == .hevc ? .hevc : .h264
597
+ var compression: [String: Any] = [
598
+ AVVideoAverageBitRateKey: configuration.bitrate,
599
+ AVVideoExpectedSourceFrameRateKey: configuration.fps,
600
+ AVVideoAllowFrameReorderingKey: true,
601
+ ]
602
+ if configuration.segmentDuration > 0 {
603
+ compression[AVVideoMaxKeyFrameIntervalKey] = max(
604
+ 1,
605
+ Int((configuration.segmentDuration * Double(configuration.fps)).rounded())
606
+ )
607
+ }
608
+ if configuration.codec == .h264 {
609
+ compression[AVVideoProfileLevelKey] = AVVideoProfileLevelH264HighAutoLevel
610
+ }
611
+ return [
612
+ AVVideoCodecKey: codecType,
613
+ AVVideoWidthKey: width,
614
+ AVVideoHeightKey: height,
615
+ AVVideoCompressionPropertiesKey: compression,
616
+ ]
617
+ }
618
+
619
+ private func signalFirstFrameReadyIfNeeded() {
620
+ if firstFrameReady {
621
+ return
622
+ }
623
+ firstFrameReady = true
624
+ firstFrameSemaphore.signal()
625
+ }
626
+
627
+ private func isFirstFrameReady() -> Bool {
628
+ writerQueue.sync {
629
+ firstFrameReady
630
+ }
631
+ }
632
+ }
@@ -0,0 +1,60 @@
1
+ import CoreMedia
2
+ import Foundation
3
+
4
+ enum RecorderError {
5
+ static func make(_ code: Int, _ message: String) -> NSError {
6
+ NSError(
7
+ domain: "RecordSim",
8
+ code: code,
9
+ userInfo: [NSLocalizedDescriptionKey: message]
10
+ )
11
+ }
12
+ }
13
+
14
+ extension JSONEncoder {
15
+ static var prettySorted: JSONEncoder {
16
+ let encoder = JSONEncoder()
17
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
18
+ return encoder
19
+ }
20
+ }
21
+
22
+ func timeSeconds(_ time: CMTime) -> Double? {
23
+ guard time.isValid, !time.isIndefinite, !time.isNegativeInfinity, !time.isPositiveInfinity else {
24
+ return nil
25
+ }
26
+ return CMTimeGetSeconds(time)
27
+ }
28
+
29
+ func iso8601(_ date: Date) -> String {
30
+ let formatter = ISO8601DateFormatter()
31
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
32
+ formatter.timeZone = TimeZone(secondsFromGMT: 0)
33
+ return formatter.string(from: date)
34
+ }
35
+
36
+ func unixMs(_ date: Date) -> Int64 {
37
+ Int64((date.timeIntervalSince1970 * 1000).rounded())
38
+ }
39
+
40
+ struct MonotonicClock {
41
+ private static let timescale: CMTimeScale = 1_000_000_000
42
+
43
+ let startedAtUptimeNanoseconds: UInt64
44
+
45
+ init() {
46
+ startedAtUptimeNanoseconds = DispatchTime.now().uptimeNanoseconds
47
+ }
48
+
49
+ func elapsedSeconds() -> TimeInterval {
50
+ TimeInterval(elapsedNanoseconds()) / TimeInterval(Self.timescale)
51
+ }
52
+
53
+ func elapsedTime() -> CMTime {
54
+ CMTime(value: CMTimeValue(elapsedNanoseconds()), timescale: Self.timescale)
55
+ }
56
+
57
+ private func elapsedNanoseconds() -> UInt64 {
58
+ DispatchTime.now().uptimeNanoseconds - startedAtUptimeNanoseconds
59
+ }
60
+ }