simframe 0.4.2 → 0.6.0-rc.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.
Files changed (47) hide show
  1. package/README.md +334 -85
  2. package/native/simframed/Package.swift +16 -0
  3. package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +379 -0
  4. package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +523 -0
  5. package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
  6. package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
  7. package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +149 -0
  8. package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +112 -0
  9. package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
  10. package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
  11. package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
  12. package/native/simframed/Sources/SimframeCore/Element.swift +148 -0
  13. package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
  14. package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
  15. package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
  16. package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
  17. package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
  18. package/native/simframed/Sources/simframed/main.swift +485 -0
  19. package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +270 -0
  20. package/package.json +12 -4
  21. package/scripts/bench-flow.mjs +54 -0
  22. package/scripts/bench.sh +98 -0
  23. package/scripts/check-package.mjs +99 -0
  24. package/scripts/ci-memory.mjs +416 -0
  25. package/scripts/eval-fingerprint.mjs +192 -0
  26. package/scripts/smoke.mjs +76 -0
  27. package/scripts/sync-server-version.mjs +39 -0
  28. package/scripts/verify-baseline.mjs +65 -0
  29. package/skills/simframe/SKILL.md +173 -0
  30. package/src/actions.js +264 -18
  31. package/src/cli.js +561 -89
  32. package/src/control.js +77 -0
  33. package/src/daemon.js +8 -1
  34. package/src/engine.js +99 -0
  35. package/src/fingerprint.js +183 -0
  36. package/src/graph.js +411 -0
  37. package/src/index.js +351 -24
  38. package/src/input.js +179 -2
  39. package/src/matching.js +265 -0
  40. package/src/mcp.js +425 -112
  41. package/src/navigate.js +120 -0
  42. package/src/refs.js +141 -0
  43. package/src/regions.js +267 -0
  44. package/src/screenmap.js +119 -22
  45. package/src/simctl.js +74 -5
  46. package/src/store.js +8 -0
  47. package/src/view.js +342 -0
@@ -0,0 +1,431 @@
1
+ import CoreGraphics
2
+ import Foundation
3
+
4
+ /// What the screen is doing, derived from frame history alone.
5
+ ///
6
+ /// Everything here works on a small grayscale grid rather than the frame, so a
7
+ /// full analysis costs well under a millisecond and can run on every capture.
8
+ public enum Motion {
9
+ /// Grid the analysis runs on. Large enough to localise a keyboard or a
10
+ /// spinner and to measure a scroll offset; small enough to be free.
11
+ public static let cols = 48
12
+ public static let rows = 96
13
+
14
+ /// Below this mean absolute difference, two frames are the same picture.
15
+ public static let stillThreshold = 0.004
16
+ /// Frames that must agree before the screen counts as settled.
17
+ ///
18
+ /// Paired with a duration, because frame count alone is not a measure of
19
+ /// time: the capture loop slows to one frame every two seconds when nothing
20
+ /// is happening, so requiring three consecutive still frames would take six
21
+ /// seconds to call a motionless screen settled.
22
+ public static let stillFramesRequired = 2
23
+ public static let stillDurationRequired: Double = 200
24
+
25
+ public struct Frame: Sendable {
26
+ public let at: Double
27
+ public let gray: [UInt8]
28
+ public init(at: Double, gray: [UInt8]) {
29
+ self.at = at
30
+ self.gray = gray
31
+ }
32
+ }
33
+
34
+ public enum Kind: String, Sendable {
35
+ case none, scroll, push, pop
36
+ /// The screen changed substantially, but not by translating — a tab
37
+ /// switch, a reload, a different view entirely.
38
+ case replace
39
+ case sheetPresent = "sheet-present", sheetDismiss = "sheet-dismiss"
40
+ case alertPresent = "alert-present", alertDismiss = "alert-dismiss"
41
+ case keyboardUp = "keyboard-up", keyboardDown = "keyboard-down"
42
+ case toast, loading
43
+ }
44
+
45
+ /// The quantities a classification was made from. Carried on the result
46
+ /// because a misclassification is otherwise impossible to argue with.
47
+ public struct Evidence: Sendable {
48
+ public var overall = 0.0
49
+ public var shiftDy = 0
50
+ public var shiftScore = 0.0
51
+ public var horizontalDx = 0
52
+ public var horizontalScore = 0.0
53
+ public var brightnessDelta = 0.0
54
+ public var top = 0.0
55
+ public var middle = 0.0
56
+ public var bottom = 0.0
57
+
58
+ public init() {}
59
+
60
+ public var json: [String: Any] {
61
+ func r(_ v: Double) -> Double { (v * 10000).rounded() / 10000 }
62
+ return [
63
+ "overall": r(overall),
64
+ "dy": shiftDy, "dyScore": r(shiftScore),
65
+ "dx": horizontalDx, "dxScore": r(horizontalScore),
66
+ "brightness": (brightnessDelta * 10).rounded() / 10,
67
+ "bands": ["top": r(top), "middle": r(middle), "bottom": r(bottom)],
68
+ ]
69
+ }
70
+ }
71
+
72
+ public struct Transition: Sendable {
73
+ public let kind: Kind
74
+ /// Pixel offset in points, for scroll and push/pop.
75
+ public let offset: CGPoint?
76
+ public let confidence: Double
77
+ public let evidence: Evidence
78
+
79
+ public init(kind: Kind, offset: CGPoint?, confidence: Double, evidence: Evidence = Evidence()) {
80
+ self.kind = kind
81
+ self.offset = offset
82
+ self.confidence = confidence
83
+ self.evidence = evidence
84
+ }
85
+
86
+ public var json: [String: Any] {
87
+ var out: [String: Any] = [
88
+ "kind": kind.rawValue,
89
+ "confidence": (confidence * 100).rounded() / 100,
90
+ "evidence": evidence.json,
91
+ ]
92
+ if let offset { out["offset"] = ["x": Int(offset.x.rounded()), "y": Int(offset.y.rounded())] }
93
+ return out
94
+ }
95
+ }
96
+
97
+ public struct State: Sendable {
98
+ public let settled: Bool
99
+ public let stillForMs: Double
100
+ public let stillFrames: Int
101
+ /// Set when a small area animates while everything else holds still —
102
+ /// a spinner or a progress indicator. The screen is NOT settled.
103
+ public let animatingRegion: CGRect?
104
+
105
+ public var json: [String: Any] {
106
+ var out: [String: Any] = [
107
+ "settled": settled,
108
+ "stillForMs": stillForMs.rounded(),
109
+ "stillFrames": stillFrames,
110
+ ]
111
+ if let r = animatingRegion {
112
+ out["animating"] = ["x": Int(r.origin.x), "y": Int(r.origin.y),
113
+ "width": Int(r.width), "height": Int(r.height)]
114
+ }
115
+ return out
116
+ }
117
+ }
118
+
119
+ // MARK: - Frame comparison
120
+
121
+ public static func difference(_ a: [UInt8], _ b: [UInt8]) -> Double {
122
+ guard a.count == b.count, !a.isEmpty else { return 1 }
123
+ var sum = 0
124
+ for i in 0..<a.count { sum += abs(Int(a[i]) - Int(b[i])) }
125
+ return Double(sum) / Double(a.count) / 255.0
126
+ }
127
+
128
+ /// Per-cell absolute difference, used to localise what moved.
129
+ public static func differenceMap(_ a: [UInt8], _ b: [UInt8]) -> [Double] {
130
+ guard a.count == b.count else { return [] }
131
+ return (0..<a.count).map { abs(Double(a[$0]) - Double(b[$0])) / 255.0 }
132
+ }
133
+
134
+ // MARK: - Settle
135
+
136
+ /// Is the screen holding still, and if not, is one small part of it moving?
137
+ public static func state(history: [Frame], now: Double) -> State {
138
+ guard history.count >= 2 else {
139
+ return State(settled: false, stillForMs: 0, stillFrames: 0, animatingRegion: nil)
140
+ }
141
+ var stillFrames = 0
142
+ var stillSince = history.last!.at
143
+ var i = history.count - 1
144
+ while i > 0 {
145
+ let d = difference(history[i].gray, history[i - 1].gray)
146
+ if d > stillThreshold { break }
147
+ stillFrames += 1
148
+ stillSince = history[i - 1].at
149
+ i -= 1
150
+ }
151
+
152
+ // A spinner keeps a small patch moving while the rest of the screen is
153
+ // finished. Treating that as "still moving" would wait forever; treating
154
+ // it as settled would read a half-drawn screen. It is neither, so it is
155
+ // reported as its own thing.
156
+ //
157
+ // This must run even when the frames look still globally — that is the
158
+ // whole point. A spinner covering half a percent of the screen moves the
159
+ // mean difference by about 0.001, well under the still threshold, so
160
+ // checking only when the global test already says "moving" never fires.
161
+ var animating: CGRect?
162
+ if history.count >= 3 {
163
+ let recent = Array(history.suffix(4))
164
+ var union: [Double] = Array(repeating: 0, count: cols * rows)
165
+ for k in 1..<recent.count {
166
+ let map = differenceMap(recent[k].gray, recent[k - 1].gray)
167
+ for j in 0..<min(union.count, map.count) { union[j] = max(union[j], map[j]) }
168
+ }
169
+ // Per-cell, not global: a cell either changed or it did not.
170
+ let cellThreshold = 0.06
171
+ let moving = union.filter { $0 > cellThreshold }.count
172
+ let fraction = Double(moving) / Double(union.count)
173
+ // Small and persistent, rather than a screen changing.
174
+ if fraction > 0 && fraction < 0.06 {
175
+ animating = boundingBox(of: union, threshold: cellThreshold)
176
+ }
177
+ }
178
+
179
+ return State(
180
+ settled: stillFrames >= stillFramesRequired
181
+ && (now - stillSince) >= stillDurationRequired
182
+ && animating == nil,
183
+ stillForMs: now - stillSince,
184
+ stillFrames: stillFrames,
185
+ animatingRegion: animating
186
+ )
187
+ }
188
+
189
+ private static func boundingBox(of map: [Double], threshold: Double) -> CGRect? {
190
+ var minX = cols, minY = rows, maxX = -1, maxY = -1
191
+ for r in 0..<rows {
192
+ for c in 0..<cols where map[r * cols + c] > threshold {
193
+ minX = min(minX, c); maxX = max(maxX, c)
194
+ minY = min(minY, r); maxY = max(maxY, r)
195
+ }
196
+ }
197
+ guard maxX >= minX, maxY >= minY else { return nil }
198
+ return CGRect(x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1)
199
+ }
200
+
201
+ // MARK: - Shift estimation
202
+
203
+ /// Rows that actually changed between two frames.
204
+ ///
205
+ /// A scrolling screen almost never scrolls all of itself: a navigation bar,
206
+ /// a search field, a filter row and a tab bar stay put while the content
207
+ /// between them moves. Searching the whole frame for a translation lets
208
+ /// that fixed chrome — often a quarter of the screen, and pixel-identical —
209
+ /// pin the answer at "no shift". Restricting the search to the rows that
210
+ /// moved is what makes a real scroll measurable.
211
+ public static func movingRows(_ a: [UInt8], _ b: [UInt8], threshold: Double = 0.02) -> ClosedRange<Int>? {
212
+ var first = -1, last = -1
213
+ for r in 0..<rows {
214
+ var sum = 0
215
+ for c in 0..<cols { sum += abs(Int(a[r * cols + c]) - Int(b[r * cols + c])) }
216
+ let rowChange = Double(sum) / Double(cols) / 255.0
217
+ if rowChange > threshold {
218
+ if first < 0 { first = r }
219
+ last = r
220
+ }
221
+ }
222
+ guard first >= 0, last > first else { return nil }
223
+ return first...last
224
+ }
225
+
226
+ /// Best whole-cell shift of `b` relative to `a` within `region`, by
227
+ /// minimising absolute difference over candidate offsets.
228
+ ///
229
+ /// Phase correlation via FFT is the textbook approach, but at 48x96 a direct
230
+ /// search over the plausible range is a fraction of a millisecond and has no
231
+ /// windowing artefacts to reason about.
232
+ public static func shift(
233
+ _ a: [UInt8], _ b: [UInt8], maxShift: Int = 40, region: ClosedRange<Int>? = nil
234
+ ) -> (dx: Int, dy: Int, score: Double) {
235
+ let rowRange = region ?? 0...(rows - 1)
236
+ var best = (dx: 0, dy: 0, score: Double.infinity)
237
+ for dy in -maxShift...maxShift {
238
+ for dx in -2...2 { // horizontal drift during a vertical scroll
239
+ var sum = 0, n = 0
240
+ for r in rowRange {
241
+ let sr = r + dy
242
+ guard sr >= 0, sr < rows else { continue }
243
+ for c in 0..<cols {
244
+ let sc = c + dx
245
+ guard sc >= 0, sc < cols else { continue }
246
+ sum += abs(Int(a[r * cols + c]) - Int(b[sr * cols + sc]))
247
+ n += 1
248
+ }
249
+ }
250
+ guard n > rowRange.count * cols / 3 else { continue }
251
+ let score = Double(sum) / Double(n) / 255.0
252
+ if score < best.score { best = (dx, dy, score) }
253
+ }
254
+ }
255
+ return best
256
+ }
257
+
258
+ /// Mean absolute difference restricted to the same rows, so a shift score
259
+ /// and the baseline it is compared against are measured over one sample.
260
+ public static func difference(_ a: [UInt8], _ b: [UInt8], region: ClosedRange<Int>) -> Double {
261
+ var sum = 0, n = 0
262
+ for r in region {
263
+ for c in 0..<cols { sum += abs(Int(a[r * cols + c]) - Int(b[r * cols + c])); n += 1 }
264
+ }
265
+ return n == 0 ? 1 : Double(sum) / Double(n) / 255.0
266
+ }
267
+
268
+ /// Same, for a horizontal push or pop.
269
+ public static func horizontalShift(_ a: [UInt8], _ b: [UInt8], maxShift: Int = 40) -> (dx: Int, score: Double) {
270
+ var best = (dx: 0, score: Double.infinity)
271
+ for dx in -maxShift...maxShift {
272
+ var sum = 0, n = 0
273
+ for r in stride(from: 0, to: rows, by: 2) {
274
+ for c in 0..<cols {
275
+ let sc = c + dx
276
+ guard sc >= 0, sc < cols else { continue }
277
+ sum += abs(Int(a[r * cols + c]) - Int(b[r * cols + sc]))
278
+ n += 1
279
+ }
280
+ }
281
+ guard n > cols * rows / 8 else { continue }
282
+ let score = Double(sum) / Double(n) / 255.0
283
+ if score < best.score { best = (dx, score) }
284
+ }
285
+ return best
286
+ }
287
+ }
288
+
289
+ // MARK: - Transition classification
290
+
291
+ extension Motion {
292
+ private static func meanBrightness(_ g: [UInt8]) -> Double {
293
+ g.isEmpty ? 0 : Double(g.reduce(0) { $0 + Int($1) }) / Double(g.count)
294
+ }
295
+
296
+ /// Mean change within a horizontal band, given as fractions of height.
297
+ private static func bandChange(_ map: [Double], from: Double, to: Double) -> Double {
298
+ let r0 = Int(Double(rows) * from), r1 = max(r0 + 1, Int(Double(rows) * to))
299
+ var sum = 0.0
300
+ var n = 0
301
+ for r in r0..<min(r1, rows) {
302
+ for c in 0..<cols { sum += map[r * cols + c]; n += 1 }
303
+ }
304
+ return n == 0 ? 0 : sum / Double(n)
305
+ }
306
+
307
+ /// What happened between two settled screens.
308
+ ///
309
+ /// The order matters: a shift that explains the whole frame is a navigation
310
+ /// or a scroll, and only when nothing explains the frame as a shift is it
311
+ /// worth asking which band changed.
312
+ public static func classify(before: [UInt8], after: [UInt8], pointHeight: Double, pointWidth: Double) -> Transition {
313
+ let overall = difference(before, after)
314
+ guard overall > stillThreshold else { return Transition(kind: .none, offset: nil, confidence: 1) }
315
+
316
+ let map = differenceMap(before, after)
317
+ let top = bandChange(map, from: 0, to: 0.12) // status + nav
318
+ let middle = bandChange(map, from: 0.12, to: 0.60)
319
+ let bottom = bandChange(map, from: 0.60, to: 1.0)
320
+ let brightnessDelta = meanBrightness(after) - meanBrightness(before)
321
+ var evidence = Evidence()
322
+ evidence.overall = overall
323
+ evidence.brightnessDelta = brightnessDelta
324
+ evidence.top = top; evidence.middle = middle; evidence.bottom = bottom
325
+
326
+ // A shift that explains the frame better than the raw difference is
327
+ // motion rather than a repaint.
328
+ // Compare like with like: the shift score and the baseline it is judged
329
+ // against must be measured over the same rows, or the ratio is
330
+ // meaningless.
331
+ let region = movingRows(before, after)
332
+ let baseline = region.map { difference(before, after, region: $0) } ?? overall
333
+ let h = horizontalShift(before, after)
334
+ let v = shift(before, after, region: region)
335
+ evidence.horizontalDx = h.dx; evidence.horizontalScore = h.score
336
+ evidence.shiftDy = v.dy; evidence.shiftScore = v.score
337
+
338
+ // Whichever direction explains the frame better wins. Requiring a
339
+ // stable top band to call something a scroll was wrong: a scroll view
340
+ // filling the screen moves the top too, and such a scroll fell through
341
+ // to "no change".
342
+ let horizontalWins = abs(h.dx) >= 4 && h.score < v.score
343
+ let verticalWins = abs(v.dy) >= 2 && !horizontalWins
344
+ let bestScore = horizontalWins ? h.score : v.score
345
+
346
+ if (horizontalWins || verticalWins), bestScore < baseline * 0.7 {
347
+ let confidence = min(1, (baseline - bestScore) / max(baseline, 0.0001))
348
+ if horizontalWins {
349
+ // Content moving left is a push; moving right is going back.
350
+ return Transition(kind: h.dx < 0 ? .push : .pop,
351
+ offset: CGPoint(x: Double(h.dx) / Double(cols) * pointWidth, y: 0),
352
+ confidence: confidence, evidence: evidence)
353
+ }
354
+ return Transition(kind: .scroll,
355
+ offset: CGPoint(x: 0, y: Double(v.dy) / Double(rows) * pointHeight),
356
+ confidence: confidence, evidence: evidence)
357
+ }
358
+
359
+ // Bottom-only change is the keyboard. Which way is told by whether the
360
+ // bottom gained detail or lost it.
361
+ if bottom > overall * 1.6, top < overall * 0.35, middle < overall * 0.8 {
362
+ let kind: Kind = brightnessDelta < 0 ? .keyboardUp : .keyboardDown
363
+ return Transition(kind: kind, offset: nil, confidence: min(1, bottom / max(overall, 0.0001) / 2), evidence: evidence)
364
+ }
365
+
366
+ // Something laid over the screen dims everything behind it. An alert
367
+ // changes the middle; a sheet takes the lower half.
368
+ if abs(brightnessDelta) > 6 {
369
+ let presenting = brightnessDelta < 0
370
+ let sheetish = bottom > middle
371
+ let kind: Kind = sheetish
372
+ ? (presenting ? .sheetPresent : .sheetDismiss)
373
+ : (presenting ? .alertPresent : .alertDismiss)
374
+ return Transition(kind: kind, offset: nil, confidence: min(1, abs(brightnessDelta) / 40), evidence: evidence)
375
+ }
376
+
377
+ // A small banner at one edge, with the rest of the screen untouched.
378
+ if top > overall * 2.2, middle < overall * 0.4, bottom < overall * 0.4 {
379
+ return Transition(kind: .toast, offset: nil, confidence: min(1, top / max(overall, 0.0001) / 3), evidence: evidence)
380
+ }
381
+
382
+ // Plenty changed and nothing explains it as motion: the screen was
383
+ // replaced. Saying so is more use to a caller than "none", which reads
384
+ // as "nothing happened".
385
+ if overall > stillThreshold * 4 {
386
+ return Transition(kind: .replace, offset: nil,
387
+ confidence: min(1, overall * 12), evidence: evidence)
388
+ }
389
+ return Transition(kind: .none, offset: nil, confidence: 1 - min(1, overall * 4), evidence: evidence)
390
+ }
391
+ }
392
+
393
+
394
+ extension Motion {
395
+ /// The analysis grid, built with integer arithmetic and subsampling.
396
+ ///
397
+ /// `Hashing.grayGrid` averages every pixel in double precision, which is
398
+ /// right for a hash that has to match the JavaScript byte for byte. Motion
399
+ /// analysis has no such constraint and runs on every frame, so it samples
400
+ /// every other pixel and stays in integers — about fifteen milliseconds a
401
+ /// frame cheaper.
402
+ public static func grid(from bmp: Bitmap) -> [UInt8] {
403
+ var out = [UInt8](repeating: 0, count: cols * rows)
404
+ bmp.data.withUnsafeBufferPointer { src in
405
+ for r in 0..<rows {
406
+ let y0 = (r * bmp.height) / rows
407
+ let y1 = max(y0 + 1, ((r + 1) * bmp.height) / rows)
408
+ for c in 0..<cols {
409
+ let x0 = (c * bmp.width) / cols
410
+ let x1 = max(x0 + 1, ((c + 1) * bmp.width) / cols)
411
+ var sum = 0, n = 0
412
+ var y = y0
413
+ while y < y1 {
414
+ let row = y * bmp.width
415
+ var x = x0
416
+ while x < x1 {
417
+ let i = (row + x) * 4
418
+ // Integer luminance: 77/150/29 over 256.
419
+ sum += (Int(src[i]) * 77 + Int(src[i + 1]) * 150 + Int(src[i + 2]) * 29) >> 8
420
+ n += 1
421
+ x += 2
422
+ }
423
+ y += 2
424
+ }
425
+ out[r * cols + c] = n > 0 ? UInt8(min(255, sum / n)) : 0
426
+ }
427
+ }
428
+ }
429
+ return out
430
+ }
431
+ }
@@ -0,0 +1,40 @@
1
+ import CoreGraphics
2
+ import Foundation
3
+ import ImageIO
4
+ import UniformTypeIdentifiers
5
+
6
+ public enum PNGWriter {
7
+ /// Write a packed RGBA bitmap as a PNG. Used for the frame files the Node
8
+ /// readers consume, so it must produce an ordinary 8-bit RGBA PNG.
9
+ public static func write(_ bmp: Bitmap, to url: URL) throws {
10
+ try encode(bmp).write(to: url)
11
+ }
12
+
13
+ /// Encode once, write anywhere. The ring frame and `latest.png` are the
14
+ /// same picture; encoding it twice doubled the cost of every capture.
15
+ public static func encode(_ bmp: Bitmap) throws -> Data {
16
+ var data = bmp.data
17
+ guard let provider = CGDataProvider(data: Data(data) as CFData),
18
+ let image = CGImage(
19
+ width: bmp.width, height: bmp.height,
20
+ bitsPerComponent: 8, bitsPerPixel: 32,
21
+ bytesPerRow: bmp.width * 4,
22
+ space: CGColorSpaceCreateDeviceRGB(),
23
+ bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
24
+ provider: provider, decode: nil,
25
+ shouldInterpolate: false, intent: .defaultIntent
26
+ ) else {
27
+ throw NSError(domain: "PNGWriter", code: 1, userInfo: [NSLocalizedDescriptionKey: "could not build image"])
28
+ }
29
+ data.removeAll()
30
+ let out = NSMutableData()
31
+ guard let dest = CGImageDestinationCreateWithData(out, UTType.png.identifier as CFString, 1, nil) else {
32
+ throw NSError(domain: "PNGWriter", code: 2, userInfo: [NSLocalizedDescriptionKey: "could not create PNG destination"])
33
+ }
34
+ CGImageDestinationAddImage(dest, image, nil)
35
+ guard CGImageDestinationFinalize(dest) else {
36
+ throw NSError(domain: "PNGWriter", code: 3, userInfo: [NSLocalizedDescriptionKey: "could not encode PNG"])
37
+ }
38
+ return out as Data
39
+ }
40
+ }
@@ -0,0 +1,75 @@
1
+ import CoreGraphics
2
+ import Foundation
3
+ import PrivateAPI
4
+ import Vision
5
+
6
+ /// Text recognition straight off the framebuffer.
7
+ ///
8
+ /// The Node path wrote a PNG and shelled out to a helper; this reads the
9
+ /// IOSurface that is already mapped, so a recognition pass costs one CGImage
10
+ /// wrap rather than an encode, a write, a process spawn and a decode.
11
+ public enum VisionOCR {
12
+ /// Recognised text as elements, in points.
13
+ ///
14
+ /// - Parameter scale: points per pixel, so boxes come back in the space
15
+ /// input speaks rather than in framebuffer pixels.
16
+ public static func recognise(
17
+ frame: RawFrame,
18
+ scale: Double,
19
+ startingId: Int = 1,
20
+ minimumConfidence: Float = 0.3
21
+ ) throws -> [Element] {
22
+ guard let image = cgImage(from: frame) else { return [] }
23
+ let request = VNRecognizeTextRequest()
24
+ request.recognitionLevel = .accurate
25
+ // Correction rewrites UI strings into dictionary words — "Fryer 3"
26
+ // becomes something else entirely — and a label must be what is on
27
+ // screen, not what the language model thinks was meant.
28
+ request.usesLanguageCorrection = false
29
+ try VNImageRequestHandler(cgImage: image, options: [:]).perform([request])
30
+
31
+ let width = Double(frame.width), height = Double(frame.height)
32
+ var elements: [Element] = []
33
+ var id = startingId
34
+ for observation in request.results ?? [] {
35
+ guard let candidate = observation.topCandidates(1).first,
36
+ candidate.confidence >= minimumConfidence else { continue }
37
+ let box = observation.boundingBox // normalised, origin bottom-left
38
+ let rect = CGRect(
39
+ x: box.origin.x * width / scale,
40
+ // Vision's origin is bottom-left; screens are top-left.
41
+ y: (1 - box.origin.y - box.size.height) * height / scale,
42
+ width: box.size.width * width / scale,
43
+ height: box.size.height * height / scale
44
+ )
45
+ elements.append(Element(
46
+ id: id,
47
+ frame: rect,
48
+ role: "text",
49
+ label: candidate.string,
50
+ source: .ocr,
51
+ confidence: Double(candidate.confidence)
52
+ ))
53
+ id += 1
54
+ }
55
+ return elements
56
+ }
57
+
58
+ /// Wrap the surface as a CGImage without copying its pixels.
59
+ private static func cgImage(from frame: RawFrame) -> CGImage? {
60
+ let bytes = frame.bytesPerRow * frame.height
61
+ guard let provider = CGDataProvider(dataInfo: nil, data: frame.pixels, size: bytes, releaseData: { _, _, _ in }) else {
62
+ return nil
63
+ }
64
+ let info = CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipFirst.rawValue).union(.byteOrder32Little)
65
+ return CGImage(
66
+ width: frame.width, height: frame.height,
67
+ bitsPerComponent: 8, bitsPerPixel: 32,
68
+ bytesPerRow: frame.bytesPerRow,
69
+ space: CGColorSpaceCreateDeviceRGB(),
70
+ bitmapInfo: info,
71
+ provider: provider, decode: nil,
72
+ shouldInterpolate: false, intent: .defaultIntent
73
+ )
74
+ }
75
+ }