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 +2 -0
- package/Sources/SimNative/CaptureEngine.swift +32 -5
- package/Sources/SimNative/EncoderDiagnostics.swift +96 -0
- package/Sources/SimNative/FrameCapture.swift +83 -5
- package/Sources/SimNative/H264Encoder.swift +10 -1
- package/Sources/SimNative/sim-module.swift +6 -0
- package/Sources/SimNativeSupport/EncoderErrorLog.swift +123 -0
- package/Tests/SimNativeSupportTests/EncoderErrorLogTests.swift +55 -0
- package/dist/middleware.js +51 -49
- package/dist/native/serve-sim-native.node +0 -0
- package/dist/serve-sim.js +82 -78
- package/package.json +1 -1
- package/src/middleware.ts +87 -1
- package/src/native.ts +8 -0
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
|
-
|
|
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
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
+
}
|