@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.
- package/dist/android/gradle.d.ts +5 -0
- package/dist/android/gradle.js +13 -33
- package/dist/common/installDependencies.js +21 -18
- package/dist/common/projectSources.js +1 -1
- package/dist/context.d.ts +2 -2
- package/dist/context.js +31 -1
- package/dist/ios/fastlane.d.ts +1 -1
- package/dist/steps/easFunctions.js +18 -0
- package/dist/steps/functions/capturePosthogEvent.d.ts +2 -0
- package/dist/steps/functions/capturePosthogEvent.js +79 -0
- package/dist/steps/functions/createPosthogAnnotation.d.ts +2 -0
- package/dist/steps/functions/createPosthogAnnotation.js +75 -0
- package/dist/steps/functions/createSubmissionEntity.js +25 -1
- package/dist/steps/functions/downloadArtifact.js +13 -3
- package/dist/steps/functions/finishIosSimulatorRecordings.d.ts +2 -0
- package/dist/steps/functions/finishIosSimulatorRecordings.js +24 -0
- package/dist/steps/functions/repack.js +12 -7
- package/dist/steps/functions/restoreCache.js +2 -1
- package/dist/steps/functions/rolloutPosthogFlag.d.ts +2 -0
- package/dist/steps/functions/rolloutPosthogFlag.js +208 -0
- package/dist/steps/functions/saveCache.js +3 -2
- package/dist/steps/functions/startAgentDeviceRemoteSession.js +21 -8
- package/dist/steps/functions/startArgentRemoteSession.d.ts +14 -0
- package/dist/steps/functions/startArgentRemoteSession.js +105 -44
- package/dist/steps/functions/startIosSimulator.js +19 -0
- package/dist/steps/functions/startIosSimulatorRecordings.d.ts +2 -0
- package/dist/steps/functions/startIosSimulatorRecordings.js +20 -0
- package/dist/steps/functions/startServeSimRemoteSession.js +7 -5
- package/dist/steps/functions/uploadDeviceRunSessionScreenRecordings.d.ts +3 -0
- package/dist/steps/functions/uploadDeviceRunSessionScreenRecordings.js +91 -0
- package/dist/steps/functions/uploadPosthogSourcemaps.d.ts +2 -0
- package/dist/steps/functions/uploadPosthogSourcemaps.js +83 -0
- package/dist/steps/functions/waitForPosthogMetric.d.ts +2 -0
- package/dist/steps/functions/waitForPosthogMetric.js +135 -0
- package/dist/steps/functions/waitForPosthogQuery.d.ts +2 -0
- package/dist/steps/functions/waitForPosthogQuery.js +95 -0
- package/dist/steps/utils/IosSimulatorRecordingUtils.d.ts +17 -0
- package/dist/steps/utils/IosSimulatorRecordingUtils.js +218 -0
- package/dist/steps/utils/PosthogClient.d.ts +46 -0
- package/dist/steps/utils/PosthogClient.js +156 -0
- package/dist/steps/utils/PosthogUtils.d.ts +41 -0
- package/dist/steps/utils/PosthogUtils.js +65 -0
- package/dist/steps/utils/agentDeviceArtifacts.d.ts +27 -0
- package/dist/steps/utils/agentDeviceArtifacts.js +133 -0
- package/dist/steps/utils/android/gradle.d.ts +5 -0
- package/dist/steps/utils/android/gradle.js +13 -62
- package/dist/steps/utils/argentArtifacts.d.ts +5 -3
- package/dist/steps/utils/argentArtifacts.js +95 -23
- package/dist/steps/utils/deviceRunSessionArtifacts.d.ts +3 -1
- package/dist/steps/utils/deviceRunSessionArtifacts.js +6 -2
- package/dist/steps/utils/remoteDeviceRunSession.d.ts +8 -0
- package/dist/steps/utils/remoteDeviceRunSession.js +62 -1
- package/dist/utils/AndroidEmulatorUtils.js +3 -0
- package/dist/utils/IosSimulatorUtils.d.ts +5 -0
- package/dist/utils/IosSimulatorUtils.js +81 -2
- package/dist/utils/expoUpdates.js +6 -1
- package/dist/utils/expoUpdatesEmbedded.js +4 -0
- package/dist/utils/processes.d.ts +1 -0
- package/dist/utils/processes.js +23 -0
- package/package.json +9 -8
- package/resources/record-sim/Package.swift +28 -0
- package/resources/record-sim/README.md +79 -0
- package/resources/record-sim/Sources/RecordSim/FramebufferDisplaySource.swift +192 -0
- package/resources/record-sim/Sources/RecordSim/PrivateSimulatorServices.swift +96 -0
- package/resources/record-sim/Sources/RecordSim/RecordingModels.swift +70 -0
- package/resources/record-sim/Sources/RecordSim/RecordingOutputWriter.swift +136 -0
- package/resources/record-sim/Sources/RecordSim/SimulatorRecorder.swift +632 -0
- package/resources/record-sim/Sources/RecordSim/Utilities.swift +60 -0
- 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
|
+
}
|