@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 +3 -0
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/app.plugin.js +3 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/expo-module.config.json +6 -0
- package/ios/UseSmileIDVisionFaceExpo.podspec +61 -0
- package/ios/UseSmileIDVisionFaceModule/ResizingFrameStorage.swift +181 -0
- package/ios/UseSmileIDVisionFaceModule/UseSmileIDAnalyzerExecutor.swift +119 -0
- package/ios/UseSmileIDVisionFaceModule/UseSmileIDVisionFaceFrameProcessorPlugin.swift +96 -0
- package/ios/UseSmileIDVisionFaceModule/UseSmileIDVisionFaceModule.swift +280 -0
- package/ios/UseSmileIDVisionFaceModule/UseSmileIDVisionFaceSessionHolder.swift +105 -0
- package/package.json +79 -0
- package/plugin/build/index.d.ts +3 -0
- package/plugin/build/index.js +115 -0
- package/plugin/build/index.js.map +1 -0
- package/plugin/build/lib/podfile.d.ts +17 -0
- package/plugin/build/lib/podfile.js +180 -0
- package/plugin/build/lib/podfile.js.map +1 -0
- package/plugin/build/lib/spm.d.ts +33 -0
- package/plugin/build/lib/spm.js +196 -0
- package/plugin/build/lib/spm.js.map +1 -0
- package/plugin/src/index.ts +96 -0
- package/plugin/src/lib/podfile.ts +209 -0
- package/plugin/src/lib/spm.ts +314 -0
- package/plugin/tsconfig.json +19 -0
- package/src/__tests__/use_smile_id_vision_face.test.ts +79 -0
- package/src/index.ts +41 -0
package/CHANGELOG.md
ADDED
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
package/dist/index.d.ts
ADDED
|
@@ -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,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
|
+
}
|