@smileid/usesmileid_vision_document 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 document 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,51 @@
1
+ # `@smileid/usesmileid_vision_document`
2
+
3
+ UseSmileID v12 — Apple Vision document provider for React Native (Expo Modules).
4
+ **iOS-only.** Thin shim around the existing Swift Package
5
+ `UseSmileIDVisionDocument`.
6
+
7
+ ## Architecture
8
+
9
+ The native side runs the entire document-scan pipeline (analyzer +
10
+ validator + transitioner + state machine). This package contributes a
11
+ Swift Expo Module that constructs `UseSmileIDDocumentSession(DocumentDetectorAnalyzer())`
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_document
22
+ ```
23
+
24
+ The Smile ID native SDK is declared as an SPM dependency in
25
+ `expo-module.config.json`. Expo Modules autolinking surfaces it to
26
+ the iOS target — partners do not need to add anything to their
27
+ `Podfile` beyond the standard `use_expo_modules!` invocation.
28
+
29
+ ## Usage
30
+
31
+ ```ts
32
+ import { useSmileIDVisionDocument } from "@smileid/usesmileid_vision_document";
33
+
34
+ await useSmileIDVisionDocument.configure({ timeoutMs: 15_000 });
35
+ const result = await useSmileIDVisionDocument.analyze(bytes, rotation);
36
+ await useSmileIDVisionDocument.close();
37
+ ```
38
+
39
+ ## Cross-platform peers
40
+
41
+ | Platform | Provider package | Native dep |
42
+ | ---------------- | ------------------------------------- | -------------------------------- |
43
+ | iOS | `@smileid/usesmileid_vision_document` | `UseSmileIDVisionDocument` (SPM) |
44
+ | Android | `@smileid/usesmileid_mlkit_document` | `com.usesmileid:mlkit-document` |
45
+ | Android (no GMS) | `@smileid/usesmileid_huawei_document` | `com.usesmileid:huawei-document` |
46
+
47
+ ## Testing
48
+
49
+ ```sh
50
+ pnpm --filter @smileid/usesmileid_vision_document test
51
+ ```
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 { DocumentConfig, DocumentScanResult } from "@smileid/usesmileid_bridge";
2
+ /**
3
+ * Apple Vision document analyzer (iOS-only).
4
+ *
5
+ * Thin shim around the existing native session
6
+ * `UseSmileID.UseSmileIDDocumentSession(DocumentDetectorAnalyzer())` shipped
7
+ * in the `UseSmileIDVisionDocument` Swift Package. The native side runs the
8
+ * entire pipeline (analyzer + validator + transitioner + state machine);
9
+ * this TS layer routes calls and validates wire payloads at the channel
10
+ * 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` `DocumentAnalyzer` for the canonical
18
+ * contract.
19
+ */
20
+ /**
21
+ * Must match the Swift Expo Module's `Name("UseSmileIDVisionDocument")` and
22
+ * its VisionCamera frame-processor-plugin registration — drift is a runtime
23
+ * "module/plugin not found" error, not a compile error.
24
+ */
25
+ export declare const UseSmileIDVisionDocumentIdentifiers: {
26
+ readonly nativeModuleName: "UseSmileIDVisionDocument";
27
+ readonly frameProcessorPluginName: "UseSmileIDVisionDocumentFrameProcessor";
28
+ };
29
+ export declare const useSmileIDVisionDocument: import("@smileid/usesmileid_platform_interface").DocumentAnalyzer;
30
+ export type { DocumentConfig, DocumentScanResult };
31
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EACd,kBAAkB,EACnB,MAAM,4BAA4B,CAAC;AAGpC;;;;;;;;;;;;;;;;;GAiBG;AACH;;;;GAIG;AACH,eAAO,MAAM,mCAAmC;;;CAGtC,CAAC;AAEX,eAAO,MAAM,wBAAwB,mEAKnC,CAAC;AAEH,YAAY,EAAE,cAAc,EAAE,kBAAkB,EAAE,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,34 @@
1
+ import { useSmileIDAnalyzerExecutor } from "@smileid/usesmileid_platform_interface";
2
+ /**
3
+ * Apple Vision document analyzer (iOS-only).
4
+ *
5
+ * Thin shim around the existing native session
6
+ * `UseSmileID.UseSmileIDDocumentSession(DocumentDetectorAnalyzer())` shipped
7
+ * in the `UseSmileIDVisionDocument` Swift Package. The native side runs the
8
+ * entire pipeline (analyzer + validator + transitioner + state machine);
9
+ * this TS layer routes calls and validates wire payloads at the channel
10
+ * 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` `DocumentAnalyzer` for the canonical
18
+ * contract.
19
+ */
20
+ /**
21
+ * Must match the Swift Expo Module's `Name("UseSmileIDVisionDocument")` and
22
+ * its VisionCamera frame-processor-plugin registration — drift is a runtime
23
+ * "module/plugin not found" error, not a compile error.
24
+ */
25
+ export const UseSmileIDVisionDocumentIdentifiers = {
26
+ nativeModuleName: "UseSmileIDVisionDocument",
27
+ frameProcessorPluginName: "UseSmileIDVisionDocumentFrameProcessor",
28
+ };
29
+ export const useSmileIDVisionDocument = useSmileIDAnalyzerExecutor({
30
+ nativeModuleName: UseSmileIDVisionDocumentIdentifiers.nativeModuleName,
31
+ frameProcessorPluginName: UseSmileIDVisionDocumentIdentifiers.frameProcessorPluginName,
32
+ kind: "document",
33
+ });
34
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,0BAA0B,EAAE,MAAM,wCAAwC,CAAC;AAEpF;;;;;;;;;;;;;;;;;GAiBG;AACH;;;;GAIG;AACH,MAAM,CAAC,MAAM,mCAAmC,GAAG;IACjD,gBAAgB,EAAE,0BAA0B;IAC5C,wBAAwB,EAAE,wCAAwC;CAC1D,CAAC;AAEX,MAAM,CAAC,MAAM,wBAAwB,GAAG,0BAA0B,CAAC;IACjE,gBAAgB,EAAE,mCAAmC,CAAC,gBAAgB;IACtE,wBAAwB,EACtB,mCAAmC,CAAC,wBAAwB;IAC9D,IAAI,EAAE,UAAU;CACjB,CAAC,CAAC"}
@@ -0,0 +1,6 @@
1
+ {
2
+ "platforms": ["apple"],
3
+ "apple": {
4
+ "modules": ["UseSmileIDVisionDocumentModule"]
5
+ }
6
+ }
@@ -0,0 +1,53 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ # Pod name is intentionally distinct from the SPM product
7
+ # `UseSmileIDVisionDocument` (declared in `expo-module.config.json`)
8
+ # to avoid a name collision between the CocoaPods bridge target
9
+ # and the Swift Package product it imports.
10
+ s.name = 'UseSmileIDVisionDocumentExpo'
11
+ s.version = package['version']
12
+ s.summary = 'Apple Vision document provider for UseSmileID v12 (Expo Module bridge).'
13
+ s.description = package['description']
14
+ s.license = package['license']
15
+ s.author = package['author']
16
+ s.homepage = package['homepage']
17
+ s.platforms = { :ios => '15.0' }
18
+ s.swift_version = '5.9'
19
+ s.source = { git: 'https://github.com/usesmileid/react-native-expo.git', tag: "v#{s.version}" }
20
+ s.static_framework = true
21
+
22
+ s.dependency 'ExpoModulesCore'
23
+ # VisionCamera provides the FrameProcessorPlugin base class and
24
+ # FrameProcessorPluginRegistry used by UseSmileIDVisionDocumentFrameProcessorPlugin.
25
+ # Frame processors additionally require react-native-worklets-core in the
26
+ # consuming app (already declared as an SDK peer dependency).
27
+ s.dependency 'VisionCamera'
28
+
29
+ # The Smile ID native deps (`UseSmileIDVisionDocument` / `UseSmileIDBridge`)
30
+ # are SPM-only — declared in `expo-module.config.json` under
31
+ # `apple.dependencies`. Expo Modules autolinking surfaces the SPM
32
+ # products to this target at compile time.
33
+
34
+ s.pod_target_xcconfig = {
35
+ 'DEFINES_MODULE' => 'YES',
36
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule',
37
+ # Hardening — mirrors the native iOS frameworks' Release-config
38
+ # build settings. Dead code is stripped, install-path metadata is
39
+ # removed at link time, debug symbols (DWARF / line tables) are
40
+ # stripped via STRIP_STYLE = 'debugging', and Swift's field-name
41
+ # reflection metadata is dropped so class-dump / Hopper cannot
42
+ # recover Swift property names. STRIP_STYLE must stay 'debugging'
43
+ # (not 'all') — dynamic frameworks need their dyld-visible symbol
44
+ # table intact for consumers to link against. 'all' is for apps.
45
+ 'DEAD_CODE_STRIPPING' => 'YES',
46
+ 'DEPLOYMENT_POSTPROCESSING' => 'YES',
47
+ 'STRIP_INSTALLED_PRODUCT' => 'YES',
48
+ 'STRIP_STYLE' => 'debugging',
49
+ 'SWIFT_REFLECTION_METADATA_LEVEL' => 'without-names'
50
+ }
51
+
52
+ s.source_files = 'UseSmileIDVisionDocumentModule/**/*.{h,m,mm,swift,hpp,cpp}'
53
+ 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,97 @@
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 `UseSmileIDDocumentSession` 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 `UseSmileIDVisionDocumentSessionHolder.operationQueue`.
12
+ /// `CIContext` is created once per instance — per-frame creation causes GPU pipeline re-compilation.
13
+ @objc(UseSmileIDVisionDocumentFrameProcessorPlugin)
14
+ public class UseSmileIDVisionDocumentFrameProcessorPlugin: 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 + mirror 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
+ let orientationFlag = frame.isMirrored
36
+ ? frame.orientation.composedWithHorizontalMirror()
37
+ : frame.orientation
38
+ let orientedImage = CIImage(cvPixelBuffer: pixelBuffer)
39
+ .oriented(CGImagePropertyOrientation(orientationFlag))
40
+
41
+ guard let cgImage = ciContext.createCGImage(orientedImage, from: orientedImage.extent) else {
42
+ return ["error": "frame_conversion_failed"]
43
+ }
44
+
45
+ let rotation = (arguments?["rotation"] as? NSNumber)?.intValue ?? 0
46
+ let uiImage = UIImage(cgImage: cgImage)
47
+
48
+ guard let result = UseSmileIDVisionDocumentSessionHolder.shared.scanAndDrain(
49
+ image: uiImage,
50
+ rotation: rotation
51
+ ) else {
52
+ return ["error": "session_not_configured"]
53
+ }
54
+
55
+ return result
56
+ }
57
+ }
58
+
59
+
60
+ private extension UIImage.Orientation {
61
+ /// Compose the buffer's horizontal mirror into this orientation so rendering
62
+ /// undoes the flip together with the rotation. EXIF mirrored cases pair
63
+ /// directly with their rotation counterparts (.left ↔ .leftMirrored).
64
+ func composedWithHorizontalMirror() -> UIImage.Orientation {
65
+ switch self {
66
+ case .up: return .upMirrored
67
+ case .upMirrored: return .up
68
+ case .down: return .downMirrored
69
+ case .downMirrored: return .down
70
+ case .left: return .leftMirrored
71
+ case .leftMirrored: return .left
72
+ case .right: return .rightMirrored
73
+ case .rightMirrored: return .right
74
+ @unknown default: return self
75
+ }
76
+ }
77
+ }
78
+
79
+
80
+ private extension CGImagePropertyOrientation {
81
+ /// Case-for-case translation from `UIImage.Orientation` — the two
82
+ /// enums share semantics but not raw values, so a direct cast would
83
+ /// silently produce the wrong transform.
84
+ init(_ uiOrientation: UIImage.Orientation) {
85
+ switch uiOrientation {
86
+ case .up: self = .up
87
+ case .upMirrored: self = .upMirrored
88
+ case .down: self = .down
89
+ case .downMirrored: self = .downMirrored
90
+ case .left: self = .left
91
+ case .leftMirrored: self = .leftMirrored
92
+ case .right: self = .right
93
+ case .rightMirrored: self = .rightMirrored
94
+ @unknown default: self = .up
95
+ }
96
+ }
97
+ }