serve-sim 0.1.49-beta.131.1 → 0.1.49-beta.133.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
@@ -111,6 +111,8 @@ serve-sim exec <command...> [-d udid] Run a host shell command through a server
111
111
  started with --allow-exec. One argument
112
112
  is a shell line (exec "xcrun simctl list");
113
113
  several are quoted as an argv
114
+ serve-sim doctor [-d udid] Collect redacted diagnostics for bug reports
115
+ (--json, --out <file>)
114
116
 
115
117
  serve-sim camera <bundle-id> [-d udid] [source-options]
116
118
  Inject a synthetic camera feed and (re)launch the app
@@ -33,8 +33,10 @@ actor CaptureConsumer<E: FrameEncoder>: CaptureConsuming {
33
33
 
34
34
  init(
35
35
  encoder: E,
36
+ diagnostics: EncoderDiagnostics? = nil,
36
37
  onFrame: @escaping @isolated(any) (E.Encoded) async -> Void
37
38
  ) {
39
+ let codec = String(describing: E.self).replacingOccurrences(of: "Encoder", with: "")
38
40
  let (stream, continuation) = AsyncStream.makeStream(
39
41
  of: Frame.self,
40
42
  // drop old frames if there's backpressure
@@ -46,9 +48,17 @@ actor CaptureConsumer<E: FrameEncoder>: CaptureConsuming {
46
48
  for await frame in stream {
47
49
  do {
48
50
  let encoded = try await encoder.encode(frame)
51
+ diagnostics?.recordSuccess(codec: codec)
49
52
  await onFrame(encoded)
50
53
  } catch {
51
- print("error encoding \(E.self) frame: \(error)")
54
+ // One line per distinct failure plus periodic counts; the
55
+ // per-frame print used to flood the log (x20 per second).
56
+ let size = frame.pixelBuffer.dimensions
57
+ if let diagnostics {
58
+ diagnostics.recordFailure(codec: codec, error: error, width: size.width, height: size.height)
59
+ } else {
60
+ print("error encoding \(E.self) frame: \(error)")
61
+ }
52
62
  continue
53
63
  }
54
64
  }
@@ -76,6 +86,7 @@ actor CaptureEngine {
76
86
 
77
87
  // mjpeg is stateless so we can share a single encoder instance
78
88
  private let mjpegEncoder = MJPEGEncoder()
89
+ private let encoderDiagnostics = EncoderDiagnostics()
79
90
 
80
91
  private(set) var screenSize = Dimensions(width: 0, height: 0)
81
92
  private var consumers = [UUID: CaptureConsuming]()
@@ -88,6 +99,20 @@ actor CaptureEngine {
88
99
  await frameCapture.setPreferredScreenSize(width: width, height: height)
89
100
  }
90
101
 
102
+ /// JSON snapshot for `serve-sim doctor`: display selection + encoder state.
103
+ func diagnostics() async -> String {
104
+ let capture = await frameCapture.diagnosticsJSON()
105
+ let encoders = jsonString(encoderDiagnostics.snapshot())
106
+ let phaseName: String
107
+ switch phase {
108
+ case .unstarted: phaseName = "unstarted"
109
+ case .starting: phaseName = "starting"
110
+ case .running: phaseName = "running"
111
+ case .stopped: phaseName = "stopped"
112
+ }
113
+ return "{\"phase\":\"\(phaseName)\",\"consumers\":\(consumers.count),\"capture\":\(capture),\"encoders\":\(encoders)}"
114
+ }
115
+
91
116
  func start() async throws {
92
117
  guard phase == .unstarted else { return }
93
118
  phase = .starting
@@ -130,7 +155,7 @@ actor CaptureEngine {
130
155
  encoder: E,
131
156
  onFrame: sending @escaping @isolated(any) (E.Encoded) async -> Void
132
157
  ) -> (@Sendable () async -> Void) {
133
- let consumer = CaptureConsumer(encoder: encoder) { [weak self] encoded in
158
+ let consumer = CaptureConsumer(encoder: encoder, diagnostics: encoderDiagnostics) { [weak self] encoded in
134
159
  guard let self, await self.phase == .running else { return }
135
160
  await onFrame(encoded)
136
161
  }
@@ -165,7 +190,7 @@ actor CaptureEngine {
165
190
  func addAVCCConsumer(
166
191
  onFrame: sending @escaping (Dimensions, Data, Int32) async -> Void
167
192
  ) -> (@Sendable () async -> Void) {
168
- addConsumer(encoder: AVCCEncoder()) { [weak self] encoded in
193
+ addConsumer(encoder: AVCCEncoder(diagnostics: encoderDiagnostics)) { [weak self] encoded in
169
194
  let flagDescription: Int32 = 1 << 0
170
195
  let flagKeyframe: Int32 = 1 << 1
171
196
 
@@ -219,10 +244,12 @@ actor MJPEGEncoder: FrameEncoder {
219
244
  actor AVCCEncoder: FrameEncoder {
220
245
  private static let timeout: Duration = .milliseconds(500)
221
246
 
222
- let h264Encoder = H264Encoder(fps: 60)
247
+ let h264Encoder: H264Encoder
223
248
  var forceKeyframe = true
224
249
 
225
- init() {}
250
+ init(diagnostics: EncoderDiagnostics? = nil) {
251
+ h264Encoder = H264Encoder(fps: 60, diagnostics: diagnostics)
252
+ }
226
253
 
227
254
  func encode(_ frame: Frame) async throws -> H264Encoder.Encoded? {
228
255
  // TODO: cancel after timeout using TaskGroup
@@ -0,0 +1,96 @@
1
+ import Foundation
2
+ import SimNativeSupport
3
+
4
+ /// Per-capture-engine encoder counters for `serve-sim doctor`, plus the
5
+ /// collapsed encoder-error log. Touched once per failed encode, per session
6
+ /// rebuild, and (cheaply) per successful encode after a failure.
7
+ final class EncoderDiagnostics: @unchecked Sendable {
8
+ private struct Count {
9
+ var count: Int
10
+ var first: Date
11
+ var last: Date
12
+ }
13
+
14
+ private let lock = NSLock()
15
+ // Per codec: MJPEG and AVCC consumers share this instance.
16
+ private var log = CodecEncoderErrorLogs()
17
+ private var errors: [EncoderErrorKey: Count] = [:]
18
+ private var sessions: [[String: Any]] = []
19
+
20
+ static func describe(_ error: Error) -> String {
21
+ if let h264 = error as? H264Encoder.Errors, case .encodingFailed(let status) = h264 {
22
+ return "encodingFailed: \(VideoToolboxStatus.describe(status))"
23
+ }
24
+ return String(describing: error)
25
+ }
26
+
27
+ func recordFailure(codec: String, error: Error, width: Int, height: Int) {
28
+ let key = EncoderErrorKey(codec: codec, error: Self.describe(error), width: width, height: height)
29
+ let now = Date()
30
+ lock.lock()
31
+ var entry = errors[key] ?? Count(count: 0, first: now, last: now)
32
+ entry.count += 1
33
+ entry.last = now
34
+ errors[key] = entry
35
+ let lines = log.record(key, at: now.timeIntervalSince1970)
36
+ lock.unlock()
37
+ for line in lines { print(line) }
38
+ }
39
+
40
+ func recordSuccess(codec: String) {
41
+ lock.lock()
42
+ guard log.isFailing(codec: codec) else { lock.unlock(); return }
43
+ let lines = log.recovered(codec: codec, at: Date().timeIntervalSince1970)
44
+ lock.unlock()
45
+ for line in lines { print(line) }
46
+ }
47
+
48
+ func recordSession(codec: String, width: Int, height: Int, status: Int32, lowLatency: Bool) {
49
+ lock.lock()
50
+ let session: [String: Any] = [
51
+ "at": isoTimestamp(Date()),
52
+ "codec": codec,
53
+ "width": width,
54
+ "height": height,
55
+ "status": Int(status),
56
+ "status_name": status == 0 ? "noErr" : VideoToolboxStatus.describe(status),
57
+ "low_latency": lowLatency,
58
+ ]
59
+ sessions.append(session)
60
+ if sessions.count > 10 { sessions.removeFirst(sessions.count - 10) }
61
+ lock.unlock()
62
+ }
63
+
64
+ func snapshot() -> [String: Any] {
65
+ lock.lock()
66
+ defer { lock.unlock() }
67
+ let errorList: [[String: Any]] = errors
68
+ .sorted { $0.value.last > $1.value.last }
69
+ .map { entry -> [String: Any] in
70
+ [
71
+ "codec": entry.key.codec,
72
+ "error": entry.key.error,
73
+ "width": entry.key.width,
74
+ "height": entry.key.height,
75
+ "count": entry.value.count,
76
+ "first_at": isoTimestamp(entry.value.first),
77
+ "last_at": isoTimestamp(entry.value.last),
78
+ ]
79
+ }
80
+ return ["sessions": sessions, "errors": errorList]
81
+ }
82
+ }
83
+
84
+ func isoTimestamp(_ date: Date) -> String {
85
+ let formatter = ISO8601DateFormatter()
86
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
87
+ return formatter.string(from: date)
88
+ }
89
+
90
+ func jsonString(_ object: Any) -> String {
91
+ guard JSONSerialization.isValidJSONObject(object),
92
+ let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]),
93
+ let string = String(data: data, encoding: .utf8)
94
+ else { return "{}" }
95
+ return string
96
+ }
@@ -45,6 +45,11 @@ actor FrameCapture {
45
45
  private var preferredScreenSize: FramebufferSurfaceSize?
46
46
  private var didLogRejectedPresentationSurface = false
47
47
  private var didLogMissingExpectedSurface = false
48
+ // Diagnostics for `serve-sim doctor`: what was live, what was chosen, why.
49
+ private var lastCandidates: [FramebufferSurfaceSize] = []
50
+ private var lastSelectedIndex: Int?
51
+ private var lastSelectionReason = "none"
52
+ private var sizeChanges: [[String: Any]] = []
48
53
 
49
54
  func start(deviceUDID: String, onFrame: @escaping @Sendable (CVPixelBuffer, CMTime) -> Void) throws {
50
55
  self.onFrame = onFrame
@@ -166,13 +171,20 @@ actor FrameCapture {
166
171
  height: IOSurfaceGetHeight(surf)
167
172
  )
168
173
  }
174
+ lastCandidates = sizes
169
175
  guard let selection = FramebufferSurfaceSelector.select(
170
176
  from: sizes,
171
177
  expectedSizes: expectedScreenSizes,
172
178
  preferredSize: preferredScreenSize
173
179
  ) else {
180
+ lastSelectedIndex = nil
181
+ lastSelectionReason = "no_live_surface"
174
182
  return nil
175
183
  }
184
+ lastSelectedIndex = selection.index
185
+ lastSelectionReason = Self.selectionReason(
186
+ selection, selected: sizes[selection.index], preferred: preferredScreenSize
187
+ )
176
188
 
177
189
  if selection.matchedExpectedSize, !didLogRejectedPresentationSurface {
178
190
  let selected = sizes[selection.index]
@@ -180,10 +192,19 @@ actor FrameCapture {
180
192
  if let larger = sizes.filter(\.isLive).max(by: {
181
193
  Int64($0.width) * Int64($0.height) < Int64($1.width) * Int64($1.height)
182
194
  }), Int64(larger.width) * Int64(larger.height) > selectedArea {
183
- print(
184
- "[capture] Ignoring non-device framebuffer \(larger.width)x\(larger.height); "
185
- + "native screen is \(selected.width)x\(selected.height)"
186
- )
195
+ // On a foldable the larger surface is usually the other native
196
+ // panel (Duo inner), not a presentation surface. Say which.
197
+ if expectedScreenSizes.contains(where: { Self.sameSize($0, larger) }) {
198
+ print(
199
+ "[capture] Selected native display \(selected.width)x\(selected.height) "
200
+ + "(\(lastSelectionReason)); other native display \(larger.width)x\(larger.height) is also live"
201
+ )
202
+ } else {
203
+ print(
204
+ "[capture] Ignoring non-device framebuffer \(larger.width)x\(larger.height); "
205
+ + "native screen is \(selected.width)x\(selected.height)"
206
+ )
207
+ }
187
208
  didLogRejectedPresentationSurface = true
188
209
  }
189
210
  } else if
@@ -276,9 +297,18 @@ actor FrameCapture {
276
297
  guard w > 0, h > 0 else { return }
277
298
 
278
299
  if capturedWidth != w || capturedHeight != h {
300
+ let from = "\(capturedWidth)x\(capturedHeight)"
279
301
  capturedWidth = w
280
302
  capturedHeight = h
281
- print("[capture] Surface size changed: \(w)x\(h)")
303
+ print("[capture] Surface size changed: \(from) -> \(w)x\(h) (\(lastSelectionReason))")
304
+ let change: [String: Any] = [
305
+ "at": isoTimestamp(Date()),
306
+ "from": from,
307
+ "to": "\(w)x\(h)",
308
+ "reason": lastSelectionReason,
309
+ ]
310
+ sizeChanges.append(change)
311
+ if sizeChanges.count > 20 { sizeChanges.removeFirst(sizeChanges.count - 20) }
282
312
  }
283
313
 
284
314
  var pixelBuffer: Unmanaged<CVPixelBuffer>?
@@ -326,6 +356,10 @@ actor FrameCapture {
326
356
  preferredScreenSize = nil
327
357
  didLogRejectedPresentationSurface = false
328
358
  didLogMissingExpectedSurface = false
359
+ lastCandidates = []
360
+ lastSelectedIndex = nil
361
+ lastSelectionReason = "none"
362
+ sizeChanges = []
329
363
  }
330
364
 
331
365
  func setPreferredScreenSize(width: Int, height: Int) {
@@ -339,6 +373,50 @@ actor FrameCapture {
339
373
  captureFrame(force: true)
340
374
  }
341
375
 
376
+ /// JSON snapshot of display selection for `serve-sim doctor`.
377
+ func diagnosticsJSON() -> String {
378
+ func size(_ s: FramebufferSurfaceSize) -> [String: Any] { ["width": s.width, "height": s.height] }
379
+ let candidates: [[String: Any]] = lastCandidates.enumerated().map { entry -> [String: Any] in
380
+ [
381
+ "width": entry.element.width,
382
+ "height": entry.element.height,
383
+ "live": entry.element.isLive,
384
+ "native": expectedScreenSizes.contains(where: { Self.sameSize($0, entry.element) }),
385
+ "selected": entry.offset == lastSelectedIndex,
386
+ ]
387
+ }
388
+ let age = ContinuousClock.now - lastCaptureTime
389
+ let ageMs = Int(age.components.seconds) * 1000
390
+ + Int(age.components.attoseconds / 1_000_000_000_000_000)
391
+ var object: [String: Any] = [
392
+ "native_displays": expectedScreenSizes.map(size),
393
+ "candidates": candidates,
394
+ "selection_reason": lastSelectionReason,
395
+ "captured": ["width": capturedWidth, "height": capturedHeight],
396
+ "frame_count": Int(frameCount),
397
+ "size_changes": sizeChanges,
398
+ ]
399
+ if frameCount > 0 { object["last_frame_age_ms"] = ageMs }
400
+ if let preferredScreenSize { object["preferred"] = size(preferredScreenSize) }
401
+ return jsonString(object)
402
+ }
403
+
404
+ private static func sameSize(_ a: FramebufferSurfaceSize, _ b: FramebufferSurfaceSize) -> Bool {
405
+ (a.width == b.width && a.height == b.height) || (a.width == b.height && a.height == b.width)
406
+ }
407
+
408
+ /// preferred: pinned by the Duo panel follower (or WS 0x0d).
409
+ /// native_smallest: no pin; smallest native display (foldable rest pose).
410
+ /// largest_fallback: no native-size metadata matched.
411
+ private static func selectionReason(
412
+ _ selection: FramebufferSurfaceSelection,
413
+ selected: FramebufferSurfaceSize,
414
+ preferred: FramebufferSurfaceSize?
415
+ ) -> String {
416
+ if let preferred, preferred.isLive, sameSize(preferred, selected) { return "preferred" }
417
+ return selection.matchedExpectedSize ? "native_smallest" : "largest_fallback"
418
+ }
419
+
342
420
  // MARK: - Helpers
343
421
 
344
422
  private func makeError(_ code: Int, _ msg: String) -> NSError {
@@ -32,9 +32,12 @@ actor H264Encoder {
32
32
  private var emittedDescription = false
33
33
  private var frameCount: Int64 = 0
34
34
 
35
- init(fps: Int = 60, bitrate: Int = 6_000_000) {
35
+ private let diagnostics: EncoderDiagnostics?
36
+
37
+ init(fps: Int = 60, bitrate: Int = 6_000_000, diagnostics: EncoderDiagnostics? = nil) {
36
38
  self.fps = Int32(fps)
37
39
  self.bitrate = bitrate
40
+ self.diagnostics = diagnostics
38
41
  }
39
42
 
40
43
  deinit {
@@ -125,10 +128,16 @@ actor H264Encoder {
125
128
  )
126
129
  }
127
130
  var status = create(spec: lowLatencySpec)
131
+ var lowLatency = true
128
132
  if status != noErr || sess == nil {
129
133
  sess = nil
134
+ lowLatency = false
130
135
  status = create(spec: nil)
131
136
  }
137
+ diagnostics?.recordSession(
138
+ codec: "AVCC", width: Int(width), height: Int(height),
139
+ status: status, lowLatency: lowLatency
140
+ )
132
141
  guard status == noErr, let sess else { return }
133
142
 
134
143
  let props: [(CFString, Any)] = [
@@ -173,6 +173,12 @@ private func u32(_ v: Int) -> UInt32 {
173
173
  try await engine.start()
174
174
  }
175
175
 
176
+ /// JSON string: display selection, surface-size history, encoder sessions
177
+ /// and error counts. Read by `/api/diagnostics` (`serve-sim doctor`).
178
+ @NodeMethod func diagnostics() async -> String {
179
+ await engine.diagnostics()
180
+ }
181
+
176
182
  @NodeMethod func stop() async {
177
183
  await engine.stop()
178
184
  }
@@ -0,0 +1,123 @@
1
+ import Foundation
2
+
3
+ /// Names for the VideoToolbox OSStatus values an encoder session can return.
4
+ /// Kept as a table (not VideoToolbox imports) so it stays testable anywhere.
5
+ public enum VideoToolboxStatus {
6
+ private static let names: [Int32: String] = [
7
+ -12900: "kVTPropertyNotSupportedErr",
8
+ -12901: "kVTPropertyReadOnlyErr",
9
+ -12902: "kVTParameterErr",
10
+ -12903: "kVTInvalidSessionErr",
11
+ -12904: "kVTAllocationFailedErr",
12
+ -12905: "kVTPixelTransferNotSupportedErr",
13
+ -12906: "kVTCouldNotFindVideoDecoderErr",
14
+ -12907: "kVTCouldNotCreateInstanceErr",
15
+ -12908: "kVTCouldNotFindVideoEncoderErr",
16
+ -12912: "kVTVideoEncoderMalfunctionErr",
17
+ -12915: "kVTVideoEncoderNotAvailableNowErr",
18
+ -12916: "kVTFormatDescriptionChangeNotSupportedErr",
19
+ -12211: "kVTVideoEncoderAuthorizationErr",
20
+ -12218: "kVTPixelTransferNotPermittedErr",
21
+ -17691: "kVTSessionMalfunctionErr",
22
+ -17693: "kVTVideoEncoderNeedsRosettaErr",
23
+ ]
24
+
25
+ public static func name(_ status: Int32) -> String? { names[status] }
26
+
27
+ /// "kVTPixelTransferNotSupportedErr (-12905)", or "OSStatus -1" when unknown.
28
+ public static func describe(_ status: Int32) -> String {
29
+ if let name = names[status] { return "\(name) (\(status))" }
30
+ return "OSStatus \(status)"
31
+ }
32
+ }
33
+
34
+ public struct EncoderErrorKey: Hashable, Sendable {
35
+ public let codec: String
36
+ public let error: String
37
+ public let width: Int
38
+ public let height: Int
39
+
40
+ public init(codec: String, error: String, width: Int, height: Int) {
41
+ self.codec = codec
42
+ self.error = error
43
+ self.width = width
44
+ self.height = height
45
+ }
46
+
47
+ var line: String { "[capture] \(codec) encode failed at \(width)x\(height): \(error)" }
48
+ }
49
+
50
+ /// Collapses a per-frame encoder error into one line plus periodic counts.
51
+ /// The first failure for a key prints immediately. Repeats are counted and
52
+ /// summarized every `summaryInterval` seconds, when the key changes, or when
53
+ /// encoding recovers. Time is injected so the policy is unit-testable.
54
+ public struct EncoderErrorLog: Sendable {
55
+ public let summaryInterval: Double
56
+ private var key: EncoderErrorKey?
57
+ private var suppressed = 0
58
+ private var windowStart: Double = 0
59
+
60
+ public init(summaryInterval: Double = 10) {
61
+ self.summaryInterval = summaryInterval
62
+ }
63
+
64
+ /// True between a recorded failure and the next `recovered`.
65
+ public var isFailing: Bool { key != nil }
66
+
67
+ public mutating func record(_ next: EncoderErrorKey, at now: Double) -> [String] {
68
+ if key == next {
69
+ suppressed += 1
70
+ guard now - windowStart >= summaryInterval else { return [] }
71
+ return [summary(at: now)]
72
+ }
73
+ var lines: [String] = []
74
+ if key != nil, suppressed > 0 { lines.append(summary(at: now)) }
75
+ key = next
76
+ suppressed = 0
77
+ windowStart = now
78
+ lines.append(next.line)
79
+ return lines
80
+ }
81
+
82
+ /// A successful encode ends the run; report anything still uncounted.
83
+ public mutating func recovered(at now: Double) -> [String] {
84
+ guard key != nil else { return [] }
85
+ let lines = suppressed > 0 ? [summary(at: now, suffix: "; recovered")] : []
86
+ key = nil
87
+ suppressed = 0
88
+ return lines
89
+ }
90
+
91
+ private mutating func summary(at now: Double, suffix: String = "") -> String {
92
+ let seconds = max(0, now - windowStart)
93
+ let line = "\(key!.line) repeated \(suppressed)x in \(String(format: "%.1f", seconds))s\(suffix)"
94
+ suppressed = 0
95
+ windowStart = now
96
+ return line
97
+ }
98
+ }
99
+
100
+ /// One `EncoderErrorLog` per codec. The MJPEG and AVCC consumers share a
101
+ /// capture engine, so a success on one codec must not end (and reset the
102
+ /// suppression of) a failure run on the other.
103
+ public struct CodecEncoderErrorLogs: Sendable {
104
+ public let summaryInterval: Double
105
+ private var logs: [String: EncoderErrorLog] = [:]
106
+
107
+ public init(summaryInterval: Double = 10) {
108
+ self.summaryInterval = summaryInterval
109
+ }
110
+
111
+ public func isFailing(codec: String) -> Bool {
112
+ logs[codec]?.isFailing ?? false
113
+ }
114
+
115
+ public mutating func record(_ next: EncoderErrorKey, at now: Double) -> [String] {
116
+ logs[next.codec, default: EncoderErrorLog(summaryInterval: summaryInterval)].record(next, at: now)
117
+ }
118
+
119
+ public mutating func recovered(codec: String, at now: Double) -> [String] {
120
+ guard isFailing(codec: codec) else { return [] }
121
+ return logs[codec]!.recovered(at: now)
122
+ }
123
+ }
@@ -0,0 +1,55 @@
1
+ import XCTest
2
+ @testable import SimNativeSupport
3
+
4
+ final class EncoderErrorLogTests: XCTestCase {
5
+ private let inner = EncoderErrorKey(
6
+ codec: "AVCC",
7
+ error: VideoToolboxStatus.describe(-12905),
8
+ width: 2007,
9
+ height: 2853
10
+ )
11
+
12
+ func testNamesPixelTransferStatus() {
13
+ XCTAssertEqual(VideoToolboxStatus.describe(-12905), "kVTPixelTransferNotSupportedErr (-12905)")
14
+ XCTAssertEqual(VideoToolboxStatus.describe(-1), "OSStatus -1")
15
+ }
16
+
17
+ func testFirstErrorPrintsOnceThenRepeatsAreCounted() {
18
+ var log = EncoderErrorLog(summaryInterval: 10)
19
+ XCTAssertEqual(log.record(inner, at: 0), [
20
+ "[capture] AVCC encode failed at 2007x2853: kVTPixelTransferNotSupportedErr (-12905)",
21
+ ])
22
+ for i in 1...19 { XCTAssertEqual(log.record(inner, at: Double(i) * 0.1), []) }
23
+ XCTAssertEqual(log.recovered(at: 2), [
24
+ "[capture] AVCC encode failed at 2007x2853: kVTPixelTransferNotSupportedErr (-12905) repeated 19x in 2.0s; recovered",
25
+ ])
26
+ XCTAssertEqual(log.recovered(at: 3), [])
27
+ }
28
+
29
+ func testPeriodicSummaryAndKeyChange() {
30
+ var log = EncoderErrorLog(summaryInterval: 10)
31
+ _ = log.record(inner, at: 0)
32
+ XCTAssertEqual(log.record(inner, at: 5), [])
33
+ XCTAssertEqual(log.record(inner, at: 10).count, 1)
34
+ let cover = EncoderErrorKey(codec: "AVCC", error: inner.error, width: 1398, height: 2034)
35
+ XCTAssertEqual(log.record(cover, at: 11), [
36
+ "[capture] AVCC encode failed at 1398x2034: kVTPixelTransferNotSupportedErr (-12905)",
37
+ ])
38
+ }
39
+
40
+ func testSuccessOnOneCodecDoesNotResetAnotherCodecsSuppression() {
41
+ var logs = CodecEncoderErrorLogs(summaryInterval: 10)
42
+ XCTAssertEqual(logs.record(inner, at: 0).count, 1)
43
+ // MJPEG keeps encoding fine while AVCC fails every frame.
44
+ XCTAssertEqual(logs.recovered(codec: "MJPEG", at: 0.05), [])
45
+ XCTAssertEqual(logs.record(inner, at: 0.1), [])
46
+ XCTAssertEqual(logs.recovered(codec: "MJPEG", at: 0.15), [])
47
+ XCTAssertEqual(logs.record(inner, at: 0.2), [])
48
+ XCTAssertTrue(logs.isFailing(codec: "AVCC"))
49
+ XCTAssertFalse(logs.isFailing(codec: "MJPEG"))
50
+ XCTAssertEqual(logs.recovered(codec: "AVCC", at: 1), [
51
+ "[capture] AVCC encode failed at 2007x2853: kVTPixelTransferNotSupportedErr (-12905) repeated 2x in 1.0s; recovered",
52
+ ])
53
+ XCTAssertFalse(logs.isFailing(codec: "AVCC"))
54
+ }
55
+ }