@richard.fadiora/liveness-detection 4.2.12 → 4.2.13
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.
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# Liveness Detection SDK
|
|
2
|
+
|
|
3
|
+
A cross-framework **liveness detection SDK** that performs randomized user challenges and verifies real-user presence via a backend anti-spoofing API. Works with **React** and **Angular** via framework-specific wrappers while the **core engine is framework-agnostic**.
|
|
4
|
+
|
|
5
|
+
This version introduces:
|
|
6
|
+
|
|
7
|
+
- **Headless mode** for fully custom UI
|
|
8
|
+
- Robust **challenge sequencing**
|
|
9
|
+
- **Configurable thresholds** for `Smile`, `Blink`, and `Turn_Head` challenges
|
|
10
|
+
- Sequential challenge execution with **strict timeout handling**
|
|
11
|
+
- Pause between steps for user feedback
|
|
12
|
+
- Cross-framework integration (React hook & Angular service)
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## 📌 Overview
|
|
17
|
+
|
|
18
|
+
This SDK strengthens identity verification by combining:
|
|
19
|
+
|
|
20
|
+
- Randomized challenge-response validation
|
|
21
|
+
- Sequential challenge execution
|
|
22
|
+
- Strict timeout enforcement
|
|
23
|
+
- Backend spoof detection
|
|
24
|
+
- Callback-based integration for easy usage
|
|
25
|
+
- Headless / fully customizable UI via render props (React)
|
|
26
|
+
- Configurable challenge thresholds
|
|
27
|
+
|
|
28
|
+
Protects against:
|
|
29
|
+
|
|
30
|
+
- Presentation (photo) attacks
|
|
31
|
+
- Screen glare attacks
|
|
32
|
+
- Video replay/injection attacks
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## ⚙️ Architecture
|
|
37
|
+
|
|
38
|
+
### Core Engine
|
|
39
|
+
|
|
40
|
+
- `LivenessEngine` (framework-agnostic)
|
|
41
|
+
- Handles:
|
|
42
|
+
- Challenge sequence generation
|
|
43
|
+
- MediaPipe face & hand model detection
|
|
44
|
+
- Challenge validation (`Smile`, `Blink`, `Turn_Head`, `Thumbs_Up`)
|
|
45
|
+
- Face cropping
|
|
46
|
+
- Detection loop
|
|
47
|
+
- Final frame capture and backend verification
|
|
48
|
+
- Can be used directly or via React/Angular wrappers
|
|
49
|
+
|
|
50
|
+
### Framework Wrappers
|
|
51
|
+
|
|
52
|
+
| Framework | Wrapper | Notes |
|
|
53
|
+
|-----------|--------|------|
|
|
54
|
+
| React | `useLiveness` hook | Uses `webcamRef` and React state, supports render-prop customization |
|
|
55
|
+
| Angular | `LivenessService` | Injectable service, exposes engine state and control methods. Must be imported with "@richard.fadiora/liveness-detection/angular |
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## ⚙️ How It Works
|
|
60
|
+
|
|
61
|
+
### 1️⃣ Challenge Initialization
|
|
62
|
+
|
|
63
|
+
- Randomly selects **3 challenges** from the pool: `Smile`, `Blink`, `Turn_Head`, `Thumbs_Up`.
|
|
64
|
+
- Timer starts immediately (default **60 seconds**, configurable via `duration`).
|
|
65
|
+
- Each challenge is verified sequentially, with a brief pause between steps for feedback.
|
|
66
|
+
- If the timer expires, the session terminates and no frames are sent to the backend.
|
|
67
|
+
|
|
68
|
+
### 2️⃣ Challenge Execution
|
|
69
|
+
|
|
70
|
+
- Real-time validation using webcam input and MediaPipe models.
|
|
71
|
+
- Sequential verification ensures **no steps are skipped**.
|
|
72
|
+
- Developers can render custom UI via the **render prop** while using the same underlying logic.
|
|
73
|
+
- Challenge thresholds are configurable through props:
|
|
74
|
+
|
|
75
|
+
| Prop Name | Default | Description |
|
|
76
|
+
|-----------------------|---------|------------------------------------------|
|
|
77
|
+
| `smileThreshold` | 0.20 | Minimum smile width to count as valid |
|
|
78
|
+
| `blinkThreshold` | 0.01 | Maximum eye aspect ratio to count as blink |
|
|
79
|
+
| `minturnHeadThreshold`| 0.15 | Minimum yaw for right head turn detection |
|
|
80
|
+
| `maxturnHeadThreshold`| 0.85 | Maximum yaw for left head turn detection |
|
|
81
|
+
|
|
82
|
+
### 3️⃣ Backend Liveness Verification
|
|
83
|
+
|
|
84
|
+
- Captures **5 frames** from the webcam after all challenges are complete.
|
|
85
|
+
- Frames sent to backend API (`apiUrl` prop).
|
|
86
|
+
- Backend performs spoof detection, glare detection, and video injection detection.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## ✅ Success & Failure Behavior
|
|
91
|
+
|
|
92
|
+
### Success
|
|
93
|
+
- UI (or custom component) shows success feedback.
|
|
94
|
+
- `onComplete` callback is triggered:
|
|
95
|
+
```js
|
|
96
|
+
onComplete({ success: true, image: frame, skinConfidence });
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Failure
|
|
100
|
+
- UI shows failure message.
|
|
101
|
+
- `onError` callback is triggered:
|
|
102
|
+
```js
|
|
103
|
+
onError({ success: false, reason, skinConfidence: null });
|
|
104
|
+
```
|
|
105
|
+
- Component resets; user must start a new session.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## 📦 Props
|
|
110
|
+
|
|
111
|
+
| Prop Name | Type | Required | Description |
|
|
112
|
+
|------------|-----------------------------|----------|-------------|
|
|
113
|
+
| `apiUrl` | `string` | Yes | Backend endpoint used for liveness verification |
|
|
114
|
+
| `onComplete` | `(result: object) => void` | Yes | Callback fired after verification succeeds |
|
|
115
|
+
| `onError` | `(result: object) => void` | Yes | Callback fired after verification fails |
|
|
116
|
+
| `duration` | `number` | No | Maximum time for all challenges (default: 60s) |
|
|
117
|
+
| `render` | `(sdk: object) => JSX.Element` | No | Optional render prop for full UI customization |
|
|
118
|
+
| `classNames` | `object` | No | Optional CSS class names to customize default UI |
|
|
119
|
+
| `smileThreshold` | `number` | No | Minimum smile width for Smile challenge |
|
|
120
|
+
| `blinkThreshold` | `number` | No | Maximum eye aspect ratio for Blink challenge |
|
|
121
|
+
| `minturnHeadThreshold` | `number` | No | Minimum yaw for Turn_Head challenge |
|
|
122
|
+
| `maxturnHeadThreshold` | `number` | No | Maximum yaw for Turn_Head challenge |
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## 🧩 Usage Example
|
|
128
|
+
|
|
129
|
+
### Default UI
|
|
130
|
+
```jsx
|
|
131
|
+
import { LivenessSDK } from "@richard.fadiora/liveness-detection";
|
|
132
|
+
|
|
133
|
+
function App() {
|
|
134
|
+
return (
|
|
135
|
+
<LivenessSDK
|
|
136
|
+
apiUrl="https://your-backend-api.com/liveness-check"
|
|
137
|
+
onComplete={(result) => console.log("Success:", result)}
|
|
138
|
+
onError={(error) => console.log("Failed:", error.reason)}
|
|
139
|
+
duration={60}
|
|
140
|
+
/>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export default App;
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Headless / Custom UI
|
|
148
|
+
```jsx
|
|
149
|
+
<LivenessSDK
|
|
150
|
+
apiUrl="https://your-backend-api.com/liveness-check"
|
|
151
|
+
onComplete={handleComplete}
|
|
152
|
+
onError={handleError}
|
|
153
|
+
render={(sdk) => (
|
|
154
|
+
<div>
|
|
155
|
+
<video ref={sdk.webcamRef} autoPlay playsInline muted />
|
|
156
|
+
<div>Step {sdk.currentStep + 1}: {sdk.sequence[sdk.currentStep]}</div>
|
|
157
|
+
<div>Time left: {sdk.timeLeft}s</div>
|
|
158
|
+
<button onClick={sdk.start}>Start</button>
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
/>
|
|
162
|
+
```
|
|
163
|
+
## 📤 Hook Return Values
|
|
164
|
+
|
|
165
|
+
The `useLiveness` hook exposes the following values and control functions for managing the liveness detection session.
|
|
166
|
+
|
|
167
|
+
| Name | Type | Description |
|
|
168
|
+
|-----|------|-------------|
|
|
169
|
+
| `webcamRef` | `ref` | React ref attached to the webcam component. Provides access to the live video stream used for face and hand detection as well as frame capture for verification. |
|
|
170
|
+
| `status` | `string` | Represents the current state of the liveness session. Possible values include `loading`, `ready`, `capturing`, `verifying`, `success`, `error`, and `expired`. |
|
|
171
|
+
| `errorMsg` | `string` | Contains the error message displayed when the liveness verification fails or when the system encounters an issue. |
|
|
172
|
+
| `sequence` | `string[]` | The randomized sequence of liveness challenges selected for the current session. Three challenges are chosen from the challenge pool. |
|
|
173
|
+
| `currentStep` | `number` | The index of the current challenge being performed within the challenge sequence. |
|
|
174
|
+
| `timeLeft` | `number` | Remaining time (in seconds) before the session expires. The timer runs while the system is in the `capturing` state. |
|
|
175
|
+
| `isStepTransitioning` | `boolean` | Indicates whether the system is currently transitioning between challenges. Used to briefly pause detection and provide UI feedback after a challenge is completed. |
|
|
176
|
+
| `start` | `function` | Starts the liveness challenge session and begins the detection loop. |
|
|
177
|
+
| `reset` | `function` | Resets the entire session, including the timer, challenge sequence, step index, and error state. |
|
|
178
|
+
| `sendFinalProof` | `function` | Sends captured face frames to the backend verification API to perform the final liveness check. Normally triggered automatically after the last challenge is completed. |
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## 🎨 Styling with `classNames`
|
|
186
|
+
|
|
187
|
+
You can pass a `classNames` object to override the default UI classes:
|
|
188
|
+
```js
|
|
189
|
+
classNames={{
|
|
190
|
+
container: "liveness-container",
|
|
191
|
+
webcam: "liveness-webcam",
|
|
192
|
+
button: "liveness-button",
|
|
193
|
+
challenge: "liveness-challenge",
|
|
194
|
+
timer: "liveness-timer",
|
|
195
|
+
error: "liveness-error",
|
|
196
|
+
success: "liveness-success",
|
|
197
|
+
}}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### CSS Example
|
|
201
|
+
```css
|
|
202
|
+
.liveness-container {
|
|
203
|
+
text-align: center;
|
|
204
|
+
background: #121212;
|
|
205
|
+
padding: 24px;
|
|
206
|
+
border-radius: 20px;
|
|
207
|
+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
|
208
|
+
color: white;
|
|
209
|
+
}
|
|
210
|
+
.liveness-webcam {
|
|
211
|
+
border-radius: 20px;
|
|
212
|
+
border: 2px solid #007bff;
|
|
213
|
+
width: 100%;
|
|
214
|
+
}
|
|
215
|
+
.liveness-button {
|
|
216
|
+
padding: 12px 30px;
|
|
217
|
+
font-size: 16px;
|
|
218
|
+
font-weight: bold;
|
|
219
|
+
border-radius: 10px;
|
|
220
|
+
background: #28a745;
|
|
221
|
+
color: white;
|
|
222
|
+
border: none;
|
|
223
|
+
cursor: pointer;
|
|
224
|
+
margin-top: 10px;
|
|
225
|
+
}
|
|
226
|
+
.liveness-challenge {
|
|
227
|
+
margin: 5px 0;
|
|
228
|
+
font-weight: bold;
|
|
229
|
+
font-size: 40px;
|
|
230
|
+
}
|
|
231
|
+
.liveness-timer {
|
|
232
|
+
margin: 10px 0;
|
|
233
|
+
font-weight: bold;
|
|
234
|
+
font-size: 18px;
|
|
235
|
+
}
|
|
236
|
+
.liveness-error {
|
|
237
|
+
color: #ff4d4d;
|
|
238
|
+
margin-top: 20px;
|
|
239
|
+
}
|
|
240
|
+
.liveness-success {
|
|
241
|
+
color: #4caf50;
|
|
242
|
+
margin-top: 20px;
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## ⏳ Timeout Rules
|
|
249
|
+
|
|
250
|
+
- Maximum session duration: set via `duration` prop (default 60s).
|
|
251
|
+
- On timeout: challenge stops, state resets, backend verification is not triggered.
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## 🔐 Security Architecture
|
|
256
|
+
|
|
257
|
+
- **Client-side**: Randomized challenges, real-time gesture detection
|
|
258
|
+
- **Server-side**: Frame-based spoof analysis, glare detection, video injection detection
|
|
259
|
+
- **Session control**: Timeout enforcement, manual restart on failure
|
|
260
|
+
- Reduces false positives and prevents replay attacks.
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## 📊 Verification Criteria
|
|
265
|
+
|
|
266
|
+
- All 3 challenges completed
|
|
267
|
+
- All 5 frames successfully sent to backend
|
|
268
|
+
- Backend confirms: no spoofing, no glare, human skin texture, no video injection
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## 🏗️ Integration Notes
|
|
273
|
+
|
|
274
|
+
- Webcam permissions required
|
|
275
|
+
- Backend must accept 5 frames in expected format
|
|
276
|
+
- `apiUrl` must be reachable and have an endpoint `/v1/verify`
|
|
277
|
+
- CORS must be configured properly
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## 🚀 Ideal Use Cases
|
|
282
|
+
|
|
283
|
+
- KYC verification flows
|
|
284
|
+
- Identity onboarding
|
|
285
|
+
- Account recovery
|
|
286
|
+
- Secure login
|
|
287
|
+
- Financial / compliance apps
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## 👨💻 Maintainer
|
|
292
|
+
|
|
293
|
+
Fadiora Richard.
|
|
294
|
+
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { Injectable, EventEmitter, Output, Input, ViewChild, Component } from '@angular/core';
|
|
3
|
+
import { BehaviorSubject } from 'rxjs';
|
|
4
|
+
import { FilesetResolver, FaceLandmarker, HandLandmarker } from '@mediapipe/tasks-vision';
|
|
5
|
+
import * as i2 from '@angular/common';
|
|
6
|
+
import { CommonModule } from '@angular/common';
|
|
7
|
+
|
|
8
|
+
// src/core/LivenessEngine.ts
|
|
9
|
+
class LivenessEngine {
|
|
10
|
+
config;
|
|
11
|
+
models = { face: null, hand: null };
|
|
12
|
+
webcam = null;
|
|
13
|
+
state;
|
|
14
|
+
currentStepRef = 0;
|
|
15
|
+
isStepTransitioningRef = false;
|
|
16
|
+
timerId = null;
|
|
17
|
+
requestId = null;
|
|
18
|
+
offscreenCanvas = document.createElement("canvas");
|
|
19
|
+
CHALLENGE_POOL = ["Smile", "Blink", "Turn_Head", "Thumbs_Up"];
|
|
20
|
+
constructor(config) {
|
|
21
|
+
this.config = {
|
|
22
|
+
apiUrl: config.apiUrl,
|
|
23
|
+
duration: config.duration ?? 60,
|
|
24
|
+
smileThreshold: config.smileThreshold ?? 0.2,
|
|
25
|
+
blinkThreshold: config.blinkThreshold ?? 0.012,
|
|
26
|
+
minturnHeadThreshold: config.minturnHeadThreshold ?? 0.15,
|
|
27
|
+
maxturnHeadThreshold: config.maxturnHeadThreshold ?? 0.85,
|
|
28
|
+
onStateChange: config.onStateChange || (() => { }),
|
|
29
|
+
onComplete: config.onComplete || (() => { }),
|
|
30
|
+
onError: config.onError || (() => { }),
|
|
31
|
+
};
|
|
32
|
+
this.state = {
|
|
33
|
+
status: "loading",
|
|
34
|
+
sequence: [],
|
|
35
|
+
currentStep: 0,
|
|
36
|
+
timeLeft: this.config.duration,
|
|
37
|
+
isStepTransitioning: false,
|
|
38
|
+
errorMsg: "",
|
|
39
|
+
};
|
|
40
|
+
this.offscreenCanvas.width = 224;
|
|
41
|
+
this.offscreenCanvas.height = 224;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Phase 1: Load AI Assets (Call this immediately on mount)
|
|
45
|
+
*/
|
|
46
|
+
async loadModels() {
|
|
47
|
+
console.log("[LivenessEngine] Loading AI Models...");
|
|
48
|
+
try {
|
|
49
|
+
const vision = await FilesetResolver.forVisionTasks("https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.3/wasm");
|
|
50
|
+
this.models.face = await FaceLandmarker.createFromOptions(vision, {
|
|
51
|
+
baseOptions: {
|
|
52
|
+
modelAssetPath: "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task",
|
|
53
|
+
delegate: "GPU",
|
|
54
|
+
},
|
|
55
|
+
outputFaceBlendshapes: true,
|
|
56
|
+
runningMode: "VIDEO",
|
|
57
|
+
});
|
|
58
|
+
this.models.hand = await HandLandmarker.createFromOptions(vision, {
|
|
59
|
+
baseOptions: {
|
|
60
|
+
modelAssetPath: "https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task",
|
|
61
|
+
delegate: "GPU",
|
|
62
|
+
},
|
|
63
|
+
runningMode: "VIDEO",
|
|
64
|
+
numHands: 1,
|
|
65
|
+
});
|
|
66
|
+
this.generateSequence();
|
|
67
|
+
// Stay in 'loading' or move to a 'ready_to_mount' status if you prefer
|
|
68
|
+
// For now, we move to ready to signal the UI can show the camera
|
|
69
|
+
this.updateState({ status: "ready" });
|
|
70
|
+
console.log("[LivenessEngine] Models Loaded.");
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
console.error("[LivenessEngine] Init Error:", err);
|
|
74
|
+
this.updateState({ status: "error", errorMsg: "Failed to load security assets." });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Phase 2: Attach Video (Call this once the Webcam is in the DOM)
|
|
79
|
+
*/
|
|
80
|
+
attachVideo = (video) => {
|
|
81
|
+
this.webcam = video;
|
|
82
|
+
console.log("[LivenessEngine] Video stream attached.");
|
|
83
|
+
};
|
|
84
|
+
start = () => {
|
|
85
|
+
if (this.state.status !== "ready" || !this.webcam) {
|
|
86
|
+
console.warn("[LivenessEngine] Engine not ready or video missing.");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
console.log("[LivenessEngine] Session Starting...");
|
|
90
|
+
this.state.status = "capturing"; // Sync internal status immediately
|
|
91
|
+
this.updateState({ status: "capturing" });
|
|
92
|
+
this.startTimer();
|
|
93
|
+
this.detectLoop();
|
|
94
|
+
};
|
|
95
|
+
stop = () => {
|
|
96
|
+
if (this.timerId)
|
|
97
|
+
clearInterval(this.timerId);
|
|
98
|
+
if (this.requestId)
|
|
99
|
+
cancelAnimationFrame(this.requestId);
|
|
100
|
+
this.timerId = null;
|
|
101
|
+
this.requestId = null;
|
|
102
|
+
};
|
|
103
|
+
reset = () => {
|
|
104
|
+
this.stop();
|
|
105
|
+
this.currentStepRef = 0;
|
|
106
|
+
this.isStepTransitioningRef = false;
|
|
107
|
+
this.generateSequence();
|
|
108
|
+
this.updateState({
|
|
109
|
+
status: "ready",
|
|
110
|
+
currentStep: 0,
|
|
111
|
+
timeLeft: this.config.duration,
|
|
112
|
+
isStepTransitioning: false,
|
|
113
|
+
errorMsg: "",
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
updateState(newState) {
|
|
117
|
+
this.state = { ...this.state, ...newState };
|
|
118
|
+
this.config.onStateChange(this.state);
|
|
119
|
+
}
|
|
120
|
+
generateSequence() {
|
|
121
|
+
const seq = [...this.CHALLENGE_POOL].sort(() => 0.5 - Math.random()).slice(0, 3);
|
|
122
|
+
this.state.sequence = seq;
|
|
123
|
+
return seq;
|
|
124
|
+
}
|
|
125
|
+
startTimer() {
|
|
126
|
+
if (this.timerId)
|
|
127
|
+
clearInterval(this.timerId);
|
|
128
|
+
this.timerId = setInterval(() => {
|
|
129
|
+
if (this.state.timeLeft > 0) {
|
|
130
|
+
this.updateState({ timeLeft: this.state.timeLeft - 1 });
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
this.stop();
|
|
134
|
+
this.updateState({ status: "expired" });
|
|
135
|
+
}
|
|
136
|
+
}, 1000);
|
|
137
|
+
}
|
|
138
|
+
detectLoop = () => {
|
|
139
|
+
if (this.state.status !== "capturing" || !this.webcam || !this.models.face)
|
|
140
|
+
return;
|
|
141
|
+
const now = performance.now();
|
|
142
|
+
try {
|
|
143
|
+
const faceRes = this.models.face.detectForVideo(this.webcam, now);
|
|
144
|
+
const handRes = this.models.hand?.detectForVideo(this.webcam, now);
|
|
145
|
+
const currentChallenge = this.state.sequence[this.currentStepRef];
|
|
146
|
+
if (this.checkAction(faceRes, handRes, currentChallenge)) {
|
|
147
|
+
this.handleStepSuccess();
|
|
148
|
+
return; // handleStepSuccess schedules the next frame after a delay
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
console.error("[LivenessEngine] Loop detection error:", err);
|
|
153
|
+
}
|
|
154
|
+
this.requestId = requestAnimationFrame(this.detectLoop);
|
|
155
|
+
};
|
|
156
|
+
handleStepSuccess() {
|
|
157
|
+
if (this.isStepTransitioningRef)
|
|
158
|
+
return;
|
|
159
|
+
this.isStepTransitioningRef = true;
|
|
160
|
+
this.updateState({ isStepTransitioning: true });
|
|
161
|
+
setTimeout(() => {
|
|
162
|
+
if (this.currentStepRef < this.state.sequence.length - 1) {
|
|
163
|
+
this.currentStepRef++;
|
|
164
|
+
this.isStepTransitioningRef = false;
|
|
165
|
+
this.updateState({
|
|
166
|
+
currentStep: this.currentStepRef,
|
|
167
|
+
isStepTransitioning: false,
|
|
168
|
+
});
|
|
169
|
+
this.requestId = requestAnimationFrame(this.detectLoop);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
this.sendFinalProof();
|
|
173
|
+
}
|
|
174
|
+
}, 1500);
|
|
175
|
+
}
|
|
176
|
+
checkAction(faceRes, handRes, challenge) {
|
|
177
|
+
if (this.isStepTransitioningRef)
|
|
178
|
+
return false;
|
|
179
|
+
if (challenge === "Thumbs_Up" && handRes?.landmarks?.length > 0) {
|
|
180
|
+
const l = handRes.landmarks[0];
|
|
181
|
+
return l[4].y < l[2].y && [8, 12, 16, 20].every(i => l[i].y > l[i - 2].y);
|
|
182
|
+
}
|
|
183
|
+
if (faceRes?.faceLandmarks?.length > 0) {
|
|
184
|
+
const lms = faceRes.faceLandmarks[0];
|
|
185
|
+
switch (challenge) {
|
|
186
|
+
case "Smile":
|
|
187
|
+
return (lms[291].x - lms[61].x) > this.config.smileThreshold;
|
|
188
|
+
case "Blink":
|
|
189
|
+
const leftEar = Math.abs(lms[159].y - lms[145].y);
|
|
190
|
+
const rightEar = Math.abs(lms[386].y - lms[374].y);
|
|
191
|
+
return ((leftEar + rightEar) / 2) < this.config.blinkThreshold;
|
|
192
|
+
case "Turn_Head":
|
|
193
|
+
const yaw = (lms[1].x - lms[33].x) / (lms[263].x - lms[33].x);
|
|
194
|
+
return yaw < this.config.minturnHeadThreshold || yaw > this.config.maxturnHeadThreshold;
|
|
195
|
+
default: return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
async sendFinalProof() {
|
|
201
|
+
this.stop();
|
|
202
|
+
this.updateState({ status: "verifying" });
|
|
203
|
+
try {
|
|
204
|
+
const blobs = [];
|
|
205
|
+
let finalBase64 = "";
|
|
206
|
+
for (let i = 0; i < 5; i++) {
|
|
207
|
+
if (!this.webcam)
|
|
208
|
+
break;
|
|
209
|
+
const faceRes = this.models.face?.detectForVideo(this.webcam, performance.now());
|
|
210
|
+
if (faceRes?.faceLandmarks?.[0]) {
|
|
211
|
+
const canvas = this.getFaceCrop(this.webcam, faceRes.faceLandmarks[0]);
|
|
212
|
+
const blob = await new Promise(res => canvas.toBlob(res, "image/jpeg", 0.9));
|
|
213
|
+
if (blob)
|
|
214
|
+
blobs.push(blob);
|
|
215
|
+
if (i === 4)
|
|
216
|
+
finalBase64 = canvas.toDataURL("image/jpeg");
|
|
217
|
+
}
|
|
218
|
+
await new Promise(r => setTimeout(r, 100));
|
|
219
|
+
}
|
|
220
|
+
const { verifyLiveness } = await Promise.resolve().then(function () { return api; });
|
|
221
|
+
const result = await verifyLiveness(this.config.apiUrl, blobs, this.state.sequence);
|
|
222
|
+
if (result.is_live) {
|
|
223
|
+
this.updateState({ status: "success" });
|
|
224
|
+
this.config.onComplete({ success: true, image: finalBase64, skinConfidence: result.skin_confidence });
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
const reason = result.reason || "Liveness check failed";
|
|
228
|
+
this.updateState({ status: "error", errorMsg: reason });
|
|
229
|
+
this.config.onError({ success: false, reason });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
this.updateState({ status: "error", errorMsg: "Verification failed." });
|
|
234
|
+
this.config.onError({ success: false, reason: "Network error" });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
getFaceCrop(video, landmarks) {
|
|
238
|
+
const ctx = this.offscreenCanvas.getContext("2d");
|
|
239
|
+
const xs = landmarks.map(l => l.x * video.videoWidth);
|
|
240
|
+
const ys = landmarks.map(l => l.y * video.videoHeight);
|
|
241
|
+
const minX = Math.min(...xs);
|
|
242
|
+
const maxX = Math.max(...xs);
|
|
243
|
+
const minY = Math.min(...ys);
|
|
244
|
+
const maxY = Math.max(...ys);
|
|
245
|
+
const width = maxX - minX;
|
|
246
|
+
const height = maxY - minY;
|
|
247
|
+
const margin = width * 0.3;
|
|
248
|
+
ctx.clearRect(0, 0, 224, 224);
|
|
249
|
+
ctx.drawImage(video, minX - margin, minY - margin, width + margin * 2, height + margin * 2, 0, 0, 224, 224);
|
|
250
|
+
return this.offscreenCanvas;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
class LivenessService {
|
|
255
|
+
ngZone;
|
|
256
|
+
engine;
|
|
257
|
+
stateSubject = new BehaviorSubject(null);
|
|
258
|
+
// Components will subscribe to this
|
|
259
|
+
state$ = this.stateSubject.asObservable();
|
|
260
|
+
constructor(ngZone) {
|
|
261
|
+
this.ngZone = ngZone;
|
|
262
|
+
}
|
|
263
|
+
init(config) {
|
|
264
|
+
// Run inside runOutsideAngular to prevent 60fps change detection triggers
|
|
265
|
+
this.ngZone.runOutsideAngular(() => {
|
|
266
|
+
this.engine = new LivenessEngine({
|
|
267
|
+
...config,
|
|
268
|
+
onStateChange: (state) => {
|
|
269
|
+
// Bring state changes BACK into the zone so the UI updates
|
|
270
|
+
this.ngZone.run(() => this.stateSubject.next(state));
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
this.engine.loadModels();
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
attach(video) {
|
|
277
|
+
this.engine?.attachVideo(video);
|
|
278
|
+
}
|
|
279
|
+
start() {
|
|
280
|
+
this.engine?.start();
|
|
281
|
+
}
|
|
282
|
+
reset() {
|
|
283
|
+
this.engine?.reset();
|
|
284
|
+
}
|
|
285
|
+
ngOnDestroy() {
|
|
286
|
+
this.engine?.stop();
|
|
287
|
+
}
|
|
288
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.3", ngImport: i0, type: LivenessService, deps: [{ token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Injectable });
|
|
289
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.3", ngImport: i0, type: LivenessService, providedIn: 'root' });
|
|
290
|
+
}
|
|
291
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.3", ngImport: i0, type: LivenessService, decorators: [{
|
|
292
|
+
type: Injectable,
|
|
293
|
+
args: [{ providedIn: 'root' }]
|
|
294
|
+
}], ctorParameters: () => [{ type: i0.NgZone }] });
|
|
295
|
+
|
|
296
|
+
class LivenessComponent {
|
|
297
|
+
liveness;
|
|
298
|
+
webcamRef;
|
|
299
|
+
// --- Logic Configuration ---
|
|
300
|
+
apiUrl;
|
|
301
|
+
duration = 60;
|
|
302
|
+
smileThreshold;
|
|
303
|
+
blinkThreshold;
|
|
304
|
+
minturnHeadThreshold;
|
|
305
|
+
maxturnHeadThreshold;
|
|
306
|
+
// --- Styling Configuration (Class Names) ---
|
|
307
|
+
containerClass = '';
|
|
308
|
+
videoWrapperClass = '';
|
|
309
|
+
videoClass = '';
|
|
310
|
+
timerClass = '';
|
|
311
|
+
challengeTextClass = '';
|
|
312
|
+
buttonClass = '';
|
|
313
|
+
retryButtonClass = '';
|
|
314
|
+
statusOverlayClass = '';
|
|
315
|
+
successMessageClass = '';
|
|
316
|
+
errorMessageClass = '';
|
|
317
|
+
instructionBoxClass = '';
|
|
318
|
+
onComplete = new EventEmitter();
|
|
319
|
+
onError = new EventEmitter();
|
|
320
|
+
constructor(liveness) {
|
|
321
|
+
this.liveness = liveness;
|
|
322
|
+
}
|
|
323
|
+
ngOnInit() {
|
|
324
|
+
// Pass the custom thresholds directly to the service
|
|
325
|
+
this.liveness.init({
|
|
326
|
+
apiUrl: this.apiUrl,
|
|
327
|
+
duration: this.duration,
|
|
328
|
+
smileThreshold: this.smileThreshold,
|
|
329
|
+
blinkThreshold: this.blinkThreshold,
|
|
330
|
+
minturnHeadThreshold: this.minturnHeadThreshold,
|
|
331
|
+
maxturnHeadThreshold: this.maxturnHeadThreshold,
|
|
332
|
+
onComplete: (res) => this.onComplete.emit(res),
|
|
333
|
+
onError: (err) => this.onError.emit(err)
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
async ngAfterViewInit() {
|
|
337
|
+
try {
|
|
338
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
339
|
+
video: { width: 640, height: 480, facingMode: 'user' }
|
|
340
|
+
});
|
|
341
|
+
const video = this.webcamRef.nativeElement;
|
|
342
|
+
video.srcObject = stream;
|
|
343
|
+
video.onloadedmetadata = () => {
|
|
344
|
+
video.play();
|
|
345
|
+
this.liveness.attach(video);
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
catch (e) {
|
|
349
|
+
this.onError.emit({ success: false, reason: 'Camera access denied' });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
ngOnDestroy() {
|
|
353
|
+
const stream = this.webcamRef?.nativeElement?.srcObject;
|
|
354
|
+
stream?.getTracks().forEach(track => track.stop());
|
|
355
|
+
}
|
|
356
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.3", ngImport: i0, type: LivenessComponent, deps: [{ token: LivenessService }], target: i0.ɵɵFactoryTarget.Component });
|
|
357
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.3", type: LivenessComponent, isStandalone: true, selector: "LivenessCheck", inputs: { apiUrl: "apiUrl", duration: "duration", smileThreshold: "smileThreshold", blinkThreshold: "blinkThreshold", minturnHeadThreshold: "minturnHeadThreshold", maxturnHeadThreshold: "maxturnHeadThreshold", containerClass: "containerClass", videoWrapperClass: "videoWrapperClass", videoClass: "videoClass", timerClass: "timerClass", challengeTextClass: "challengeTextClass", buttonClass: "buttonClass", retryButtonClass: "retryButtonClass", statusOverlayClass: "statusOverlayClass", successMessageClass: "successMessageClass", errorMessageClass: "errorMessageClass", instructionBoxClass: "instructionBoxClass" }, outputs: { onComplete: "onComplete", onError: "onError" }, viewQueries: [{ propertyName: "webcamRef", first: true, predicate: ["webcam"], descendants: true }], ngImport: i0, template: "<div class=\"liveness-sdk-container\" [ngClass]=\"containerClass\">\r\n <div class=\"video-window\" [ngClass]=\"videoWrapperClass\">\r\n <video #webcam \r\n autoplay \r\n playsinline \r\n muted \r\n [ngClass]=\"videoClass\" \r\n style=\"transform: scaleX(-1);\">\r\n </video>\r\n\r\n <ng-container *ngIf=\"(liveness.state$ | async) as state\">\r\n <div class=\"status-overlay\" [ngClass]=\"statusOverlayClass\">\r\n \r\n <div class=\"instruction-box\">\r\n <h2 class=\"status-text\">{{ state.status | uppercase }}</h2>\r\n \r\n <p *ngIf=\"state.status === 'capturing'\" \r\n class=\"challenge-text\" \r\n [ngClass]=\"challengeTextClass\">\r\n Challenge: {{ state.sequence[state.currentStep] }}\r\n </p>\r\n\r\n <p *ngIf=\"state.status === 'success'\" \r\n class=\"success-msg\" \r\n [ngClass]=\"successMessageClass\">\r\n Verification Successful!\r\n </p>\r\n \r\n <p *ngIf=\"state.status === 'error' || state.status === 'expired'\" \r\n class=\"error-msg\" \r\n [ngClass]=\"errorMessageClass\">\r\n {{ state.errorMsg || 'Verification failed. Please try again.' }}\r\n </p>\r\n </div>\r\n\r\n <div *ngIf=\"state.status === 'capturing'\" \r\n class=\"timer-ring\" \r\n [ngClass]=\"timerClass\">\r\n {{ state.timeLeft }}s\r\n </div>\r\n\r\n <div class=\"action-area\">\r\n <button *ngIf=\"state.status === 'ready'\" \r\n (click)=\"liveness.start()\" \r\n class=\"btn-start\" \r\n [ngClass]=\"buttonClass\">\r\n Start Verification\r\n </button>\r\n \r\n <button *ngIf=\"state.status === 'error' || state.status === 'expired'\" \r\n (click)=\"liveness.reset()\" \r\n class=\"btn-retry\"\r\n [ngClass]=\"retryButtonClass\">\r\n Try Again\r\n </button>\r\n </div>\r\n\r\n </div>\r\n </ng-container>\r\n </div>\r\n</div>", styles: [".liveness-wrapper{position:relative;max-width:500px;margin:auto;font-family:sans-serif;color:#fff}.video-container{position:relative;width:100%;aspect-ratio:3/4;background:#000;border-radius:20px;overflow:hidden}video{width:100%;height:100%;object-fit:cover;transform:scaleX(-1)}.challenge-ui{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:space-between;padding:20px;background:#0003}.instruction-box h2{font-size:2.5rem;text-shadow:0 2px 10px rgba(0,0,0,.5);margin-top:0}.timer{font-size:1.5rem;background:#00000080;padding:5px 15px;border-radius:20px}.btn-start{padding:15px 40px;font-size:1.2rem;background:#28a745;color:#fff;border:none;border-radius:50px;cursor:pointer;transition:transform .2s}.btn-start:hover{transform:scale(1.05)}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i2.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "pipe", type: i2.AsyncPipe, name: "async" }, { kind: "pipe", type: i2.UpperCasePipe, name: "uppercase" }] });
|
|
358
|
+
}
|
|
359
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.3", ngImport: i0, type: LivenessComponent, decorators: [{
|
|
360
|
+
type: Component,
|
|
361
|
+
args: [{ selector: 'LivenessCheck', standalone: true, imports: [CommonModule], template: "<div class=\"liveness-sdk-container\" [ngClass]=\"containerClass\">\r\n <div class=\"video-window\" [ngClass]=\"videoWrapperClass\">\r\n <video #webcam \r\n autoplay \r\n playsinline \r\n muted \r\n [ngClass]=\"videoClass\" \r\n style=\"transform: scaleX(-1);\">\r\n </video>\r\n\r\n <ng-container *ngIf=\"(liveness.state$ | async) as state\">\r\n <div class=\"status-overlay\" [ngClass]=\"statusOverlayClass\">\r\n \r\n <div class=\"instruction-box\">\r\n <h2 class=\"status-text\">{{ state.status | uppercase }}</h2>\r\n \r\n <p *ngIf=\"state.status === 'capturing'\" \r\n class=\"challenge-text\" \r\n [ngClass]=\"challengeTextClass\">\r\n Challenge: {{ state.sequence[state.currentStep] }}\r\n </p>\r\n\r\n <p *ngIf=\"state.status === 'success'\" \r\n class=\"success-msg\" \r\n [ngClass]=\"successMessageClass\">\r\n Verification Successful!\r\n </p>\r\n \r\n <p *ngIf=\"state.status === 'error' || state.status === 'expired'\" \r\n class=\"error-msg\" \r\n [ngClass]=\"errorMessageClass\">\r\n {{ state.errorMsg || 'Verification failed. Please try again.' }}\r\n </p>\r\n </div>\r\n\r\n <div *ngIf=\"state.status === 'capturing'\" \r\n class=\"timer-ring\" \r\n [ngClass]=\"timerClass\">\r\n {{ state.timeLeft }}s\r\n </div>\r\n\r\n <div class=\"action-area\">\r\n <button *ngIf=\"state.status === 'ready'\" \r\n (click)=\"liveness.start()\" \r\n class=\"btn-start\" \r\n [ngClass]=\"buttonClass\">\r\n Start Verification\r\n </button>\r\n \r\n <button *ngIf=\"state.status === 'error' || state.status === 'expired'\" \r\n (click)=\"liveness.reset()\" \r\n class=\"btn-retry\"\r\n [ngClass]=\"retryButtonClass\">\r\n Try Again\r\n </button>\r\n </div>\r\n\r\n </div>\r\n </ng-container>\r\n </div>\r\n</div>", styles: [".liveness-wrapper{position:relative;max-width:500px;margin:auto;font-family:sans-serif;color:#fff}.video-container{position:relative;width:100%;aspect-ratio:3/4;background:#000;border-radius:20px;overflow:hidden}video{width:100%;height:100%;object-fit:cover;transform:scaleX(-1)}.challenge-ui{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:space-between;padding:20px;background:#0003}.instruction-box h2{font-size:2.5rem;text-shadow:0 2px 10px rgba(0,0,0,.5);margin-top:0}.timer{font-size:1.5rem;background:#00000080;padding:5px 15px;border-radius:20px}.btn-start{padding:15px 40px;font-size:1.2rem;background:#28a745;color:#fff;border:none;border-radius:50px;cursor:pointer;transition:transform .2s}.btn-start:hover{transform:scale(1.05)}\n"] }]
|
|
362
|
+
}], ctorParameters: () => [{ type: LivenessService }], propDecorators: { webcamRef: [{
|
|
363
|
+
type: ViewChild,
|
|
364
|
+
args: ['webcam']
|
|
365
|
+
}], apiUrl: [{
|
|
366
|
+
type: Input
|
|
367
|
+
}], duration: [{
|
|
368
|
+
type: Input
|
|
369
|
+
}], smileThreshold: [{
|
|
370
|
+
type: Input
|
|
371
|
+
}], blinkThreshold: [{
|
|
372
|
+
type: Input
|
|
373
|
+
}], minturnHeadThreshold: [{
|
|
374
|
+
type: Input
|
|
375
|
+
}], maxturnHeadThreshold: [{
|
|
376
|
+
type: Input
|
|
377
|
+
}], containerClass: [{
|
|
378
|
+
type: Input
|
|
379
|
+
}], videoWrapperClass: [{
|
|
380
|
+
type: Input
|
|
381
|
+
}], videoClass: [{
|
|
382
|
+
type: Input
|
|
383
|
+
}], timerClass: [{
|
|
384
|
+
type: Input
|
|
385
|
+
}], challengeTextClass: [{
|
|
386
|
+
type: Input
|
|
387
|
+
}], buttonClass: [{
|
|
388
|
+
type: Input
|
|
389
|
+
}], retryButtonClass: [{
|
|
390
|
+
type: Input
|
|
391
|
+
}], statusOverlayClass: [{
|
|
392
|
+
type: Input
|
|
393
|
+
}], successMessageClass: [{
|
|
394
|
+
type: Input
|
|
395
|
+
}], errorMessageClass: [{
|
|
396
|
+
type: Input
|
|
397
|
+
}], instructionBoxClass: [{
|
|
398
|
+
type: Input
|
|
399
|
+
}], onComplete: [{
|
|
400
|
+
type: Output
|
|
401
|
+
}], onError: [{
|
|
402
|
+
type: Output
|
|
403
|
+
}] } });
|
|
404
|
+
|
|
405
|
+
// src/api.ts
|
|
406
|
+
/**
|
|
407
|
+
* Sends captured frames to the backend API for liveness verification.
|
|
408
|
+
* @param apiUrl - Base URL of the liveness backend
|
|
409
|
+
* @param frameBlobs - Array of Blob objects representing captured frames
|
|
410
|
+
* @param challenge - The current challenge string
|
|
411
|
+
* @returns LivenessResponse from backend
|
|
412
|
+
*/
|
|
413
|
+
const verifyLiveness = async (apiUrl, frameBlobs, challenge) => {
|
|
414
|
+
const formData = new FormData();
|
|
415
|
+
frameBlobs.forEach((blob, i) => {
|
|
416
|
+
formData.append('files', blob, `frame_${i}.jpg`);
|
|
417
|
+
});
|
|
418
|
+
formData.append('challenge', JSON.stringify(challenge));
|
|
419
|
+
const response = await fetch(`${apiUrl}/v1/verify`, {
|
|
420
|
+
method: 'POST',
|
|
421
|
+
body: formData,
|
|
422
|
+
});
|
|
423
|
+
if (!response.ok) {
|
|
424
|
+
throw new Error("Network response was not ok");
|
|
425
|
+
}
|
|
426
|
+
const data = await response.json();
|
|
427
|
+
return data;
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
var api = /*#__PURE__*/Object.freeze({
|
|
431
|
+
__proto__: null,
|
|
432
|
+
verifyLiveness: verifyLiveness
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Generated bundle index. Do not edit.
|
|
437
|
+
*/
|
|
438
|
+
|
|
439
|
+
export { LivenessComponent, LivenessEngine, LivenessService, verifyLiveness };
|
|
440
|
+
//# sourceMappingURL=richard.fadiora-liveness-detection.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"richard.fadiora-liveness-detection.mjs","sources":["../../../src/core/LivenessEngine.ts","../../../src/angular/liveness.service.ts","../../../src/angular/liveness.component.ts","../../../src/angular/liveness.component.html","../../../src/api.ts","../../../src/richard.fadiora-liveness-detection.ts"],"sourcesContent":["// src/core/LivenessEngine.ts\r\nimport { FaceLandmarker, HandLandmarker, FilesetResolver, NormalizedLandmark } from \"@mediapipe/tasks-vision\";\r\n\r\nexport type Challenge = \"Smile\" | \"Blink\" | \"Turn_Head\" | \"Thumbs_Up\";\r\n\r\nexport interface LivenessSDKResult {\r\n success: boolean;\r\n image?: string;\r\n reason?: string;\r\n skinConfidence?: number;\r\n}\r\n\r\nexport interface LivenessState {\r\n status: \"loading\" | \"ready\" | \"capturing\" | \"verifying\" | \"success\" | \"error\" | \"expired\";\r\n sequence: Challenge[];\r\n currentStep: number;\r\n timeLeft: number;\r\n isStepTransitioning: boolean;\r\n errorMsg: string;\r\n}\r\n\r\nexport interface LivenessEngineConfig {\r\n apiUrl: string;\r\n duration?: number;\r\n smileThreshold?: number;\r\n blinkThreshold?: number;\r\n minturnHeadThreshold?: number;\r\n maxturnHeadThreshold?: number;\r\n onStateChange?: (state: LivenessState) => void;\r\n onComplete?: (result: LivenessSDKResult) => void;\r\n onError?: (error: LivenessSDKResult) => void;\r\n}\r\n\r\nexport class LivenessEngine {\r\n private config: Required<LivenessEngineConfig>;\r\n private models: { face: FaceLandmarker | null; hand: HandLandmarker | null } = { face: null, hand: null };\r\n private webcam: HTMLVideoElement | null = null;\r\n private state: LivenessState;\r\n \r\n private currentStepRef = 0;\r\n private isStepTransitioningRef = false;\r\n private timerId: ReturnType<typeof setInterval> | null = null;\r\n private requestId: number | null = null;\r\n \r\n private offscreenCanvas = document.createElement(\"canvas\");\r\n private CHALLENGE_POOL: Challenge[] = [\"Smile\", \"Blink\", \"Turn_Head\", \"Thumbs_Up\"];\r\n\r\n constructor(config: LivenessEngineConfig) {\r\n this.config = {\r\n apiUrl: config.apiUrl,\r\n duration: config.duration ?? 60,\r\n smileThreshold: config.smileThreshold ?? 0.2,\r\n blinkThreshold: config.blinkThreshold ?? 0.012,\r\n minturnHeadThreshold: config.minturnHeadThreshold ?? 0.15,\r\n maxturnHeadThreshold: config.maxturnHeadThreshold ?? 0.85,\r\n onStateChange: config.onStateChange || (() => {}),\r\n onComplete: config.onComplete || (() => {}),\r\n onError: config.onError || (() => {}),\r\n };\r\n\r\n this.state = {\r\n status: \"loading\",\r\n sequence: [],\r\n currentStep: 0,\r\n timeLeft: this.config.duration,\r\n isStepTransitioning: false,\r\n errorMsg: \"\",\r\n };\r\n \r\n this.offscreenCanvas.width = 224;\r\n this.offscreenCanvas.height = 224;\r\n }\r\n\r\n /**\r\n * Phase 1: Load AI Assets (Call this immediately on mount)\r\n */\r\n public async loadModels() {\r\n console.log(\"[LivenessEngine] Loading AI Models...\");\r\n try {\r\n const vision = await FilesetResolver.forVisionTasks(\r\n \"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.3/wasm\"\r\n );\r\n\r\n this.models.face = await FaceLandmarker.createFromOptions(vision, {\r\n baseOptions: {\r\n modelAssetPath: \"https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task\",\r\n delegate: \"GPU\",\r\n },\r\n outputFaceBlendshapes: true,\r\n runningMode: \"VIDEO\",\r\n });\r\n\r\n this.models.hand = await HandLandmarker.createFromOptions(vision, {\r\n baseOptions: {\r\n modelAssetPath: \"https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task\",\r\n delegate: \"GPU\",\r\n },\r\n runningMode: \"VIDEO\",\r\n numHands: 1,\r\n });\r\n\r\n this.generateSequence();\r\n // Stay in 'loading' or move to a 'ready_to_mount' status if you prefer\r\n // For now, we move to ready to signal the UI can show the camera\r\n this.updateState({ status: \"ready\" });\r\n console.log(\"[LivenessEngine] Models Loaded.\");\r\n } catch (err) {\r\n console.error(\"[LivenessEngine] Init Error:\", err);\r\n this.updateState({ status: \"error\", errorMsg: \"Failed to load security assets.\" });\r\n }\r\n }\r\n\r\n /**\r\n * Phase 2: Attach Video (Call this once the Webcam is in the DOM)\r\n */\r\n public attachVideo = (video: HTMLVideoElement) => {\r\n this.webcam = video;\r\n console.log(\"[LivenessEngine] Video stream attached.\");\r\n };\r\n\r\n public start = () => {\r\n if (this.state.status !== \"ready\" || !this.webcam) {\r\n console.warn(\"[LivenessEngine] Engine not ready or video missing.\");\r\n return;\r\n }\r\n\r\n console.log(\"[LivenessEngine] Session Starting...\");\r\n this.state.status = \"capturing\"; // Sync internal status immediately\r\n this.updateState({ status: \"capturing\" });\r\n this.startTimer();\r\n this.detectLoop();\r\n };\r\n\r\n public stop = () => {\r\n if (this.timerId) clearInterval(this.timerId);\r\n if (this.requestId) cancelAnimationFrame(this.requestId);\r\n this.timerId = null;\r\n this.requestId = null;\r\n };\r\n\r\n public reset = () => {\r\n this.stop();\r\n this.currentStepRef = 0;\r\n this.isStepTransitioningRef = false;\r\n this.generateSequence();\r\n this.updateState({\r\n status: \"ready\",\r\n currentStep: 0,\r\n timeLeft: this.config.duration,\r\n isStepTransitioning: false,\r\n errorMsg: \"\",\r\n });\r\n };\r\n\r\n private updateState(newState: Partial<LivenessState>) {\r\n this.state = { ...this.state, ...newState };\r\n this.config.onStateChange(this.state);\r\n }\r\n\r\n private generateSequence(): Challenge[] {\r\n const seq = [...this.CHALLENGE_POOL].sort(() => 0.5 - Math.random()).slice(0, 3);\r\n this.state.sequence = seq;\r\n return seq;\r\n }\r\n\r\n private startTimer() {\r\n if (this.timerId) clearInterval(this.timerId);\r\n this.timerId = setInterval(() => {\r\n if (this.state.timeLeft > 0) {\r\n this.updateState({ timeLeft: this.state.timeLeft - 1 });\r\n } else {\r\n this.stop();\r\n this.updateState({ status: \"expired\" });\r\n }\r\n }, 1000);\r\n }\r\n\r\n private detectLoop = () => {\r\n if (this.state.status !== \"capturing\" || !this.webcam || !this.models.face) return;\r\n\r\n const now = performance.now();\r\n try {\r\n const faceRes = this.models.face.detectForVideo(this.webcam, now);\r\n const handRes = this.models.hand?.detectForVideo(this.webcam, now);\r\n\r\n const currentChallenge = this.state.sequence[this.currentStepRef];\r\n\r\n if (this.checkAction(faceRes, handRes, currentChallenge)) {\r\n this.handleStepSuccess();\r\n return; // handleStepSuccess schedules the next frame after a delay\r\n }\r\n } catch (err) {\r\n console.error(\"[LivenessEngine] Loop detection error:\", err);\r\n }\r\n\r\n this.requestId = requestAnimationFrame(this.detectLoop);\r\n };\r\n\r\n private handleStepSuccess() {\r\n if (this.isStepTransitioningRef) return;\r\n \r\n this.isStepTransitioningRef = true;\r\n this.updateState({ isStepTransitioning: true });\r\n\r\n setTimeout(() => {\r\n if (this.currentStepRef < this.state.sequence.length - 1) {\r\n this.currentStepRef++;\r\n this.isStepTransitioningRef = false;\r\n this.updateState({\r\n currentStep: this.currentStepRef,\r\n isStepTransitioning: false,\r\n });\r\n this.requestId = requestAnimationFrame(this.detectLoop);\r\n } else {\r\n this.sendFinalProof();\r\n }\r\n }, 1500);\r\n }\r\n\r\n private checkAction(faceRes: any, handRes: any, challenge: Challenge): boolean {\r\n if (this.isStepTransitioningRef) return false;\r\n\r\n if (challenge === \"Thumbs_Up\" && handRes?.landmarks?.length > 0) {\r\n const l = handRes.landmarks[0];\r\n return l[4].y < l[2].y && [8, 12, 16, 20].every(i => l[i].y > l[i - 2].y);\r\n }\r\n\r\n if (faceRes?.faceLandmarks?.length > 0) {\r\n const lms: NormalizedLandmark[] = faceRes.faceLandmarks[0];\r\n switch (challenge) {\r\n case \"Smile\":\r\n return (lms[291].x - lms[61].x) > this.config.smileThreshold;\r\n case \"Blink\":\r\n const leftEar = Math.abs(lms[159].y - lms[145].y);\r\n const rightEar = Math.abs(lms[386].y - lms[374].y);\r\n return ((leftEar + rightEar) / 2) < this.config.blinkThreshold;\r\n case \"Turn_Head\":\r\n const yaw = (lms[1].x - lms[33].x) / (lms[263].x - lms[33].x);\r\n return yaw < this.config.minturnHeadThreshold || yaw > this.config.maxturnHeadThreshold;\r\n default: return false;\r\n }\r\n }\r\n return false;\r\n }\r\n\r\n private async sendFinalProof() {\r\n this.stop();\r\n this.updateState({ status: \"verifying\" });\r\n\r\n try {\r\n const blobs: Blob[] = [];\r\n let finalBase64 = \"\";\r\n\r\n for (let i = 0; i < 5; i++) {\r\n if (!this.webcam) break;\r\n const faceRes = this.models.face?.detectForVideo(this.webcam, performance.now());\r\n if (faceRes?.faceLandmarks?.[0]) {\r\n const canvas = this.getFaceCrop(this.webcam, faceRes.faceLandmarks[0]);\r\n const blob = await new Promise<Blob | null>(res => canvas.toBlob(res, \"image/jpeg\", 0.9));\r\n if (blob) blobs.push(blob);\r\n if (i === 4) finalBase64 = canvas.toDataURL(\"image/jpeg\");\r\n }\r\n await new Promise(r => setTimeout(r, 100));\r\n }\r\n\r\n const { verifyLiveness } = await import(\"../api\");\r\n const result = await verifyLiveness(this.config.apiUrl, blobs, this.state.sequence);\r\n\r\n if (result.is_live) {\r\n this.updateState({ status: \"success\" });\r\n this.config.onComplete({ success: true, image: finalBase64, skinConfidence: result.skin_confidence });\r\n } else {\r\n const reason = result.reason || \"Liveness check failed\";\r\n this.updateState({ status: \"error\", errorMsg: reason });\r\n this.config.onError({ success: false, reason });\r\n }\r\n } catch (err) {\r\n this.updateState({ status: \"error\", errorMsg: \"Verification failed.\" });\r\n this.config.onError({ success: false, reason: \"Network error\" });\r\n }\r\n }\r\n\r\n private getFaceCrop(video: HTMLVideoElement, landmarks: NormalizedLandmark[]): HTMLCanvasElement {\r\n const ctx = this.offscreenCanvas.getContext(\"2d\")!;\r\n const xs = landmarks.map(l => l.x * video.videoWidth);\r\n const ys = landmarks.map(l => l.y * video.videoHeight);\r\n \r\n const minX = Math.min(...xs);\r\n const maxX = Math.max(...xs);\r\n const minY = Math.min(...ys);\r\n const maxY = Math.max(...ys);\r\n \r\n const width = maxX - minX;\r\n const height = maxY - minY;\r\n const margin = width * 0.3;\r\n\r\n ctx.clearRect(0, 0, 224, 224);\r\n ctx.drawImage(\r\n video,\r\n minX - margin, minY - margin, \r\n width + margin * 2, height + margin * 2,\r\n 0, 0, 224, 224\r\n );\r\n\r\n return this.offscreenCanvas;\r\n }\r\n}","import { Injectable, NgZone, OnDestroy } from '@angular/core';\r\nimport { BehaviorSubject, Observable } from 'rxjs';\r\nimport { LivenessEngine, LivenessState, LivenessEngineConfig } from '../core/LivenessEngine';\r\n\r\n@Injectable({ providedIn: 'root' })\r\nexport class LivenessService implements OnDestroy {\r\n private engine!: LivenessEngine;\r\n private stateSubject = new BehaviorSubject<LivenessState | null>(null);\r\n \r\n // Components will subscribe to this\r\n public state$: Observable<LivenessState | null> = this.stateSubject.asObservable();\r\n\r\n constructor(private ngZone: NgZone) {}\r\n\r\n public init(config: LivenessEngineConfig) {\r\n // Run inside runOutsideAngular to prevent 60fps change detection triggers\r\n this.ngZone.runOutsideAngular(() => {\r\n this.engine = new LivenessEngine({\r\n ...config,\r\n onStateChange: (state) => {\r\n // Bring state changes BACK into the zone so the UI updates\r\n this.ngZone.run(() => this.stateSubject.next(state));\r\n }\r\n });\r\n this.engine.loadModels();\r\n });\r\n }\r\n\r\n public attach(video: HTMLVideoElement) {\r\n this.engine?.attachVideo(video);\r\n }\r\n\r\n public start() {\r\n this.engine?.start();\r\n }\r\n\r\n public reset() {\r\n this.engine?.reset();\r\n }\r\n\r\n ngOnDestroy() {\r\n this.engine?.stop();\r\n }\r\n}","import { Component, ElementRef, ViewChild, AfterViewInit, Input, Output, EventEmitter, OnDestroy, OnInit } from '@angular/core';\r\nimport { LivenessService } from './liveness.service';\r\nimport { LivenessSDKResult, Challenge } from '../core/LivenessEngine';\r\nimport { CommonModule } from '@angular/common'\r\n\r\n@Component({\r\n selector: 'LivenessCheck',\r\n standalone: true, // Make it standalone\r\n imports: [CommonModule], // Add CommonModule here to fix NG8002\r\n templateUrl: './liveness.component.html',\r\n styleUrls: ['./liveness.component.css']\r\n})\r\nexport class LivenessComponent implements OnInit, AfterViewInit, OnDestroy {\r\n @ViewChild('webcam') webcamRef!: ElementRef<HTMLVideoElement>;\r\n\r\n // --- Logic Configuration ---\r\n @Input() apiUrl!: string;\r\n @Input() duration = 60;\r\n @Input() smileThreshold?: number;\r\n @Input() blinkThreshold?: number;\r\n @Input() minturnHeadThreshold?: number;\r\n @Input() maxturnHeadThreshold?: number;\r\n\r\n // --- Styling Configuration (Class Names) ---\r\n @Input() containerClass = '';\r\n @Input() videoWrapperClass = '';\r\n @Input() videoClass = '';\r\n @Input() timerClass = '';\r\n @Input() challengeTextClass = '';\r\n @Input() buttonClass = '';\r\n @Input() retryButtonClass = '';\r\n @Input() statusOverlayClass = '';\r\n @Input() successMessageClass = '';\r\n @Input() errorMessageClass = '';\r\n @Input() instructionBoxClass = '';\r\n\r\n @Output() onComplete = new EventEmitter<LivenessSDKResult>();\r\n @Output() onError = new EventEmitter<any>();\r\n\r\n constructor(public liveness: LivenessService) {}\r\n\r\n ngOnInit() {\r\n // Pass the custom thresholds directly to the service\r\n this.liveness.init({\r\n apiUrl: this.apiUrl,\r\n duration: this.duration,\r\n smileThreshold: this.smileThreshold,\r\n blinkThreshold: this.blinkThreshold,\r\n minturnHeadThreshold: this.minturnHeadThreshold,\r\n maxturnHeadThreshold: this.maxturnHeadThreshold,\r\n onComplete: (res) => this.onComplete.emit(res),\r\n onError: (err) => this.onError.emit(err)\r\n });\r\n }\r\n\r\n async ngAfterViewInit() {\r\n try {\r\n const stream = await navigator.mediaDevices.getUserMedia({ \r\n video: { width: 640, height: 480, facingMode: 'user' } \r\n });\r\n const video = this.webcamRef.nativeElement;\r\n video.srcObject = stream;\r\n video.onloadedmetadata = () => {\r\n video.play();\r\n this.liveness.attach(video);\r\n };\r\n } catch (e) {\r\n this.onError.emit({ success: false, reason: 'Camera access denied' });\r\n }\r\n }\r\n\r\n ngOnDestroy() {\r\n const stream = this.webcamRef?.nativeElement?.srcObject as MediaStream;\r\n stream?.getTracks().forEach(track => track.stop());\r\n }\r\n}","<div class=\"liveness-sdk-container\" [ngClass]=\"containerClass\">\r\n <div class=\"video-window\" [ngClass]=\"videoWrapperClass\">\r\n <video #webcam \r\n autoplay \r\n playsinline \r\n muted \r\n [ngClass]=\"videoClass\" \r\n style=\"transform: scaleX(-1);\">\r\n </video>\r\n\r\n <ng-container *ngIf=\"(liveness.state$ | async) as state\">\r\n <div class=\"status-overlay\" [ngClass]=\"statusOverlayClass\">\r\n \r\n <div class=\"instruction-box\">\r\n <h2 class=\"status-text\">{{ state.status | uppercase }}</h2>\r\n \r\n <p *ngIf=\"state.status === 'capturing'\" \r\n class=\"challenge-text\" \r\n [ngClass]=\"challengeTextClass\">\r\n Challenge: {{ state.sequence[state.currentStep] }}\r\n </p>\r\n\r\n <p *ngIf=\"state.status === 'success'\" \r\n class=\"success-msg\" \r\n [ngClass]=\"successMessageClass\">\r\n Verification Successful!\r\n </p>\r\n \r\n <p *ngIf=\"state.status === 'error' || state.status === 'expired'\" \r\n class=\"error-msg\" \r\n [ngClass]=\"errorMessageClass\">\r\n {{ state.errorMsg || 'Verification failed. Please try again.' }}\r\n </p>\r\n </div>\r\n\r\n <div *ngIf=\"state.status === 'capturing'\" \r\n class=\"timer-ring\" \r\n [ngClass]=\"timerClass\">\r\n {{ state.timeLeft }}s\r\n </div>\r\n\r\n <div class=\"action-area\">\r\n <button *ngIf=\"state.status === 'ready'\" \r\n (click)=\"liveness.start()\" \r\n class=\"btn-start\" \r\n [ngClass]=\"buttonClass\">\r\n Start Verification\r\n </button>\r\n \r\n <button *ngIf=\"state.status === 'error' || state.status === 'expired'\" \r\n (click)=\"liveness.reset()\" \r\n class=\"btn-retry\"\r\n [ngClass]=\"retryButtonClass\">\r\n Try Again\r\n </button>\r\n </div>\r\n\r\n </div>\r\n </ng-container>\r\n </div>\r\n</div>","// src/api.ts\r\n\r\nimport { Challenge } from \"./core/LivenessEngine\";\r\n\r\nexport interface LivenessResponse {\r\n is_live: boolean;\r\n reason?: string;\r\n skin_confidence?: number;\r\n [key: string]: any; // allow extra fields if backend returns more\r\n}\r\n\r\n/**\r\n * Sends captured frames to the backend API for liveness verification.\r\n * @param apiUrl - Base URL of the liveness backend\r\n * @param frameBlobs - Array of Blob objects representing captured frames\r\n * @param challenge - The current challenge string\r\n * @returns LivenessResponse from backend\r\n */\r\nexport const verifyLiveness = async (\r\n apiUrl: string,\r\n frameBlobs: Blob[],\r\n challenge: Challenge[]\r\n): Promise<LivenessResponse> => {\r\n const formData = new FormData();\r\n\r\n frameBlobs.forEach((blob, i) => {\r\n formData.append('files', blob, `frame_${i}.jpg`);\r\n });\r\n formData.append('challenge', JSON.stringify(challenge));\r\n\r\n const response = await fetch(`${apiUrl}/v1/verify`, {\r\n method: 'POST',\r\n body: formData,\r\n });\r\n\r\n if (!response.ok) {\r\n throw new Error(\"Network response was not ok\");\r\n }\r\n\r\n const data: LivenessResponse = await response.json();\r\n return data;\r\n};","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":["i1.LivenessService"],"mappings":"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"}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { OnDestroy, NgZone, OnInit, AfterViewInit, ElementRef, EventEmitter } from '@angular/core';
|
|
3
|
+
import { Observable } from 'rxjs';
|
|
4
|
+
|
|
5
|
+
type Challenge = "Smile" | "Blink" | "Turn_Head" | "Thumbs_Up";
|
|
6
|
+
interface LivenessSDKResult {
|
|
7
|
+
success: boolean;
|
|
8
|
+
image?: string;
|
|
9
|
+
reason?: string;
|
|
10
|
+
skinConfidence?: number;
|
|
11
|
+
}
|
|
12
|
+
interface LivenessState {
|
|
13
|
+
status: "loading" | "ready" | "capturing" | "verifying" | "success" | "error" | "expired";
|
|
14
|
+
sequence: Challenge[];
|
|
15
|
+
currentStep: number;
|
|
16
|
+
timeLeft: number;
|
|
17
|
+
isStepTransitioning: boolean;
|
|
18
|
+
errorMsg: string;
|
|
19
|
+
}
|
|
20
|
+
interface LivenessEngineConfig {
|
|
21
|
+
apiUrl: string;
|
|
22
|
+
duration?: number;
|
|
23
|
+
smileThreshold?: number;
|
|
24
|
+
blinkThreshold?: number;
|
|
25
|
+
minturnHeadThreshold?: number;
|
|
26
|
+
maxturnHeadThreshold?: number;
|
|
27
|
+
onStateChange?: (state: LivenessState) => void;
|
|
28
|
+
onComplete?: (result: LivenessSDKResult) => void;
|
|
29
|
+
onError?: (error: LivenessSDKResult) => void;
|
|
30
|
+
}
|
|
31
|
+
declare class LivenessEngine {
|
|
32
|
+
private config;
|
|
33
|
+
private models;
|
|
34
|
+
private webcam;
|
|
35
|
+
private state;
|
|
36
|
+
private currentStepRef;
|
|
37
|
+
private isStepTransitioningRef;
|
|
38
|
+
private timerId;
|
|
39
|
+
private requestId;
|
|
40
|
+
private offscreenCanvas;
|
|
41
|
+
private CHALLENGE_POOL;
|
|
42
|
+
constructor(config: LivenessEngineConfig);
|
|
43
|
+
/**
|
|
44
|
+
* Phase 1: Load AI Assets (Call this immediately on mount)
|
|
45
|
+
*/
|
|
46
|
+
loadModels(): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* Phase 2: Attach Video (Call this once the Webcam is in the DOM)
|
|
49
|
+
*/
|
|
50
|
+
attachVideo: (video: HTMLVideoElement) => void;
|
|
51
|
+
start: () => void;
|
|
52
|
+
stop: () => void;
|
|
53
|
+
reset: () => void;
|
|
54
|
+
private updateState;
|
|
55
|
+
private generateSequence;
|
|
56
|
+
private startTimer;
|
|
57
|
+
private detectLoop;
|
|
58
|
+
private handleStepSuccess;
|
|
59
|
+
private checkAction;
|
|
60
|
+
private sendFinalProof;
|
|
61
|
+
private getFaceCrop;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
declare class LivenessService implements OnDestroy {
|
|
65
|
+
private ngZone;
|
|
66
|
+
private engine;
|
|
67
|
+
private stateSubject;
|
|
68
|
+
state$: Observable<LivenessState | null>;
|
|
69
|
+
constructor(ngZone: NgZone);
|
|
70
|
+
init(config: LivenessEngineConfig): void;
|
|
71
|
+
attach(video: HTMLVideoElement): void;
|
|
72
|
+
start(): void;
|
|
73
|
+
reset(): void;
|
|
74
|
+
ngOnDestroy(): void;
|
|
75
|
+
static ɵfac: i0.ɵɵFactoryDeclaration<LivenessService, never>;
|
|
76
|
+
static ɵprov: i0.ɵɵInjectableDeclaration<LivenessService>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
declare class LivenessComponent implements OnInit, AfterViewInit, OnDestroy {
|
|
80
|
+
liveness: LivenessService;
|
|
81
|
+
webcamRef: ElementRef<HTMLVideoElement>;
|
|
82
|
+
apiUrl: string;
|
|
83
|
+
duration: number;
|
|
84
|
+
smileThreshold?: number;
|
|
85
|
+
blinkThreshold?: number;
|
|
86
|
+
minturnHeadThreshold?: number;
|
|
87
|
+
maxturnHeadThreshold?: number;
|
|
88
|
+
containerClass: string;
|
|
89
|
+
videoWrapperClass: string;
|
|
90
|
+
videoClass: string;
|
|
91
|
+
timerClass: string;
|
|
92
|
+
challengeTextClass: string;
|
|
93
|
+
buttonClass: string;
|
|
94
|
+
retryButtonClass: string;
|
|
95
|
+
statusOverlayClass: string;
|
|
96
|
+
successMessageClass: string;
|
|
97
|
+
errorMessageClass: string;
|
|
98
|
+
instructionBoxClass: string;
|
|
99
|
+
onComplete: EventEmitter<LivenessSDKResult>;
|
|
100
|
+
onError: EventEmitter<any>;
|
|
101
|
+
constructor(liveness: LivenessService);
|
|
102
|
+
ngOnInit(): void;
|
|
103
|
+
ngAfterViewInit(): Promise<void>;
|
|
104
|
+
ngOnDestroy(): void;
|
|
105
|
+
static ɵfac: i0.ɵɵFactoryDeclaration<LivenessComponent, never>;
|
|
106
|
+
static ɵcmp: i0.ɵɵComponentDeclaration<LivenessComponent, "LivenessCheck", never, { "apiUrl": { "alias": "apiUrl"; "required": false; }; "duration": { "alias": "duration"; "required": false; }; "smileThreshold": { "alias": "smileThreshold"; "required": false; }; "blinkThreshold": { "alias": "blinkThreshold"; "required": false; }; "minturnHeadThreshold": { "alias": "minturnHeadThreshold"; "required": false; }; "maxturnHeadThreshold": { "alias": "maxturnHeadThreshold"; "required": false; }; "containerClass": { "alias": "containerClass"; "required": false; }; "videoWrapperClass": { "alias": "videoWrapperClass"; "required": false; }; "videoClass": { "alias": "videoClass"; "required": false; }; "timerClass": { "alias": "timerClass"; "required": false; }; "challengeTextClass": { "alias": "challengeTextClass"; "required": false; }; "buttonClass": { "alias": "buttonClass"; "required": false; }; "retryButtonClass": { "alias": "retryButtonClass"; "required": false; }; "statusOverlayClass": { "alias": "statusOverlayClass"; "required": false; }; "successMessageClass": { "alias": "successMessageClass"; "required": false; }; "errorMessageClass": { "alias": "errorMessageClass"; "required": false; }; "instructionBoxClass": { "alias": "instructionBoxClass"; "required": false; }; }, { "onComplete": "onComplete"; "onError": "onError"; }, never, never, true, never>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface LivenessResponse {
|
|
110
|
+
is_live: boolean;
|
|
111
|
+
reason?: string;
|
|
112
|
+
skin_confidence?: number;
|
|
113
|
+
[key: string]: any;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Sends captured frames to the backend API for liveness verification.
|
|
117
|
+
* @param apiUrl - Base URL of the liveness backend
|
|
118
|
+
* @param frameBlobs - Array of Blob objects representing captured frames
|
|
119
|
+
* @param challenge - The current challenge string
|
|
120
|
+
* @returns LivenessResponse from backend
|
|
121
|
+
*/
|
|
122
|
+
declare const verifyLiveness: (apiUrl: string, frameBlobs: Blob[], challenge: Challenge[]) => Promise<LivenessResponse>;
|
|
123
|
+
|
|
124
|
+
export { LivenessComponent, LivenessEngine, LivenessService, verifyLiveness };
|
|
125
|
+
export type { Challenge, LivenessEngineConfig, LivenessResponse, LivenessSDKResult, LivenessState };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@richard.fadiora/liveness-detection",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.13",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -11,15 +11,21 @@
|
|
|
11
11
|
"types": "./dist/index.d.ts",
|
|
12
12
|
"exports": {
|
|
13
13
|
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
14
15
|
"import": "./dist/index.es.js",
|
|
15
|
-
"require": "./dist/index.umd.js"
|
|
16
|
-
|
|
16
|
+
"require": "./dist/index.umd.js"
|
|
17
|
+
},
|
|
18
|
+
"./angular": {
|
|
19
|
+
"types": "./dist/angular/public-api.d.ts",
|
|
20
|
+
"import": "./dist/angular/fesm2022/richard.fadiora-liveness-detection.mjs",
|
|
21
|
+
"default": "./dist/angular/fesm2022/richard.fadiora-liveness-detection.mjs"
|
|
17
22
|
}
|
|
18
23
|
},
|
|
19
24
|
"scripts": {
|
|
20
|
-
"
|
|
21
|
-
"build": "
|
|
22
|
-
"
|
|
25
|
+
"build:react": "vite build",
|
|
26
|
+
"build:angular": "ng-packagr -p ng-package.json",
|
|
27
|
+
"build": "npm run build:react && npm run build:angular",
|
|
28
|
+
"prepublishOnly": "npm run build"
|
|
23
29
|
},
|
|
24
30
|
"peerDependencies": {
|
|
25
31
|
"@angular/common": ">=21.0.0",
|
|
@@ -39,6 +45,7 @@
|
|
|
39
45
|
"@types/react": "^18.0.0",
|
|
40
46
|
"@types/react-dom": "^18.0.0",
|
|
41
47
|
"@vitejs/plugin-react": "^4.0.0",
|
|
48
|
+
"ng-packagr": "^21.2.0",
|
|
42
49
|
"vite": "^5.0.0",
|
|
43
50
|
"vite-plugin-dts": "^3.6.0"
|
|
44
51
|
},
|