@smileid/usesmileid_vision_face 12.0.0-beta01

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/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ ## 12.0.0-beta01
2
+
3
+ First public beta release. Apple Vision face detection for the Smile ID React Native (Expo) SDK v12.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Smile ID
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # `@smileid/usesmileid_vision_face`
2
+
3
+ UseSmileID v12 — Apple Vision face provider for React Native (Expo Modules).
4
+ **iOS-only.** Thin shim around the existing Swift Package
5
+ `UseSmileIDVisionFace`.
6
+
7
+ ## Architecture
8
+
9
+ The native side runs the entire face-scan pipeline (analyzer +
10
+ validator + transitioner + state machine). This package contributes a
11
+ Swift Expo Module that constructs `UseSmileIDFaceSession(FaceDetectorAnalyzer())`
12
+ and a TS wrapper that bridges the four contract methods (`configure /
13
+ analyze / reset / close`) with Zod-validated wire payloads.
14
+
15
+ No ML logic is reimplemented in this package — it's a pure passthrough.
16
+ See `react-native-expo/docs/Architecture.md`.
17
+
18
+ ## Installation
19
+
20
+ ```sh
21
+ pnpm add @smileid/usesmileid_vision_face
22
+ ```
23
+
24
+ Run `npx pod-install` (or let Expo's prebuild handle it) so the iOS
25
+ project picks up the SPM dependency declared in
26
+ `UseSmileIDVisionFace.podspec`.
27
+
28
+ ## Usage
29
+
30
+ ```ts
31
+ import { useSmileIDVisionFace } from "@smileid/usesmileid_vision_face";
32
+
33
+ await useSmileIDVisionFace.configure({ selfieCaptureTimeoutMs: 60_000 });
34
+ const result = await useSmileIDVisionFace.analyze(bytes, rotation);
35
+ await useSmileIDVisionFace.close();
36
+ ```
37
+
38
+ ## Cross-platform peers
39
+
40
+ | Platform | Provider package | Native dep |
41
+ | ---------------- | --------------------------------- | ---------------------------- |
42
+ | iOS | `@smileid/usesmileid_vision_face` | `UseSmileIDVisionFace` (SPM) |
43
+ | Android | `@smileid/usesmileid_mlkit_face` | `com.usesmileid:mlkit-face` |
44
+ | Android (no GMS) | `@smileid/usesmileid_huawei_face` | `com.usesmileid:huawei-face` |
45
+
46
+ ## Testing
47
+
48
+ ```sh
49
+ pnpm --filter @smileid/usesmileid_vision_face test
50
+ ```
package/app.plugin.js ADDED
@@ -0,0 +1,3 @@
1
+ // Entry point Expo discovers when an app references this package in
2
+ // `expo.plugins`. The compiled plugin lives at `plugin/build/index.js`.
3
+ module.exports = require("./plugin/build");
@@ -0,0 +1,31 @@
1
+ import type { FaceConfig, FaceScanResult } from "@smileid/usesmileid_bridge";
2
+ /**
3
+ * Apple Vision face analyzer (iOS-only).
4
+ *
5
+ * Thin shim around the existing native session
6
+ * `UseSmileID.UseSmileIDFaceSession(FaceDetectorAnalyzer())` shipped in
7
+ * the `UseSmileIDVisionFace` Swift Package. The native side runs the
8
+ * entire pipeline (analyzer + validator + transitioner + state
9
+ * machine); this TS layer routes calls and validates wire payloads at
10
+ * the channel boundary.
11
+ *
12
+ * The canonical wiring lives in [useSmileIDAnalyzerExecutor] in
13
+ * `usesmileid_platform_interface`.
14
+ *
15
+ * Lifecycle: `configure()` → per-frame analysis via the native frame
16
+ * processor plugin → `reset()`? → `close()`. See
17
+ * `usesmileid_platform_interface` `FaceAnalyzer` for the canonical
18
+ * contract.
19
+ */
20
+ /**
21
+ * Must match the Swift Expo Module's `Name("UseSmileIDVisionFace")` and its
22
+ * VisionCamera frame-processor-plugin registration — drift is a runtime
23
+ * "module/plugin not found" error, not a compile error.
24
+ */
25
+ export declare const UseSmileIDVisionFaceIdentifiers: {
26
+ readonly nativeModuleName: "UseSmileIDVisionFace";
27
+ readonly frameProcessorPluginName: "UseSmileIDVisionFaceFrameProcessor";
28
+ };
29
+ export declare const useSmileIDVisionFace: import("@smileid/usesmileid_platform_interface").FaceAnalyzer;
30
+ export type { FaceConfig, FaceScanResult };
31
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAG7E;;;;;;;;;;;;;;;;;GAiBG;AACH;;;;GAIG;AACH,eAAO,MAAM,+BAA+B;;;CAGlC,CAAC;AAEX,eAAO,MAAM,oBAAoB,+DAM/B,CAAC;AAEH,YAAY,EAAE,UAAU,EAAE,cAAc,EAAE,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,36 @@
1
+ import { AnalyzerFactoryKey } from "@smileid/usesmileid_bridge";
2
+ import { useSmileIDAnalyzerExecutor } from "@smileid/usesmileid_platform_interface";
3
+ /**
4
+ * Apple Vision face analyzer (iOS-only).
5
+ *
6
+ * Thin shim around the existing native session
7
+ * `UseSmileID.UseSmileIDFaceSession(FaceDetectorAnalyzer())` shipped in
8
+ * the `UseSmileIDVisionFace` Swift Package. The native side runs the
9
+ * entire pipeline (analyzer + validator + transitioner + state
10
+ * machine); this TS layer routes calls and validates wire payloads at
11
+ * the channel boundary.
12
+ *
13
+ * The canonical wiring lives in [useSmileIDAnalyzerExecutor] in
14
+ * `usesmileid_platform_interface`.
15
+ *
16
+ * Lifecycle: `configure()` → per-frame analysis via the native frame
17
+ * processor plugin → `reset()`? → `close()`. See
18
+ * `usesmileid_platform_interface` `FaceAnalyzer` for the canonical
19
+ * contract.
20
+ */
21
+ /**
22
+ * Must match the Swift Expo Module's `Name("UseSmileIDVisionFace")` and its
23
+ * VisionCamera frame-processor-plugin registration — drift is a runtime
24
+ * "module/plugin not found" error, not a compile error.
25
+ */
26
+ export const UseSmileIDVisionFaceIdentifiers = {
27
+ nativeModuleName: "UseSmileIDVisionFace",
28
+ frameProcessorPluginName: "UseSmileIDVisionFaceFrameProcessor",
29
+ };
30
+ export const useSmileIDVisionFace = useSmileIDAnalyzerExecutor({
31
+ nativeModuleName: UseSmileIDVisionFaceIdentifiers.nativeModuleName,
32
+ frameProcessorPluginName: UseSmileIDVisionFaceIdentifiers.frameProcessorPluginName,
33
+ kind: "face",
34
+ key: AnalyzerFactoryKey.VisionFace,
35
+ });
36
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAEhE,OAAO,EAAE,0BAA0B,EAAE,MAAM,wCAAwC,CAAC;AAEpF;;;;;;;;;;;;;;;;;GAiBG;AACH;;;;GAIG;AACH,MAAM,CAAC,MAAM,+BAA+B,GAAG;IAC7C,gBAAgB,EAAE,sBAAsB;IACxC,wBAAwB,EAAE,oCAAoC;CACtD,CAAC;AAEX,MAAM,CAAC,MAAM,oBAAoB,GAAG,0BAA0B,CAAC;IAC7D,gBAAgB,EAAE,+BAA+B,CAAC,gBAAgB;IAClE,wBAAwB,EACtB,+BAA+B,CAAC,wBAAwB;IAC1D,IAAI,EAAE,MAAM;IACZ,GAAG,EAAE,kBAAkB,CAAC,UAAU;CACnC,CAAC,CAAC"}
@@ -0,0 +1,6 @@
1
+ {
2
+ "platforms": ["apple"],
3
+ "apple": {
4
+ "modules": ["UseSmileIDVisionFaceModule"]
5
+ }
6
+ }
@@ -0,0 +1,61 @@
1
+ require 'json'
2
+
3
+ # Reads the package's package.json so version stays in sync with the
4
+ # JS-side package version. Mirrors the convention used by every Expo
5
+ # Module in this workspace.
6
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
7
+
8
+ Pod::Spec.new do |s|
9
+ # Pod name is intentionally distinct from the SPM product
10
+ # `UseSmileIDVisionFace` (declared in `expo-module.config.json`)
11
+ # to avoid a name collision between the CocoaPods bridge target
12
+ # and the Swift Package product it imports.
13
+ s.name = 'UseSmileIDVisionFaceExpo'
14
+ s.version = package['version']
15
+ s.summary = 'Apple Vision face provider for UseSmileID v12 (Expo Module bridge).'
16
+ s.description = package['description']
17
+ s.license = package['license']
18
+ s.author = package['author']
19
+ s.homepage = package['homepage']
20
+ s.platforms = { :ios => '15.0' }
21
+ s.swift_version = '5.9'
22
+ s.source = { git: 'https://github.com/usesmileid/react-native-expo.git', tag: "v#{s.version}" }
23
+ s.static_framework = true
24
+
25
+ s.dependency 'ExpoModulesCore'
26
+ # VisionCamera provides the FrameProcessorPlugin base class and
27
+ # FrameProcessorPluginRegistry used by UseSmileIDVisionFaceFrameProcessorPlugin.
28
+ # Frame processors additionally require react-native-worklets-core in the
29
+ # consuming app (already declared as an SDK peer dependency).
30
+ s.dependency 'VisionCamera'
31
+
32
+ # The Smile ID native deps (`UseSmileIDVisionFace` / `UseSmileIDBridge`)
33
+ # are SPM-only — declared in `expo-module.config.json` under
34
+ # `apple.dependencies`. Expo Modules autolinking surfaces the SPM
35
+ # products to this target at compile time. Do NOT add a
36
+ # `s.dependency` for them here; that would require shipping a
37
+ # CocoaPods podspec for the binary, which we don't.
38
+
39
+ # Swift flags consistent with Expo Modules' Kotlin/Swift compiler-flag
40
+ # plumbing — toggled by the consuming app's Podfile when it runs in
41
+ # debug mode.
42
+ s.pod_target_xcconfig = {
43
+ 'DEFINES_MODULE' => 'YES',
44
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule',
45
+ # Hardening — mirrors the native iOS frameworks' Release-config
46
+ # build settings. Dead code is stripped, install-path metadata is
47
+ # removed at link time, debug symbols (DWARF / line tables) are
48
+ # stripped via STRIP_STYLE = 'debugging', and Swift's field-name
49
+ # reflection metadata is dropped so class-dump / Hopper cannot
50
+ # recover Swift property names. STRIP_STYLE must stay 'debugging'
51
+ # (not 'all') — dynamic frameworks need their dyld-visible symbol
52
+ # table intact for consumers to link against. 'all' is for apps.
53
+ 'DEAD_CODE_STRIPPING' => 'YES',
54
+ 'DEPLOYMENT_POSTPROCESSING' => 'YES',
55
+ 'STRIP_INSTALLED_PRODUCT' => 'YES',
56
+ 'STRIP_STYLE' => 'debugging',
57
+ 'SWIFT_REFLECTION_METADATA_LEVEL' => 'without-names'
58
+ }
59
+
60
+ s.source_files = 'UseSmileIDVisionFaceModule/**/*.{h,m,mm,swift,hpp,cpp}'
61
+ end
@@ -0,0 +1,181 @@
1
+ // MIRRORED FILE — duplicated into both RN Expo iOS vision modules
2
+ // (usesmileid_vision_face / usesmileid_vision_document). The per-module
3
+ // CocoaPods bridge target sources cannot share files without a new pod.
4
+ // Canonical location:
5
+ // usesmileid_vision_face/ios/UseSmileIDVisionFaceModule/ResizingFrameStorage.swift
6
+ // Mirror location:
7
+ // usesmileid_vision_document/ios/UseSmileIDVisionDocumentModule/ResizingFrameStorage.swift
8
+ //
9
+ // Any edit here MUST be applied to the other location in the same commit.
10
+
11
+ import Foundation
12
+ import UIKit
13
+ import UseSmileIDBridge
14
+
15
+ /// `FrameStorage` implementation that center-crops and scales every selfie and
16
+ /// liveness JPEG to the configured target dimensions before writing.
17
+ ///
18
+ /// Target dimensions are resolved at configure-time from the token
19
+ /// `sdk_config` claim; the executor falls back to 480 × 640 when the token
20
+ /// carries no override, so the on-disk pixel size always matches the
21
+ /// `image_width` / `image_height` values reported in job metadata.
22
+ ///
23
+ /// Document frames are not resized — they are written at the analyzer's
24
+ /// native frame resolution using the specified `documentQuality`. Document
25
+ /// dimensions are not part of the token `sdk_config` contract.
26
+ ///
27
+ /// Encode and write run on background tasks; `persist` is fire-and-forget so
28
+ /// the analyzer keeps running at frame rate. `flush()` awaits all in-flight
29
+ /// writes before upload proceeds.
30
+ final class ResizingFrameStorage: FrameStorage {
31
+ private let sessionDirectory: URL
32
+ private let documentSide: DocumentSide
33
+ private let selfieTargetWidth: Int
34
+ private let selfieTargetHeight: Int
35
+ private let livenessTargetWidth: Int
36
+ private let livenessTargetHeight: Int
37
+ private let selfieQuality: CGFloat
38
+ private let livenessQuality: CGFloat
39
+ private let documentQuality: CGFloat
40
+ private let writer: Writer
41
+
42
+ init(
43
+ sessionDirectory: URL,
44
+ documentSide: DocumentSide = .front,
45
+ selfieTargetWidth: Int = 480,
46
+ selfieTargetHeight: Int = 640,
47
+ livenessTargetWidth: Int = 480,
48
+ livenessTargetHeight: Int = 640,
49
+ selfieQuality: CGFloat = 0.95,
50
+ livenessQuality: CGFloat = 0.80,
51
+ documentQuality: CGFloat = 0.85
52
+ ) {
53
+ self.sessionDirectory = sessionDirectory
54
+ self.documentSide = documentSide
55
+ self.selfieTargetWidth = selfieTargetWidth
56
+ self.selfieTargetHeight = selfieTargetHeight
57
+ self.livenessTargetWidth = livenessTargetWidth
58
+ self.livenessTargetHeight = livenessTargetHeight
59
+ self.selfieQuality = selfieQuality
60
+ self.livenessQuality = livenessQuality
61
+ self.documentQuality = documentQuality
62
+ self.writer = Writer()
63
+ }
64
+
65
+ func url(for kind: FrameKind, captureIndex: Int) -> URL {
66
+ let name: String
67
+ switch kind {
68
+ case .selfie:
69
+ name = "selfie.jpg"
70
+ case .liveness:
71
+ // Use the capture index so each liveness frame has a stable,
72
+ // deterministic name. Retries after `.unsatisfied` reuse the same
73
+ // URL, keeping the emitted CaptureReadyEvent and the on-disk file
74
+ // in sync per the FrameStorage contract.
75
+ name = "liveness_\(captureIndex).jpg"
76
+ case .document:
77
+ name = documentSide == .front ? "document_front.jpg" : "document_back.jpg"
78
+ @unknown default:
79
+ name = "frame_\(captureIndex).jpg"
80
+ }
81
+ return sessionDirectory.appendingPathComponent(name)
82
+ }
83
+
84
+ func persist(image: UIImage, to url: URL) {
85
+ let name = url.lastPathComponent
86
+ let resize: (Int, Int)?
87
+ let quality: CGFloat
88
+ if name.hasPrefix("selfie") {
89
+ resize = (selfieTargetWidth, selfieTargetHeight)
90
+ quality = selfieQuality
91
+ } else if name.hasPrefix("liveness") {
92
+ resize = (livenessTargetWidth, livenessTargetHeight)
93
+ quality = livenessQuality
94
+ } else {
95
+ // Document frames: encode at the analyzer's native resolution.
96
+ resize = nil
97
+ quality = documentQuality
98
+ }
99
+ Task {
100
+ await writer.enqueue(image: image, to: url, resize: resize, quality: quality)
101
+ }
102
+ }
103
+
104
+ func flush() async throws {
105
+ try await writer.flush()
106
+ }
107
+
108
+ func clear() {
109
+ let fileManager = FileManager.default
110
+ let contents = (try? fileManager.contentsOfDirectory(
111
+ at: sessionDirectory,
112
+ includingPropertiesForKeys: nil
113
+ )) ?? []
114
+ for fileURL in contents {
115
+ try? fileManager.removeItem(at: fileURL)
116
+ }
117
+ }
118
+
119
+ /// Serialises writes through an actor so concurrent `persist` calls for
120
+ /// the same URL don't race and `flush()` can join every outstanding task
121
+ /// without external synchronisation.
122
+ private actor Writer {
123
+ private var inFlight: [Task<Void, Error>] = []
124
+
125
+ func enqueue(image: UIImage, to url: URL, resize: (Int, Int)?, quality: CGFloat) {
126
+ let task = Task<Void, Error>.detached(priority: .utility) {
127
+ try Self.encodeAndWrite(image: image, to: url, resize: resize, quality: quality)
128
+ }
129
+ inFlight.append(task)
130
+ }
131
+
132
+ func flush() async throws {
133
+ let pending = inFlight
134
+ inFlight.removeAll()
135
+ var firstError: Error?
136
+ for task in pending {
137
+ do {
138
+ try await task.value
139
+ } catch {
140
+ firstError = firstError ?? error
141
+ }
142
+ }
143
+ if let firstError {
144
+ throw firstError
145
+ }
146
+ }
147
+
148
+ private static func encodeAndWrite(
149
+ image: UIImage,
150
+ to url: URL,
151
+ resize: (Int, Int)?,
152
+ quality: CGFloat
153
+ ) throws {
154
+ try FileManager.default.createDirectory(
155
+ at: url.deletingLastPathComponent(),
156
+ withIntermediateDirectories: true
157
+ )
158
+ // Bake any non-`.up` orientation into the pixel buffer before
159
+ // encoding. `UIImage.jpegData(...)` only writes the EXIF
160
+ // Orientation tag — viewers that ignore EXIF would otherwise
161
+ // render the file sideways.
162
+ let upright = image.normalizingOrientation()
163
+ let data: Data?
164
+ if let (width, height) = resize, let cgImage = upright.cgImage {
165
+ // Center-crop and scale to the token-resolved target size.
166
+ let cropped = cgImage.centerCroppedExact(width: width, height: height)
167
+ data = cropped.flatMap { UIImage(cgImage: $0).jpegData(compressionQuality: quality) }
168
+ } else {
169
+ data = upright.jpegData(compressionQuality: quality)
170
+ }
171
+ guard let data else {
172
+ throw ResizingFrameStorageError.encodeFailed(url: url)
173
+ }
174
+ try data.write(to: url, options: .atomic)
175
+ }
176
+ }
177
+ }
178
+
179
+ enum ResizingFrameStorageError: Error, Sendable {
180
+ case encodeFailed(url: URL)
181
+ }
@@ -0,0 +1,119 @@
1
+ // MIRRORED FILE — this file is duplicated byte-identical across the two
2
+ // RN Expo iOS bridge modules. Canonical location:
3
+ // usesmileid_vision_face/ios/UseSmileIDVisionFaceModule/
4
+ // UseSmileIDAnalyzerExecutor.swift
5
+ // Mirror location:
6
+ // usesmileid_vision_document/ios/UseSmileIDVisionDocumentModule/
7
+ // UseSmileIDAnalyzerExecutor.swift
8
+ //
9
+ // Each RN module is its own CocoaPods bridge target with its own
10
+ // `source_files` glob; source cannot be shared across them without
11
+ // inventing a new pod. The helper is duplicated verbatim into both
12
+ // vision modules. Any edit here MUST be copied to the other location in
13
+ // the same commit.
14
+ //
15
+ // Purpose: collapse the analyze / flush plumbing of the two RN Expo
16
+ // vision modules into a single generic executor whose Task closures
17
+ // capture only @Sendable values (closures + locals) — no module-class
18
+ // state, no implicit `self`. This forces both modules to share one set
19
+ // of Sendable correctness properties by construction: if one compiles
20
+ // cleanly under strict concurrency, the other must too.
21
+ //
22
+ // RN-specific differences from the Flutter twin:
23
+ // • RN modules receive pre-decoded JPEG/PNG bytes (`UIImage(data:)`),
24
+ // so the executor accepts `UIImage` directly rather than Pigeon's
25
+ // `YuvImage` and an associated decoder. JPEG decoding happens in
26
+ // the per-module wrapper before the executor is invoked.
27
+ // • The completion signature is `(Result<PigeonResult, Error>) -> Void`
28
+ // identical to Flutter — the per-module wrapper translates that
29
+ // into Expo's `Promise.resolve`/`reject`. Keeping the executor's
30
+ // public surface a `Result` consumer means both bridges share the
31
+ // same Task discipline.
32
+
33
+ import ExpoModulesCore
34
+ import UIKit
35
+ import UseSmileIDBridge
36
+
37
+ /// Bridges an Expo Modules @escaping completion handler into Swift 6
38
+ /// Task captures. Expo's `Promise` is itself reference-typed but not
39
+ /// `Sendable`; the executor never captures a `Promise` — it captures a
40
+ /// `Result` consumer closure that the per-module wrapper translates
41
+ /// into `resolve`/`reject`. This box mirrors the Flutter PigeonCompletion
42
+ /// pattern.
43
+ final class ExecutorCompletion<T>: @unchecked Sendable {
44
+ let call: (T) -> Void
45
+ init(_ fn: @escaping (T) -> Void) { self.call = fn }
46
+ }
47
+
48
+ /// Generic helper that owns the analyze / flush bridging between the
49
+ /// RN Expo Module's completion-based API and the native session's async
50
+ /// API.
51
+ ///
52
+ /// The class itself is `@unchecked Sendable` because it stores only
53
+ /// `@Sendable` closures (which are themselves Sendable). The downstream
54
+ /// `Task.detached` bodies capture those closures — never an instance of a
55
+ /// concrete module class — so the compiler can prove the Task body is
56
+ /// Sendable-by-construction.
57
+ ///
58
+ /// `NativeOutput` is the concrete native analyzer output type (e.g.
59
+ /// `FaceDetectorOutput`, `DocumentDetectorOutput`); `Wire` is the
60
+ /// JS-side wire dictionary the module returns to JS (typed as
61
+ /// `[String: Any?]` after Expo bridge marshalling).
62
+ final class UseSmileIDAnalyzerExecutor<NativeOutput: AnalyzerOutput, Wire>: @unchecked Sendable {
63
+
64
+ private let scan: @Sendable (UIImage, Int) async -> (NativeOutput?, UseSmileIDScanState)
65
+ private let drainEvents: @Sendable () -> [UseSmileIDBridge.CaptureReadyEvent]
66
+ private let flushFn: @Sendable () async throws -> Void
67
+ private let buildResult: @Sendable (NativeOutput?, UseSmileIDScanState, [UseSmileIDBridge.CaptureReadyEvent]) -> Wire
68
+
69
+ init(
70
+ scan: @escaping @Sendable (UIImage, Int) async -> (NativeOutput?, UseSmileIDScanState),
71
+ drainEvents: @escaping @Sendable () -> [UseSmileIDBridge.CaptureReadyEvent],
72
+ flush: @escaping @Sendable () async throws -> Void,
73
+ buildResult: @escaping @Sendable (NativeOutput?, UseSmileIDScanState, [UseSmileIDBridge.CaptureReadyEvent]) -> Wire
74
+ ) {
75
+ self.scan = scan
76
+ self.drainEvents = drainEvents
77
+ self.flushFn = flush
78
+ self.buildResult = buildResult
79
+ }
80
+
81
+ /// Hands the already-decoded `UIImage` to the captured `scan` closure
82
+ /// on a detached task. The Task's capture list explicitly lists every
83
+ /// value it consumes so the compiler can verify the closure is
84
+ /// Sendable without falling back on implicit `self` capture.
85
+ ///
86
+ /// The `completion` consumer is a `Result<Wire, Error>` — the
87
+ /// per-module wrapper translates that into `Promise.resolve` /
88
+ /// `Promise.reject`.
89
+ func analyze(
90
+ image: UIImage,
91
+ rotation: Int,
92
+ completion: @escaping (Result<Wire, Error>) -> Void
93
+ ) {
94
+ let scan = self.scan
95
+ let drainEvents = self.drainEvents
96
+ let buildResult = self.buildResult
97
+ let completionBox = ExecutorCompletion(completion)
98
+ Task.detached(priority: .userInitiated) {
99
+ let (output, state) = await scan(image, rotation)
100
+ let events = drainEvents()
101
+ completionBox.call(.success(buildResult(output, state, events)))
102
+ }
103
+ }
104
+
105
+ /// Awaits the captured `flushFn` on a detached task. Mirrors `analyze`'s
106
+ /// capture discipline — no `self`, no class state.
107
+ func flush(completion: @escaping (Result<Void, Error>) -> Void) {
108
+ let flushFn = self.flushFn
109
+ let completionBox = ExecutorCompletion(completion)
110
+ Task.detached(priority: .userInitiated) {
111
+ do {
112
+ try await flushFn()
113
+ completionBox.call(.success(()))
114
+ } catch {
115
+ completionBox.call(.failure(error))
116
+ }
117
+ }
118
+ }
119
+ }
@@ -0,0 +1,96 @@
1
+ import CoreMedia
2
+ import CoreImage
3
+ import UIKit
4
+ import VisionCamera
5
+
6
+ /// VisionCamera frame processor plugin that feeds camera frames directly
7
+ /// into the active `UseSmileIDFaceSession` without crossing the JS thread.
8
+ ///
9
+ /// Registered programmatically via `FrameProcessorPluginRegistry` (avoids ObjC bridging-header
10
+ /// constraint); the `OnCreate` guard prevents double-registration on fast-refresh reloads.
11
+ /// All session ops are serialised through `UseSmileIDVisionFaceSessionHolder.operationQueue`.
12
+ /// `CIContext` is created once per instance — per-frame creation causes GPU pipeline re-compilation.
13
+ @objc(UseSmileIDVisionFaceFrameProcessorPlugin)
14
+ public class UseSmileIDVisionFaceFrameProcessorPlugin: FrameProcessorPlugin {
15
+
16
+ /// GPU-backed context reused across frames (CPU fallback if Metal unavailable).
17
+ private let ciContext = CIContext(options: [.useSoftwareRenderer: false])
18
+
19
+ @objc public override init(proxy: VisionCameraProxyHolder, options: [AnyHashable: Any]? = nil) {
20
+ super.init(proxy: proxy, options: options)
21
+ }
22
+
23
+ public override func callback(_ frame: Frame, withArguments arguments: [AnyHashable: Any]?) -> Any? {
24
+ guard frame.isValid else {
25
+ return ["error": "frame_conversion_failed"]
26
+ }
27
+
28
+ guard let pixelBuffer = CMSampleBufferGetImageBuffer(frame.buffer) else {
29
+ return ["error": "frame_conversion_failed"]
30
+ }
31
+
32
+ // Bake rotation physically into pixels (CIImage.oriented) rather than
33
+ // tagging UIImage with an orientation flag: some consumers honour the flag
34
+ // (UIKit draw) but others ignore it (raw CGImage ops), so physical pixels are safer.
35
+ // The front camera's mirror is preserved — the analyzer's head-pose
36
+ // convention is calibrated on mirrored pixels; un-mirroring flips
37
+ // Vision's yaw sign and inverts the ESS head-turn gates.
38
+ let orientationFlag = frame.isMirrored
39
+ ? frame.orientation.oppositeRotationPreservingMirror()
40
+ : frame.orientation
41
+ let orientedImage = CIImage(cvPixelBuffer: pixelBuffer)
42
+ .oriented(CGImagePropertyOrientation(orientationFlag))
43
+
44
+ guard let cgImage = ciContext.createCGImage(orientedImage, from: orientedImage.extent) else {
45
+ return ["error": "frame_conversion_failed"]
46
+ }
47
+
48
+ let rotation = (arguments?["rotation"] as? NSNumber)?.intValue ?? 0
49
+ let uiImage = UIImage(cgImage: cgImage)
50
+
51
+ guard let result = UseSmileIDVisionFaceSessionHolder.shared.scanAndDrain(
52
+ image: uiImage,
53
+ rotation: rotation
54
+ ) else {
55
+ return ["error": "session_not_configured"]
56
+ }
57
+
58
+ return result
59
+ }
60
+ }
61
+
62
+
63
+ private extension UIImage.Orientation {
64
+ /// Rotation-only flag that uprights a MIRRORED buffer while keeping its
65
+ /// mirror — flipH ∘ transpose is the opposite 90°, so quarter-turns swap.
66
+ /// Device-verified: `.right` ⇒ upright mirrored; raw `.left` ⇒ upside down.
67
+ func oppositeRotationPreservingMirror() -> UIImage.Orientation {
68
+ switch self {
69
+ case .left: return .right
70
+ case .right: return .left
71
+ case .leftMirrored: return .rightMirrored
72
+ case .rightMirrored: return .leftMirrored
73
+ default: return self
74
+ }
75
+ }
76
+ }
77
+
78
+
79
+ private extension CGImagePropertyOrientation {
80
+ /// Case-for-case translation from `UIImage.Orientation` — the two
81
+ /// enums share semantics but not raw values, so a direct cast would
82
+ /// silently produce the wrong transform.
83
+ init(_ uiOrientation: UIImage.Orientation) {
84
+ switch uiOrientation {
85
+ case .up: self = .up
86
+ case .upMirrored: self = .upMirrored
87
+ case .down: self = .down
88
+ case .downMirrored: self = .downMirrored
89
+ case .left: self = .left
90
+ case .leftMirrored: self = .leftMirrored
91
+ case .right: self = .right
92
+ case .rightMirrored: self = .rightMirrored
93
+ @unknown default: self = .up
94
+ }
95
+ }
96
+ }