@moveris/shared 0.0.1 → 1.0.0
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/dist/index.d.mts +460 -109
- package/dist/index.d.ts +460 -109
- package/dist/index.js +1082 -205
- package/dist/index.mjs +1016 -200
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -1,44 +1,74 @@
|
|
|
1
1
|
// src/constants/config.ts
|
|
2
2
|
var API_ENDPOINTS = {
|
|
3
|
-
production: "
|
|
4
|
-
staging: "
|
|
5
|
-
development: "
|
|
3
|
+
production: "https://api.moveris.com",
|
|
4
|
+
staging: "https://moveris-api-v2-staging.fly.dev",
|
|
5
|
+
development: "http://localhost:8000"
|
|
6
6
|
};
|
|
7
7
|
var DEFAULT_ENDPOINT = API_ENDPOINTS.production;
|
|
8
|
-
var
|
|
9
|
-
|
|
8
|
+
var API_PATHS = {
|
|
9
|
+
health: "/health",
|
|
10
|
+
fastCheck: "/api/v1/fast-check",
|
|
11
|
+
fastCheckCrops: "/api/v1/fast-check-crops",
|
|
12
|
+
verify: "/api/v1/verify",
|
|
13
|
+
hybridCheck: "/api/v1/hybrid-check",
|
|
14
|
+
hybrid50: "/api/v1/hybrid-50",
|
|
15
|
+
hybrid150: "/api/v1/hybrid-150",
|
|
16
|
+
jobResult: "/api/v1/result",
|
|
17
|
+
queueStats: "/api/v1/queue/stats"
|
|
18
|
+
};
|
|
19
|
+
var RETRY_CONFIG = {
|
|
20
|
+
maxAttempts: 3,
|
|
10
21
|
initialDelay: 1e3,
|
|
11
22
|
// 1 second
|
|
12
|
-
maxDelay:
|
|
13
|
-
//
|
|
23
|
+
maxDelay: 1e4,
|
|
24
|
+
// 10 seconds
|
|
14
25
|
backoffMultiplier: 2
|
|
15
26
|
};
|
|
16
27
|
var FRAME_BUFFER_CONFIG = {
|
|
17
28
|
maxSize: 10,
|
|
18
|
-
// Maximum
|
|
29
|
+
// Maximum frames to keep in buffer
|
|
19
30
|
maxMemory: 4 * 1024 * 1024
|
|
20
31
|
// 4 MB
|
|
21
32
|
};
|
|
22
33
|
var AUTH_CONFIG = {
|
|
23
|
-
timeout:
|
|
24
|
-
//
|
|
25
|
-
|
|
34
|
+
timeout: 3e4,
|
|
35
|
+
// 30 seconds for API requests
|
|
36
|
+
apiKeyHeader: "X-API-Key"
|
|
26
37
|
};
|
|
27
38
|
var FRAME_CONFIG = {
|
|
28
39
|
targetFPS: 30,
|
|
29
40
|
frameInterval: 33,
|
|
30
41
|
// milliseconds (1000 / 30)
|
|
31
|
-
jpegQuality: 0.8
|
|
42
|
+
jpegQuality: 0.8,
|
|
32
43
|
// 80%
|
|
44
|
+
defaultModel: "10",
|
|
45
|
+
defaultSource: "live"
|
|
46
|
+
};
|
|
47
|
+
var DEFAULT_LIVENESS_CONFIG = {
|
|
48
|
+
model: FRAME_CONFIG.defaultModel,
|
|
49
|
+
source: FRAME_CONFIG.defaultSource,
|
|
50
|
+
fps: FRAME_CONFIG.targetFPS,
|
|
51
|
+
timeout: AUTH_CONFIG.timeout
|
|
33
52
|
};
|
|
34
53
|
|
|
35
54
|
// src/utils/retry.ts
|
|
55
|
+
function defaultIsRetryable(error) {
|
|
56
|
+
if (error instanceof Error) {
|
|
57
|
+
if ("statusCode" in error) {
|
|
58
|
+
const statusCode = error.statusCode;
|
|
59
|
+
return statusCode === 0 || statusCode === 408 || statusCode === 429 || statusCode >= 500;
|
|
60
|
+
}
|
|
61
|
+
return error.name === "AbortError" || error.message.includes("network") || error.message.includes("fetch");
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
36
65
|
async function retryWithBackoff(fn, options = {}) {
|
|
37
66
|
const {
|
|
38
|
-
maxAttempts =
|
|
39
|
-
initialDelay =
|
|
40
|
-
maxDelay =
|
|
41
|
-
backoffMultiplier =
|
|
67
|
+
maxAttempts = RETRY_CONFIG.maxAttempts,
|
|
68
|
+
initialDelay = RETRY_CONFIG.initialDelay,
|
|
69
|
+
maxDelay = RETRY_CONFIG.maxDelay,
|
|
70
|
+
backoffMultiplier = RETRY_CONFIG.backoffMultiplier,
|
|
71
|
+
isRetryable = defaultIsRetryable
|
|
42
72
|
} = options;
|
|
43
73
|
let lastError;
|
|
44
74
|
let delay = initialDelay;
|
|
@@ -47,6 +77,9 @@ async function retryWithBackoff(fn, options = {}) {
|
|
|
47
77
|
return await fn();
|
|
48
78
|
} catch (error) {
|
|
49
79
|
lastError = error;
|
|
80
|
+
if (!isRetryable(error)) {
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
50
83
|
if (attempt < maxAttempts - 1) {
|
|
51
84
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
52
85
|
delay = Math.min(delay * backoffMultiplier, maxDelay);
|
|
@@ -55,189 +88,294 @@ async function retryWithBackoff(fn, options = {}) {
|
|
|
55
88
|
}
|
|
56
89
|
throw lastError;
|
|
57
90
|
}
|
|
91
|
+
async function sleep(ms) {
|
|
92
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
93
|
+
}
|
|
58
94
|
|
|
59
|
-
// src/client/
|
|
60
|
-
var
|
|
61
|
-
constructor(
|
|
62
|
-
|
|
63
|
-
this.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
try {
|
|
70
|
-
await retryWithBackoff(
|
|
71
|
-
async () => {
|
|
72
|
-
await this.config.onReconnect();
|
|
73
|
-
this.reconnectAttempts = 0;
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
maxAttempts: this.config.maxAttempts ?? 10
|
|
77
|
-
}
|
|
78
|
-
);
|
|
79
|
-
} catch (error) {
|
|
80
|
-
this.reconnectAttempts++;
|
|
81
|
-
throw error;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
reset() {
|
|
85
|
-
this.reconnectAttempts = 0;
|
|
95
|
+
// src/client/LivenessClient.ts
|
|
96
|
+
var LivenessApiError = class extends Error {
|
|
97
|
+
constructor(message, code, statusCode, required, received) {
|
|
98
|
+
super(message);
|
|
99
|
+
this.name = "LivenessApiError";
|
|
100
|
+
this.code = code;
|
|
101
|
+
this.statusCode = statusCode;
|
|
102
|
+
this.required = required;
|
|
103
|
+
this.received = received;
|
|
86
104
|
}
|
|
87
105
|
};
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
return AUTH_CONFIG.apiKeyPattern.test(apiKey);
|
|
106
|
+
function toFrameData(frames) {
|
|
107
|
+
return frames.map((frame) => ({
|
|
108
|
+
index: frame.index,
|
|
109
|
+
timestamp_ms: frame.timestampMs,
|
|
110
|
+
pixels: frame.pixels
|
|
111
|
+
}));
|
|
95
112
|
}
|
|
96
|
-
function
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
return base64Pattern.test(data) && data.length > 0;
|
|
113
|
+
function toHybridFrameData(frames) {
|
|
114
|
+
return frames.map((frame) => ({
|
|
115
|
+
timestamp_ms: frame.timestampMs,
|
|
116
|
+
pixels: frame.pixels
|
|
117
|
+
}));
|
|
102
118
|
}
|
|
103
|
-
function
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
// src/types/models.ts
|
|
108
|
-
var MODEL_CONFIGS = {
|
|
109
|
-
"50-frame": {
|
|
110
|
-
type: "50-frame",
|
|
111
|
-
fileName: "model_50.h5",
|
|
112
|
-
frameCount: 50
|
|
113
|
-
},
|
|
114
|
-
"250-frame": {
|
|
115
|
-
type: "250-frame",
|
|
116
|
-
fileName: "model_250.h5",
|
|
117
|
-
frameCount: 250
|
|
119
|
+
function toLivenessResult(response) {
|
|
120
|
+
if (!response.verdict) {
|
|
121
|
+
throw new LivenessApiError(response.error ?? "No verdict received", "no_verdict", 500);
|
|
118
122
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return new Promise((resolve, reject) => {
|
|
132
|
-
const timeout = setTimeout(() => {
|
|
133
|
-
reject(new Error("Authentication timeout"));
|
|
134
|
-
}, AUTH_CONFIG.timeout);
|
|
135
|
-
const messageHandler = (event) => {
|
|
136
|
-
try {
|
|
137
|
-
const message = JSON.parse(event.data);
|
|
138
|
-
if (message.type === "auth_success") {
|
|
139
|
-
clearTimeout(timeout);
|
|
140
|
-
ws.removeEventListener("message", messageHandler);
|
|
141
|
-
resolve(message.request_id);
|
|
142
|
-
} else if (message.type === "auth_error") {
|
|
143
|
-
clearTimeout(timeout);
|
|
144
|
-
ws.removeEventListener("message", messageHandler);
|
|
145
|
-
reject(new Error(message.error));
|
|
146
|
-
}
|
|
147
|
-
} catch (error) {
|
|
148
|
-
}
|
|
149
|
-
};
|
|
150
|
-
ws.addEventListener("message", messageHandler);
|
|
151
|
-
ws.send(
|
|
152
|
-
JSON.stringify({
|
|
153
|
-
type: "auth",
|
|
154
|
-
token: apiKey,
|
|
155
|
-
model_file_name: MODEL_CONFIGS[modelType].fileName
|
|
156
|
-
})
|
|
157
|
-
);
|
|
158
|
-
});
|
|
123
|
+
return {
|
|
124
|
+
verdict: response.verdict,
|
|
125
|
+
confidence: response.confidence ?? 0,
|
|
126
|
+
score: response.score ?? 0,
|
|
127
|
+
sessionId: response.session_id,
|
|
128
|
+
processingMs: response.processing_ms,
|
|
129
|
+
framesProcessed: "frames_processed" in response ? response.frames_processed ?? 0 : 0
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function generateSessionId() {
|
|
133
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
134
|
+
return crypto.randomUUID();
|
|
159
135
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
136
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
137
|
+
const r = Math.random() * 16 | 0;
|
|
138
|
+
const v = c === "x" ? r : r & 3 | 8;
|
|
139
|
+
return v.toString(16);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
var LivenessClient = class {
|
|
164
143
|
constructor(config) {
|
|
165
|
-
this.
|
|
166
|
-
this.
|
|
167
|
-
this.
|
|
168
|
-
this.
|
|
169
|
-
|
|
170
|
-
});
|
|
171
|
-
this.authManager = new AuthManager();
|
|
144
|
+
this.baseUrl = (config.baseUrl ?? DEFAULT_ENDPOINT).replace(/\/$/, "");
|
|
145
|
+
this.apiKey = config.apiKey;
|
|
146
|
+
this.timeout = config.timeout ?? AUTH_CONFIG.timeout;
|
|
147
|
+
this.enableRetry = config.enableRetry ?? true;
|
|
148
|
+
this.fetchFn = config.customFetch ?? (typeof window !== "undefined" ? fetch.bind(window) : fetch);
|
|
172
149
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
this.
|
|
150
|
+
/**
|
|
151
|
+
* Make an authenticated API request
|
|
152
|
+
*/
|
|
153
|
+
async request(path, options = {}) {
|
|
154
|
+
const url = `${this.baseUrl}${path}`;
|
|
155
|
+
const controller = new AbortController();
|
|
156
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
178
157
|
try {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
};
|
|
188
|
-
this.ws.onmessage = (event) => {
|
|
189
|
-
try {
|
|
190
|
-
const message = JSON.parse(event.data);
|
|
191
|
-
this.handleMessage(message);
|
|
192
|
-
} catch (error) {
|
|
193
|
-
this.handleError(error instanceof Error ? error : new Error("Failed to parse message"));
|
|
158
|
+
const response = await this.fetchFn(url, {
|
|
159
|
+
...options,
|
|
160
|
+
signal: controller.signal,
|
|
161
|
+
headers: {
|
|
162
|
+
"Content-Type": "application/json",
|
|
163
|
+
[AUTH_CONFIG.apiKeyHeader]: this.apiKey,
|
|
164
|
+
...options.headers
|
|
194
165
|
}
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
166
|
+
});
|
|
167
|
+
clearTimeout(timeoutId);
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
const errorData = await response.json();
|
|
170
|
+
throw new LivenessApiError(
|
|
171
|
+
errorData.message,
|
|
172
|
+
errorData.error,
|
|
173
|
+
response.status,
|
|
174
|
+
errorData.required,
|
|
175
|
+
errorData.received
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return await response.json();
|
|
203
179
|
} catch (error) {
|
|
204
|
-
|
|
180
|
+
clearTimeout(timeoutId);
|
|
181
|
+
if (error instanceof LivenessApiError) {
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
185
|
+
throw new LivenessApiError("Request timeout", "timeout", 408);
|
|
186
|
+
}
|
|
187
|
+
throw new LivenessApiError(
|
|
188
|
+
error instanceof Error ? error.message : "Network error",
|
|
189
|
+
"network_error",
|
|
190
|
+
0
|
|
191
|
+
);
|
|
205
192
|
}
|
|
206
193
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
194
|
+
/**
|
|
195
|
+
* Make a request with optional retry
|
|
196
|
+
*/
|
|
197
|
+
async requestWithRetry(path, options = {}) {
|
|
198
|
+
if (this.enableRetry) {
|
|
199
|
+
return retryWithBackoff(() => this.request(path, options), {
|
|
200
|
+
maxAttempts: RETRY_CONFIG.maxAttempts,
|
|
201
|
+
initialDelay: RETRY_CONFIG.initialDelay,
|
|
202
|
+
maxDelay: RETRY_CONFIG.maxDelay,
|
|
203
|
+
backoffMultiplier: RETRY_CONFIG.backoffMultiplier
|
|
204
|
+
});
|
|
211
205
|
}
|
|
212
|
-
this.
|
|
206
|
+
return this.request(path, options);
|
|
213
207
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
208
|
+
// ===========================================================================
|
|
209
|
+
// Health & Status
|
|
210
|
+
// ===========================================================================
|
|
211
|
+
/**
|
|
212
|
+
* Check API health status
|
|
213
|
+
*/
|
|
214
|
+
async health() {
|
|
215
|
+
return this.request(API_PATHS.health);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Get queue statistics
|
|
219
|
+
*/
|
|
220
|
+
async queueStats() {
|
|
221
|
+
return this.request(API_PATHS.queueStats);
|
|
222
|
+
}
|
|
223
|
+
// ===========================================================================
|
|
224
|
+
// Fast Check Endpoints
|
|
225
|
+
// ===========================================================================
|
|
226
|
+
/**
|
|
227
|
+
* Perform fast liveness check with server-side face detection
|
|
228
|
+
*
|
|
229
|
+
* @param frames - Captured frames to analyze
|
|
230
|
+
* @param options - Additional options
|
|
231
|
+
* @returns Liveness result
|
|
232
|
+
*/
|
|
233
|
+
async fastCheck(frames, options = {}) {
|
|
234
|
+
const request = {
|
|
235
|
+
session_id: options.sessionId ?? generateSessionId(),
|
|
236
|
+
model: options.model ?? "10",
|
|
237
|
+
source: options.source ?? "live",
|
|
238
|
+
frames: toFrameData(frames)
|
|
239
|
+
};
|
|
240
|
+
const response = await this.requestWithRetry(API_PATHS.fastCheck, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
body: JSON.stringify(request)
|
|
243
|
+
});
|
|
244
|
+
return toLivenessResult(response);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Perform fast liveness check with pre-cropped face images
|
|
248
|
+
*
|
|
249
|
+
* @param crops - Pre-cropped 224x224 face images
|
|
250
|
+
* @param options - Additional options
|
|
251
|
+
* @returns Liveness result
|
|
252
|
+
*/
|
|
253
|
+
async fastCheckCrops(crops, options = {}) {
|
|
254
|
+
const request = {
|
|
255
|
+
session_id: options.sessionId ?? generateSessionId(),
|
|
256
|
+
model: options.model ?? "10",
|
|
257
|
+
source: options.source ?? "live",
|
|
258
|
+
crops
|
|
223
259
|
};
|
|
224
|
-
this.
|
|
260
|
+
const response = await this.requestWithRetry(API_PATHS.fastCheckCrops, {
|
|
261
|
+
method: "POST",
|
|
262
|
+
body: JSON.stringify(request)
|
|
263
|
+
});
|
|
264
|
+
return toLivenessResult(response);
|
|
225
265
|
}
|
|
226
|
-
|
|
227
|
-
|
|
266
|
+
// ===========================================================================
|
|
267
|
+
// Verify Endpoint (Spatial Features)
|
|
268
|
+
// ===========================================================================
|
|
269
|
+
/**
|
|
270
|
+
* Verify liveness using spatial feature-based detection
|
|
271
|
+
* Requires minimum 50 frames
|
|
272
|
+
*
|
|
273
|
+
* @param frames - Captured frames to analyze
|
|
274
|
+
* @param options - Additional options
|
|
275
|
+
* @returns Liveness result
|
|
276
|
+
*/
|
|
277
|
+
async verify(frames, options = {}) {
|
|
278
|
+
const request = {
|
|
279
|
+
session_id: options.sessionId ?? generateSessionId(),
|
|
280
|
+
capture_start_ms: options.captureStartMs ?? 0,
|
|
281
|
+
frame_width: options.frameWidth ?? 640,
|
|
282
|
+
frame_height: options.frameHeight ?? 480,
|
|
283
|
+
fps: options.fps ?? 10,
|
|
284
|
+
frames: toFrameData(frames)
|
|
285
|
+
};
|
|
286
|
+
const response = await this.requestWithRetry(API_PATHS.verify, {
|
|
287
|
+
method: "POST",
|
|
288
|
+
body: JSON.stringify(request)
|
|
289
|
+
});
|
|
290
|
+
return toLivenessResult(response);
|
|
228
291
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
292
|
+
// ===========================================================================
|
|
293
|
+
// Hybrid Check Endpoints
|
|
294
|
+
// ===========================================================================
|
|
295
|
+
/**
|
|
296
|
+
* Perform hybrid liveness check with CNN + physiological features
|
|
297
|
+
* Requires minimum 50 frames
|
|
298
|
+
*
|
|
299
|
+
* @param frames - Captured frames to analyze
|
|
300
|
+
* @param options - Additional options
|
|
301
|
+
* @returns Liveness result
|
|
302
|
+
*/
|
|
303
|
+
async hybridCheck(frames, options = {}) {
|
|
304
|
+
const request = {
|
|
305
|
+
session_id: options.sessionId ?? generateSessionId(),
|
|
306
|
+
fps: options.fps ?? 30,
|
|
307
|
+
frames: toHybridFrameData(frames)
|
|
308
|
+
};
|
|
309
|
+
const response = await this.requestWithRetry(API_PATHS.hybridCheck, {
|
|
310
|
+
method: "POST",
|
|
311
|
+
body: JSON.stringify(request)
|
|
312
|
+
});
|
|
313
|
+
return toLivenessResult(response);
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Perform 50-frame hybrid liveness check
|
|
317
|
+
* Achieves 93.8% balanced accuracy
|
|
318
|
+
*
|
|
319
|
+
* @param frames - Captured frames to analyze (minimum 50)
|
|
320
|
+
* @param options - Additional options
|
|
321
|
+
* @returns Liveness result
|
|
322
|
+
*/
|
|
323
|
+
async hybrid50(frames, options = {}) {
|
|
324
|
+
const request = {
|
|
325
|
+
session_id: options.sessionId ?? generateSessionId(),
|
|
326
|
+
fps: options.fps ?? 30,
|
|
327
|
+
frames: toHybridFrameData(frames)
|
|
328
|
+
};
|
|
329
|
+
const response = await this.requestWithRetry(API_PATHS.hybrid50, {
|
|
330
|
+
method: "POST",
|
|
331
|
+
body: JSON.stringify(request)
|
|
332
|
+
});
|
|
333
|
+
return toLivenessResult(response);
|
|
234
334
|
}
|
|
235
|
-
|
|
236
|
-
|
|
335
|
+
/**
|
|
336
|
+
* Perform 150-frame hybrid liveness check
|
|
337
|
+
* Achieves 96.2% balanced accuracy
|
|
338
|
+
*
|
|
339
|
+
* @param frames - Captured frames to analyze (minimum 150)
|
|
340
|
+
* @param options - Additional options
|
|
341
|
+
* @returns Liveness result
|
|
342
|
+
*/
|
|
343
|
+
async hybrid150(frames, options = {}) {
|
|
344
|
+
const request = {
|
|
345
|
+
session_id: options.sessionId ?? generateSessionId(),
|
|
346
|
+
fps: options.fps ?? 30,
|
|
347
|
+
frames: toHybridFrameData(frames)
|
|
348
|
+
};
|
|
349
|
+
const response = await this.requestWithRetry(API_PATHS.hybrid150, {
|
|
350
|
+
method: "POST",
|
|
351
|
+
body: JSON.stringify(request)
|
|
352
|
+
});
|
|
353
|
+
return toLivenessResult(response);
|
|
237
354
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
355
|
+
// ===========================================================================
|
|
356
|
+
// Job Polling Endpoints
|
|
357
|
+
// ===========================================================================
|
|
358
|
+
/**
|
|
359
|
+
* Get job status and result
|
|
360
|
+
*
|
|
361
|
+
* @param jobId - Job identifier
|
|
362
|
+
* @returns Job status response
|
|
363
|
+
*/
|
|
364
|
+
async getJobResult(jobId) {
|
|
365
|
+
return this.request(`${API_PATHS.jobResult}/${jobId}`);
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Long-poll for job result
|
|
369
|
+
* Waits up to timeout seconds for the job to complete
|
|
370
|
+
*
|
|
371
|
+
* @param jobId - Job identifier
|
|
372
|
+
* @param timeout - Max seconds to wait (default 30, max 120)
|
|
373
|
+
* @returns Job status response
|
|
374
|
+
*/
|
|
375
|
+
async waitForJobResult(jobId, timeout = 30) {
|
|
376
|
+
return this.request(
|
|
377
|
+
`${API_PATHS.jobResult}/${jobId}/wait?timeout=${timeout}`
|
|
378
|
+
);
|
|
241
379
|
}
|
|
242
380
|
};
|
|
243
381
|
|
|
@@ -245,54 +383,103 @@ var WebSocketClient = class {
|
|
|
245
383
|
var FrameBuffer = class {
|
|
246
384
|
constructor(maxSize = FRAME_BUFFER_CONFIG.maxSize, maxMemory = FRAME_BUFFER_CONFIG.maxMemory) {
|
|
247
385
|
this.buffer = /* @__PURE__ */ new Map();
|
|
248
|
-
this.lastAcknowledged = 0;
|
|
249
386
|
this.currentMemoryUsage = 0;
|
|
250
387
|
this.maxSize = maxSize;
|
|
251
388
|
this.maxMemory = maxMemory;
|
|
252
389
|
}
|
|
390
|
+
/**
|
|
391
|
+
* Add a frame to the buffer
|
|
392
|
+
*/
|
|
253
393
|
add(frame) {
|
|
254
394
|
if (this.currentMemoryUsage + frame.size > this.maxMemory) {
|
|
255
395
|
this.flushOldest();
|
|
256
396
|
}
|
|
257
|
-
this.buffer.set(frame.
|
|
397
|
+
this.buffer.set(frame.index, frame);
|
|
258
398
|
this.currentMemoryUsage += frame.size;
|
|
259
399
|
if (this.buffer.size > this.maxSize) {
|
|
260
400
|
const oldest = Math.min(...this.buffer.keys());
|
|
261
401
|
this.remove(oldest);
|
|
262
402
|
}
|
|
263
403
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}
|
|
270
|
-
}
|
|
404
|
+
/**
|
|
405
|
+
* Get a frame by index
|
|
406
|
+
*/
|
|
407
|
+
get(index) {
|
|
408
|
+
return this.buffer.get(index);
|
|
271
409
|
}
|
|
272
|
-
|
|
273
|
-
|
|
410
|
+
/**
|
|
411
|
+
* Get all frames sorted by index
|
|
412
|
+
*/
|
|
413
|
+
getAll() {
|
|
414
|
+
return Array.from(this.buffer.values()).sort((a, b) => a.index - b.index);
|
|
274
415
|
}
|
|
275
|
-
|
|
276
|
-
|
|
416
|
+
/**
|
|
417
|
+
* Get frames within a range
|
|
418
|
+
*/
|
|
419
|
+
getRange(startIndex, endIndex) {
|
|
420
|
+
return this.getAll().filter((f) => f.index >= startIndex && f.index <= endIndex);
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Get the latest N frames
|
|
424
|
+
*/
|
|
425
|
+
getLatest(count) {
|
|
426
|
+
const all = this.getAll();
|
|
427
|
+
return all.slice(-count);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Remove frames up to and including the given index
|
|
431
|
+
*/
|
|
432
|
+
removeUpTo(index) {
|
|
433
|
+
for (const [frameIndex] of this.buffer) {
|
|
434
|
+
if (frameIndex <= index) {
|
|
435
|
+
this.remove(frameIndex);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
277
438
|
}
|
|
439
|
+
/**
|
|
440
|
+
* Get current buffer size (number of frames)
|
|
441
|
+
*/
|
|
278
442
|
size() {
|
|
279
443
|
return this.buffer.size;
|
|
280
444
|
}
|
|
445
|
+
/**
|
|
446
|
+
* Get current memory usage in bytes
|
|
447
|
+
*/
|
|
281
448
|
getMemoryUsage() {
|
|
282
449
|
return this.currentMemoryUsage;
|
|
283
450
|
}
|
|
451
|
+
/**
|
|
452
|
+
* Check if buffer is empty
|
|
453
|
+
*/
|
|
454
|
+
isEmpty() {
|
|
455
|
+
return this.buffer.size === 0;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Check if buffer is at capacity
|
|
459
|
+
*/
|
|
460
|
+
isFull() {
|
|
461
|
+
return this.buffer.size >= this.maxSize;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Clear all frames from the buffer
|
|
465
|
+
*/
|
|
284
466
|
clear() {
|
|
285
467
|
this.buffer.clear();
|
|
286
468
|
this.currentMemoryUsage = 0;
|
|
287
|
-
this.lastAcknowledged = 0;
|
|
288
469
|
}
|
|
289
|
-
|
|
290
|
-
|
|
470
|
+
/**
|
|
471
|
+
* Remove a specific frame by index
|
|
472
|
+
*/
|
|
473
|
+
remove(index) {
|
|
474
|
+
const frame = this.buffer.get(index);
|
|
291
475
|
if (frame) {
|
|
292
476
|
this.currentMemoryUsage -= frame.size;
|
|
293
|
-
this.buffer.delete(
|
|
477
|
+
this.buffer.delete(index);
|
|
294
478
|
}
|
|
295
479
|
}
|
|
480
|
+
/**
|
|
481
|
+
* Remove the oldest frame
|
|
482
|
+
*/
|
|
296
483
|
flushOldest() {
|
|
297
484
|
if (this.buffer.size === 0) {
|
|
298
485
|
return;
|
|
@@ -330,6 +517,273 @@ var FrameQueue = class {
|
|
|
330
517
|
}
|
|
331
518
|
};
|
|
332
519
|
|
|
520
|
+
// src/types/models.ts
|
|
521
|
+
var MODEL_CONFIGS = {
|
|
522
|
+
"10": {
|
|
523
|
+
type: "10",
|
|
524
|
+
minFrames: 10,
|
|
525
|
+
recommendedFrames: 10,
|
|
526
|
+
description: "Fast model - 10 frames, quick verification"
|
|
527
|
+
},
|
|
528
|
+
"50": {
|
|
529
|
+
type: "50",
|
|
530
|
+
minFrames: 50,
|
|
531
|
+
recommendedFrames: 50,
|
|
532
|
+
description: "Balanced model - 50 frames, good accuracy"
|
|
533
|
+
},
|
|
534
|
+
"250": {
|
|
535
|
+
type: "250",
|
|
536
|
+
minFrames: 250,
|
|
537
|
+
recommendedFrames: 250,
|
|
538
|
+
description: "High-accuracy model - 250 frames, best accuracy"
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
var HYBRID_MODEL_CONFIGS = {
|
|
542
|
+
"hybrid-50": {
|
|
543
|
+
type: "hybrid-50",
|
|
544
|
+
minFrames: 50,
|
|
545
|
+
recommendedFps: 30,
|
|
546
|
+
captureDurationSeconds: 1.7,
|
|
547
|
+
description: "Hybrid 50-frame model - 93.8% balanced accuracy"
|
|
548
|
+
},
|
|
549
|
+
"hybrid-150": {
|
|
550
|
+
type: "hybrid-150",
|
|
551
|
+
minFrames: 150,
|
|
552
|
+
recommendedFps: 30,
|
|
553
|
+
captureDurationSeconds: 5,
|
|
554
|
+
description: "Hybrid 150-frame model - 96.2% balanced accuracy"
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
function getMinFramesForModel(model) {
|
|
558
|
+
return MODEL_CONFIGS[model].minFrames;
|
|
559
|
+
}
|
|
560
|
+
function hasEnoughFrames(model, frameCount) {
|
|
561
|
+
return frameCount >= MODEL_CONFIGS[model].minFrames;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// src/constants/feedback.ts
|
|
565
|
+
var ALIGNMENT_THRESHOLD_CAPTURE = 0.6;
|
|
566
|
+
var ALIGNMENT_THRESHOLD_POOR = 0.5;
|
|
567
|
+
var ALIGNMENT_THRESHOLD_GOOD = 0.5;
|
|
568
|
+
var ALIGNMENT_THRESHOLD_PERFECT = 0.85;
|
|
569
|
+
var OVAL_GUIDE_COLORS = {
|
|
570
|
+
no_face: "#ef4444",
|
|
571
|
+
// Red - no face detected
|
|
572
|
+
poor: "#f97316",
|
|
573
|
+
// Orange - face detected but alignment < 50%
|
|
574
|
+
good: "#eab308",
|
|
575
|
+
// Yellow - alignment 50-85%
|
|
576
|
+
perfect: "#22c55e"
|
|
577
|
+
// Green - alignment >= 85%, ready to capture
|
|
578
|
+
};
|
|
579
|
+
var OVAL_GUIDE_STYLES = {
|
|
580
|
+
no_face: { color: "#ef4444", dashed: true, pulse: false },
|
|
581
|
+
poor: { color: "#f97316", dashed: false, pulse: false },
|
|
582
|
+
good: { color: "#eab308", dashed: false, pulse: false },
|
|
583
|
+
perfect: { color: "#22c55e", dashed: false, pulse: true }
|
|
584
|
+
};
|
|
585
|
+
function getOvalGuideState(hasFace, alignment) {
|
|
586
|
+
if (!hasFace) return "no_face";
|
|
587
|
+
if (alignment < ALIGNMENT_THRESHOLD_POOR) return "poor";
|
|
588
|
+
if (alignment < ALIGNMENT_THRESHOLD_PERFECT) return "good";
|
|
589
|
+
return "perfect";
|
|
590
|
+
}
|
|
591
|
+
var DEFAULT_STATUS_MESSAGES = {
|
|
592
|
+
idle: "Ready to start",
|
|
593
|
+
capturing: "Hold still...",
|
|
594
|
+
uploading: "Processing...",
|
|
595
|
+
processing: "Analyzing...",
|
|
596
|
+
complete: "Complete!",
|
|
597
|
+
error: "Error occurred"
|
|
598
|
+
};
|
|
599
|
+
var FEEDBACK_MESSAGES = {
|
|
600
|
+
// Initial / No face (Red state)
|
|
601
|
+
position_face: "Position your face in the oval",
|
|
602
|
+
no_face: "No face detected - move into frame",
|
|
603
|
+
multiple_faces: "Multiple people detected - please verify alone",
|
|
604
|
+
// Poor alignment (Orange state) - alignment < 50%
|
|
605
|
+
move_closer_to_center: "Move closer to center",
|
|
606
|
+
center_face: "Center your face",
|
|
607
|
+
// Good alignment (Yellow state) - alignment 50-85%
|
|
608
|
+
almost_there: "Almost there...",
|
|
609
|
+
keep_steady: "Keep steady...",
|
|
610
|
+
// Perfect alignment (Green state) - alignment >= 85%
|
|
611
|
+
perfect: "Perfect! Hold still",
|
|
612
|
+
scanning: "Scanning...",
|
|
613
|
+
// Distance issues
|
|
614
|
+
move_closer: "Move closer - face too far",
|
|
615
|
+
move_back: "Move back - face too close",
|
|
616
|
+
too_close: "Move back - face too close",
|
|
617
|
+
too_far: "Move closer - face too far",
|
|
618
|
+
// Visibility issues
|
|
619
|
+
face_not_visible: "Center your face - edges cut off",
|
|
620
|
+
partial_face: "Center your face - edges cut off",
|
|
621
|
+
// Quality issues
|
|
622
|
+
hold_still: "Hold still - image blurry",
|
|
623
|
+
blurry: "Hold still - image blurry",
|
|
624
|
+
// Lighting issues
|
|
625
|
+
poor_lighting: "Improve lighting",
|
|
626
|
+
too_dark: "Low lighting - move to a brighter area",
|
|
627
|
+
backlit: "Backlit - try facing the light source",
|
|
628
|
+
// Hand occlusion
|
|
629
|
+
hand_detected: "Remove hand from face",
|
|
630
|
+
// Progress messages
|
|
631
|
+
capturing: "Capturing...",
|
|
632
|
+
almost_done: "Almost done...",
|
|
633
|
+
verification_complete: "Verification complete",
|
|
634
|
+
// Alignment-based messages (used by getAlignmentFeedback)
|
|
635
|
+
align_face: "Align your face"
|
|
636
|
+
};
|
|
637
|
+
var DEFAULT_LOCALE = {
|
|
638
|
+
status: DEFAULT_STATUS_MESSAGES,
|
|
639
|
+
feedback: FEEDBACK_MESSAGES
|
|
640
|
+
};
|
|
641
|
+
var ES_LOCALE = {
|
|
642
|
+
status: {
|
|
643
|
+
idle: "Listo para comenzar",
|
|
644
|
+
capturing: "Mantente quieto...",
|
|
645
|
+
uploading: "Procesando...",
|
|
646
|
+
processing: "Analizando...",
|
|
647
|
+
complete: "\xA1Completado!",
|
|
648
|
+
error: "Ocurri\xF3 un error"
|
|
649
|
+
},
|
|
650
|
+
feedback: {
|
|
651
|
+
// Initial / No face (Red state)
|
|
652
|
+
position_face: "Posiciona tu rostro en el \xF3valo",
|
|
653
|
+
no_face: "No se detecta rostro - mu\xE9vete al marco",
|
|
654
|
+
multiple_faces: "Varias personas detectadas - verifica solo",
|
|
655
|
+
// Poor alignment (Orange state)
|
|
656
|
+
move_closer_to_center: "Mu\xE9vete al centro",
|
|
657
|
+
center_face: "Centra tu rostro",
|
|
658
|
+
// Good alignment (Yellow state)
|
|
659
|
+
almost_there: "Casi listo...",
|
|
660
|
+
keep_steady: "Mantente quieto...",
|
|
661
|
+
// Perfect alignment (Green state)
|
|
662
|
+
perfect: "\xA1Perfecto! No te muevas",
|
|
663
|
+
scanning: "Escaneando...",
|
|
664
|
+
// Distance issues
|
|
665
|
+
move_closer: "Ac\xE9rcate - rostro muy lejos",
|
|
666
|
+
move_back: "Al\xE9jate - rostro muy cerca",
|
|
667
|
+
too_close: "Al\xE9jate - rostro muy cerca",
|
|
668
|
+
too_far: "Ac\xE9rcate - rostro muy lejos",
|
|
669
|
+
// Visibility issues
|
|
670
|
+
face_not_visible: "Centra tu rostro - bordes cortados",
|
|
671
|
+
partial_face: "Centra tu rostro - bordes cortados",
|
|
672
|
+
// Quality issues
|
|
673
|
+
hold_still: "Mantente quieto - imagen borrosa",
|
|
674
|
+
blurry: "Mantente quieto - imagen borrosa",
|
|
675
|
+
// Lighting issues
|
|
676
|
+
poor_lighting: "Mejora la iluminaci\xF3n",
|
|
677
|
+
too_dark: "Poca luz - mu\xE9vete a un \xE1rea m\xE1s iluminada",
|
|
678
|
+
backlit: "Contraluz - intenta mirar hacia la fuente de luz",
|
|
679
|
+
// Hand occlusion
|
|
680
|
+
hand_detected: "Retira la mano del rostro",
|
|
681
|
+
// Progress messages
|
|
682
|
+
capturing: "Capturando...",
|
|
683
|
+
almost_done: "Casi listo...",
|
|
684
|
+
verification_complete: "Verificaci\xF3n completada",
|
|
685
|
+
// Alignment-based
|
|
686
|
+
align_face: "Alinea tu rostro"
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
function getFeedbackMessage(key, locale = DEFAULT_LOCALE) {
|
|
690
|
+
return locale.feedback[key] ?? FEEDBACK_MESSAGES[key];
|
|
691
|
+
}
|
|
692
|
+
function getStatusMessage(key, locale = DEFAULT_LOCALE) {
|
|
693
|
+
return locale.status[key] ?? DEFAULT_STATUS_MESSAGES[key];
|
|
694
|
+
}
|
|
695
|
+
function getCaptureQualityFeedback(state) {
|
|
696
|
+
const {
|
|
697
|
+
hasFace,
|
|
698
|
+
alignment,
|
|
699
|
+
isCapturing,
|
|
700
|
+
tooClose,
|
|
701
|
+
tooFar,
|
|
702
|
+
isBlurry,
|
|
703
|
+
isPartialFace,
|
|
704
|
+
framesCaptured,
|
|
705
|
+
targetFrames
|
|
706
|
+
} = state;
|
|
707
|
+
if (!hasFace) {
|
|
708
|
+
return FEEDBACK_MESSAGES.no_face;
|
|
709
|
+
}
|
|
710
|
+
if (tooClose) {
|
|
711
|
+
return FEEDBACK_MESSAGES.too_close;
|
|
712
|
+
}
|
|
713
|
+
if (tooFar) {
|
|
714
|
+
return FEEDBACK_MESSAGES.too_far;
|
|
715
|
+
}
|
|
716
|
+
if (isPartialFace) {
|
|
717
|
+
return FEEDBACK_MESSAGES.partial_face;
|
|
718
|
+
}
|
|
719
|
+
if (isBlurry) {
|
|
720
|
+
return FEEDBACK_MESSAGES.blurry;
|
|
721
|
+
}
|
|
722
|
+
if (alignment < ALIGNMENT_THRESHOLD_POOR) {
|
|
723
|
+
return FEEDBACK_MESSAGES.move_closer_to_center;
|
|
724
|
+
}
|
|
725
|
+
if (alignment < ALIGNMENT_THRESHOLD_PERFECT) {
|
|
726
|
+
return FEEDBACK_MESSAGES.almost_there;
|
|
727
|
+
}
|
|
728
|
+
if (isCapturing && framesCaptured > 0) {
|
|
729
|
+
if (framesCaptured >= targetFrames - 2) {
|
|
730
|
+
return FEEDBACK_MESSAGES.almost_done;
|
|
731
|
+
}
|
|
732
|
+
return FEEDBACK_MESSAGES.scanning;
|
|
733
|
+
}
|
|
734
|
+
return FEEDBACK_MESSAGES.perfect;
|
|
735
|
+
}
|
|
736
|
+
function canCaptureFrame(state) {
|
|
737
|
+
const { hasFace, alignment, tooClose, tooFar, isBlurry, isPartialFace } = state;
|
|
738
|
+
return hasFace && alignment >= ALIGNMENT_THRESHOLD_CAPTURE && !tooClose && !tooFar && !isBlurry && !isPartialFace;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// src/utils/validators.ts
|
|
742
|
+
function validateApiKey(apiKey) {
|
|
743
|
+
return typeof apiKey === "string" && apiKey.length > 0;
|
|
744
|
+
}
|
|
745
|
+
function validateFrameData(data) {
|
|
746
|
+
if (!data || typeof data !== "string") {
|
|
747
|
+
return false;
|
|
748
|
+
}
|
|
749
|
+
if (data.startsWith("data:")) {
|
|
750
|
+
const base64Part = data.split(",")[1];
|
|
751
|
+
return base64Part !== void 0 && base64Part.length > 0;
|
|
752
|
+
}
|
|
753
|
+
const base64Pattern = /^[A-Za-z0-9+/=]+$/;
|
|
754
|
+
return base64Pattern.test(data) && data.length > 0;
|
|
755
|
+
}
|
|
756
|
+
function validateFrameIndex(index) {
|
|
757
|
+
return Number.isInteger(index) && index >= 0;
|
|
758
|
+
}
|
|
759
|
+
function validateTimestamp(timestamp) {
|
|
760
|
+
return typeof timestamp === "number" && timestamp >= 0 && isFinite(timestamp);
|
|
761
|
+
}
|
|
762
|
+
function validateFrameCount(model, frameCount) {
|
|
763
|
+
const config = MODEL_CONFIGS[model];
|
|
764
|
+
const required = config.minFrames;
|
|
765
|
+
return {
|
|
766
|
+
valid: frameCount >= required,
|
|
767
|
+
required,
|
|
768
|
+
received: frameCount
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
function validateUUID(uuid) {
|
|
772
|
+
if (!uuid || typeof uuid !== "string") {
|
|
773
|
+
return false;
|
|
774
|
+
}
|
|
775
|
+
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
776
|
+
return uuidPattern.test(uuid);
|
|
777
|
+
}
|
|
778
|
+
function validateUrl(url) {
|
|
779
|
+
try {
|
|
780
|
+
new URL(url);
|
|
781
|
+
return true;
|
|
782
|
+
} catch {
|
|
783
|
+
return false;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
333
787
|
// src/utils/encoders.ts
|
|
334
788
|
function encodeBase64(buffer) {
|
|
335
789
|
if (buffer instanceof ArrayBuffer) {
|
|
@@ -346,22 +800,384 @@ function decodeBase64(base64) {
|
|
|
346
800
|
}
|
|
347
801
|
return bytes;
|
|
348
802
|
}
|
|
803
|
+
|
|
804
|
+
// src/utils/frameAnalysis.ts
|
|
805
|
+
var DEFAULT_BLUR_THRESHOLD = 100;
|
|
806
|
+
var BLUR_THRESHOLD_MOBILE = 150;
|
|
807
|
+
var BACKLIT_RATIO_THRESHOLD = 0.6;
|
|
808
|
+
var LOW_LIGHT_THRESHOLD = 50;
|
|
809
|
+
var MIN_FACE_TOP_MARGIN = 0.1;
|
|
810
|
+
var MIN_FACE_BOTTOM_MARGIN = 0.08;
|
|
811
|
+
var MIN_FACE_SIDE_MARGIN = 0.05;
|
|
812
|
+
var MIN_CAPTURE_ALIGNMENT = 0.6;
|
|
813
|
+
var HIGH_ALIGNMENT = 0.85;
|
|
814
|
+
var GOOD_ALIGNMENT = 0.5;
|
|
815
|
+
var IDEAL_CROP_MULTIPLIER = 3.33;
|
|
816
|
+
var MIN_CROP_MULTIPLIER = 1.5;
|
|
817
|
+
var MAX_CROP_MULTIPLIER = 4;
|
|
818
|
+
var FACE_CENTER_VERTICAL_OFFSET = 0.15;
|
|
819
|
+
var MIN_FACE_RATIO = 0.03;
|
|
820
|
+
var MAX_FACE_RATIO = 0.7;
|
|
821
|
+
var FACE_CROP_OUTPUT_SIZE = 224;
|
|
822
|
+
var MAX_FACE_PERCENTAGE_IN_CROP = 0.5;
|
|
823
|
+
var TARGET_FACE_PERCENTAGE_IN_CROP = 0.3;
|
|
824
|
+
function analyzeBlur(grayscalePixels, width, height, threshold = DEFAULT_BLUR_THRESHOLD) {
|
|
825
|
+
const laplacian = [];
|
|
826
|
+
for (let y = 1; y < height - 1; y++) {
|
|
827
|
+
for (let x = 1; x < width - 1; x++) {
|
|
828
|
+
const idx = y * width + x;
|
|
829
|
+
const top = grayscalePixels[idx - width] ?? 0;
|
|
830
|
+
const left = grayscalePixels[idx - 1] ?? 0;
|
|
831
|
+
const center = grayscalePixels[idx] ?? 0;
|
|
832
|
+
const right = grayscalePixels[idx + 1] ?? 0;
|
|
833
|
+
const bottom = grayscalePixels[idx + width] ?? 0;
|
|
834
|
+
const value = top + left + center * -4 + right + bottom;
|
|
835
|
+
laplacian.push(value);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
const n = laplacian.length;
|
|
839
|
+
if (n === 0) {
|
|
840
|
+
return { variance: 0, isBlurry: true, threshold };
|
|
841
|
+
}
|
|
842
|
+
const mean = laplacian.reduce((a, b) => a + b, 0) / n;
|
|
843
|
+
const variance = laplacian.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / n;
|
|
844
|
+
return {
|
|
845
|
+
variance,
|
|
846
|
+
isBlurry: variance < threshold,
|
|
847
|
+
threshold
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
function rgbaToGrayscale(rgbaPixels) {
|
|
851
|
+
const grayscale = [];
|
|
852
|
+
for (let i = 0; i < rgbaPixels.length; i += 4) {
|
|
853
|
+
const r = rgbaPixels[i] ?? 0;
|
|
854
|
+
const g = rgbaPixels[i + 1] ?? 0;
|
|
855
|
+
const b = rgbaPixels[i + 2] ?? 0;
|
|
856
|
+
grayscale.push(0.299 * r + 0.587 * g + 0.114 * b);
|
|
857
|
+
}
|
|
858
|
+
return grayscale;
|
|
859
|
+
}
|
|
860
|
+
function calculateBrightness(rgbaPixels) {
|
|
861
|
+
let totalLuminance = 0;
|
|
862
|
+
const pixelCount = rgbaPixels.length / 4;
|
|
863
|
+
for (let i = 0; i < rgbaPixels.length; i += 4) {
|
|
864
|
+
const r = rgbaPixels[i] ?? 0;
|
|
865
|
+
const g = rgbaPixels[i + 1] ?? 0;
|
|
866
|
+
const b = rgbaPixels[i + 2] ?? 0;
|
|
867
|
+
totalLuminance += 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
868
|
+
}
|
|
869
|
+
return pixelCount > 0 ? totalLuminance / pixelCount : 0;
|
|
870
|
+
}
|
|
871
|
+
function analyzeLighting(faceBrightness, backgroundBrightness) {
|
|
872
|
+
const ratio = backgroundBrightness > 0 ? faceBrightness / backgroundBrightness : 1;
|
|
873
|
+
let status = "good";
|
|
874
|
+
let warning;
|
|
875
|
+
if (faceBrightness < LOW_LIGHT_THRESHOLD && backgroundBrightness < LOW_LIGHT_THRESHOLD) {
|
|
876
|
+
status = "low_light";
|
|
877
|
+
warning = "Low lighting - move to a brighter area";
|
|
878
|
+
} else if (ratio < BACKLIT_RATIO_THRESHOLD) {
|
|
879
|
+
status = "backlit";
|
|
880
|
+
warning = "Backlit - try facing the light source";
|
|
881
|
+
}
|
|
882
|
+
return {
|
|
883
|
+
faceBrightness,
|
|
884
|
+
backgroundBrightness,
|
|
885
|
+
ratio,
|
|
886
|
+
status,
|
|
887
|
+
warning
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
function isFaceFullyVisible(boundingBox, frameWidth, frameHeight) {
|
|
891
|
+
const faceTop = boundingBox.originY / frameHeight;
|
|
892
|
+
const faceBottom = (boundingBox.originY + boundingBox.height) / frameHeight;
|
|
893
|
+
const faceLeft = boundingBox.originX / frameWidth;
|
|
894
|
+
const faceRight = (boundingBox.originX + boundingBox.width) / frameWidth;
|
|
895
|
+
if (faceTop < MIN_FACE_TOP_MARGIN) {
|
|
896
|
+
return { visible: false, reason: "Face too close to top edge" };
|
|
897
|
+
}
|
|
898
|
+
if (faceBottom > 1 - MIN_FACE_BOTTOM_MARGIN) {
|
|
899
|
+
return { visible: false, reason: "Face too close to bottom edge" };
|
|
900
|
+
}
|
|
901
|
+
if (faceLeft < MIN_FACE_SIDE_MARGIN) {
|
|
902
|
+
return { visible: false, reason: "Face too close to left edge" };
|
|
903
|
+
}
|
|
904
|
+
if (faceRight > 1 - MIN_FACE_SIDE_MARGIN) {
|
|
905
|
+
return { visible: false, reason: "Face too close to right edge" };
|
|
906
|
+
}
|
|
907
|
+
return { visible: true };
|
|
908
|
+
}
|
|
909
|
+
var DEFAULT_OVAL_REGION = {
|
|
910
|
+
centerX: 0.5,
|
|
911
|
+
centerY: 0.5,
|
|
912
|
+
width: 0.3,
|
|
913
|
+
// 30% of frame width
|
|
914
|
+
height: 0.4
|
|
915
|
+
// 30% * (4/3) = 40% of frame height (3:4 aspect ratio)
|
|
916
|
+
};
|
|
917
|
+
function isFaceInOval(faceBox, frameWidth, frameHeight, oval = DEFAULT_OVAL_REGION, tolerance = 0.3) {
|
|
918
|
+
const faceCenterX = (faceBox.originX + faceBox.width / 2) / frameWidth;
|
|
919
|
+
const faceCenterY = (faceBox.originY + faceBox.height / 2) / frameHeight;
|
|
920
|
+
const faceWidth = faceBox.width / frameWidth;
|
|
921
|
+
const faceHeight = faceBox.height / frameHeight;
|
|
922
|
+
const ovalRadiusX = oval.width / 2;
|
|
923
|
+
const ovalRadiusY = oval.height / 2;
|
|
924
|
+
const dx = faceCenterX - oval.centerX;
|
|
925
|
+
const dy = faceCenterY - oval.centerY;
|
|
926
|
+
const normalizedDist = dx * dx / (ovalRadiusX * ovalRadiusX) + dy * dy / (ovalRadiusY * ovalRadiusY);
|
|
927
|
+
const centerInOval = normalizedDist <= 1;
|
|
928
|
+
const expectedFaceWidth = oval.width * 0.7;
|
|
929
|
+
const expectedFaceHeight = oval.height * 0.7;
|
|
930
|
+
const widthRatio = faceWidth / expectedFaceWidth;
|
|
931
|
+
const heightRatio = faceHeight / expectedFaceHeight;
|
|
932
|
+
const tooSmall = widthRatio < 1 - tolerance || heightRatio < 1 - tolerance;
|
|
933
|
+
const tooBig = widthRatio > 1 + tolerance || heightRatio > 1 + tolerance;
|
|
934
|
+
const sizeMatch = !tooSmall && !tooBig;
|
|
935
|
+
let feedback;
|
|
936
|
+
if (!centerInOval) {
|
|
937
|
+
if (dy < -0.1) {
|
|
938
|
+
feedback = "Move face down";
|
|
939
|
+
} else if (dy > 0.1) {
|
|
940
|
+
feedback = "Move face up";
|
|
941
|
+
} else if (dx < -0.1) {
|
|
942
|
+
feedback = "Move face right";
|
|
943
|
+
} else if (dx > 0.1) {
|
|
944
|
+
feedback = "Move face left";
|
|
945
|
+
} else {
|
|
946
|
+
feedback = "Center your face in the oval";
|
|
947
|
+
}
|
|
948
|
+
} else if (tooSmall) {
|
|
949
|
+
feedback = "Move closer";
|
|
950
|
+
} else if (tooBig) {
|
|
951
|
+
feedback = "Move back";
|
|
952
|
+
}
|
|
953
|
+
return {
|
|
954
|
+
isInside: centerInOval && sizeMatch,
|
|
955
|
+
centerInOval,
|
|
956
|
+
sizeMatch,
|
|
957
|
+
feedback
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
function calculateFaceAlignment(boundingBox, frameWidth, frameHeight) {
|
|
961
|
+
const faceCenterX = boundingBox.originX + boundingBox.width / 2;
|
|
962
|
+
const faceCenterY = boundingBox.originY + boundingBox.height / 2;
|
|
963
|
+
const targetX = frameWidth / 2;
|
|
964
|
+
const targetY = frameHeight / 2;
|
|
965
|
+
const distX = Math.abs(faceCenterX - targetX) / (frameWidth / 2);
|
|
966
|
+
const distY = Math.abs(faceCenterY - targetY) / (frameHeight / 2);
|
|
967
|
+
const faceRatio = Math.max(boundingBox.width, boundingBox.height) / Math.min(frameWidth, frameHeight);
|
|
968
|
+
const score = Math.max(0, 1 - Math.max(distX, distY));
|
|
969
|
+
return {
|
|
970
|
+
score,
|
|
971
|
+
tooClose: faceRatio > MAX_FACE_RATIO,
|
|
972
|
+
tooFar: faceRatio < MIN_FACE_RATIO
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
function calculateAdaptiveCropMultiplier(faceBox, frameWidth, frameHeight) {
|
|
976
|
+
const faceSize = Math.max(faceBox.width, faceBox.height);
|
|
977
|
+
const idealCropSize = faceSize * IDEAL_CROP_MULTIPLIER;
|
|
978
|
+
const maxCropSize = Math.min(frameWidth, frameHeight);
|
|
979
|
+
if (idealCropSize <= maxCropSize) {
|
|
980
|
+
return IDEAL_CROP_MULTIPLIER;
|
|
981
|
+
}
|
|
982
|
+
const adaptiveMultiplier = maxCropSize / faceSize;
|
|
983
|
+
return Math.max(MIN_CROP_MULTIPLIER, Math.min(MAX_CROP_MULTIPLIER, adaptiveMultiplier));
|
|
984
|
+
}
|
|
985
|
+
function isFaceCropFullyInFrame(faceBox, frameWidth, frameHeight) {
|
|
986
|
+
const marginMultiplier = calculateAdaptiveCropMultiplier(faceBox, frameWidth, frameHeight);
|
|
987
|
+
const marginX = faceBox.width * (marginMultiplier - 1) / 2;
|
|
988
|
+
const marginY = faceBox.height * (marginMultiplier - 1) / 2;
|
|
989
|
+
const faceLeft = faceBox.originX - marginX;
|
|
990
|
+
const faceTop = faceBox.originY - marginY;
|
|
991
|
+
const faceRight = faceBox.originX + faceBox.width + marginX;
|
|
992
|
+
const faceBottom = faceBox.originY + faceBox.height + marginY;
|
|
993
|
+
return faceLeft >= 0 && faceTop >= 0 && faceRight <= frameWidth && faceBottom <= frameHeight;
|
|
994
|
+
}
|
|
995
|
+
function calculateFaceCropRegion(faceBox, frameWidth, frameHeight) {
|
|
996
|
+
const cropMultiplier = calculateAdaptiveCropMultiplier(faceBox, frameWidth, frameHeight);
|
|
997
|
+
const expandedWidth = faceBox.width * cropMultiplier;
|
|
998
|
+
const expandedHeight = faceBox.height * cropMultiplier;
|
|
999
|
+
const cropSize = Math.max(expandedWidth, expandedHeight);
|
|
1000
|
+
const centerX = faceBox.originX + faceBox.width / 2;
|
|
1001
|
+
const verticalShift = faceBox.height * FACE_CENTER_VERTICAL_OFFSET;
|
|
1002
|
+
const centerY = faceBox.originY + faceBox.height / 2 + verticalShift;
|
|
1003
|
+
let cropX = centerX - cropSize / 2;
|
|
1004
|
+
let cropY = centerY - cropSize / 2;
|
|
1005
|
+
cropX = Math.max(0, Math.min(frameWidth - cropSize, cropX));
|
|
1006
|
+
cropY = Math.max(0, Math.min(frameHeight - cropSize, cropY));
|
|
1007
|
+
const finalSize = Math.min(cropSize, frameWidth - cropX, frameHeight - cropY);
|
|
1008
|
+
return { x: cropX, y: cropY, size: finalSize };
|
|
1009
|
+
}
|
|
1010
|
+
function checkFrameQuality(options) {
|
|
1011
|
+
const {
|
|
1012
|
+
faceBox,
|
|
1013
|
+
frameWidth,
|
|
1014
|
+
frameHeight,
|
|
1015
|
+
blurAnalysis,
|
|
1016
|
+
lightingAnalysis,
|
|
1017
|
+
minAlignment = MIN_CAPTURE_ALIGNMENT
|
|
1018
|
+
} = options;
|
|
1019
|
+
const result = { passed: true };
|
|
1020
|
+
if (!faceBox) {
|
|
1021
|
+
result.passed = false;
|
|
1022
|
+
result.rejectionReason = "no_face";
|
|
1023
|
+
return result;
|
|
1024
|
+
}
|
|
1025
|
+
result.visibility = isFaceFullyVisible(faceBox, frameWidth, frameHeight);
|
|
1026
|
+
if (!result.visibility.visible) {
|
|
1027
|
+
result.passed = false;
|
|
1028
|
+
result.rejectionReason = "partial_face";
|
|
1029
|
+
return result;
|
|
1030
|
+
}
|
|
1031
|
+
result.alignment = calculateFaceAlignment(faceBox, frameWidth, frameHeight);
|
|
1032
|
+
if (result.alignment.tooClose) {
|
|
1033
|
+
result.passed = false;
|
|
1034
|
+
result.rejectionReason = "too_close";
|
|
1035
|
+
return result;
|
|
1036
|
+
}
|
|
1037
|
+
if (result.alignment.tooFar) {
|
|
1038
|
+
result.passed = false;
|
|
1039
|
+
result.rejectionReason = "too_far";
|
|
1040
|
+
return result;
|
|
1041
|
+
}
|
|
1042
|
+
if (result.alignment.score < minAlignment) {
|
|
1043
|
+
result.passed = false;
|
|
1044
|
+
result.rejectionReason = "misaligned";
|
|
1045
|
+
return result;
|
|
1046
|
+
}
|
|
1047
|
+
if (blurAnalysis) {
|
|
1048
|
+
result.blur = blurAnalysis;
|
|
1049
|
+
if (blurAnalysis.isBlurry) {
|
|
1050
|
+
result.passed = false;
|
|
1051
|
+
result.rejectionReason = "blur";
|
|
1052
|
+
return result;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
if (lightingAnalysis) {
|
|
1056
|
+
result.lighting = lightingAnalysis;
|
|
1057
|
+
if (lightingAnalysis.status === "backlit") {
|
|
1058
|
+
result.passed = false;
|
|
1059
|
+
result.rejectionReason = "backlit";
|
|
1060
|
+
return result;
|
|
1061
|
+
}
|
|
1062
|
+
if (lightingAnalysis.status === "low_light") {
|
|
1063
|
+
result.passed = false;
|
|
1064
|
+
result.rejectionReason = "low_light";
|
|
1065
|
+
return result;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
return result;
|
|
1069
|
+
}
|
|
1070
|
+
var BaseFrameCollector = class {
|
|
1071
|
+
constructor(maxFrames = 10) {
|
|
1072
|
+
this.frames = [];
|
|
1073
|
+
this.maxFrames = maxFrames;
|
|
1074
|
+
this.startTime = Date.now();
|
|
1075
|
+
}
|
|
1076
|
+
setMaxFrames(max) {
|
|
1077
|
+
this.maxFrames = max;
|
|
1078
|
+
}
|
|
1079
|
+
addFrame(frame) {
|
|
1080
|
+
if (!this.isComplete()) {
|
|
1081
|
+
this.frames.push(frame);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
getFrames() {
|
|
1085
|
+
return [...this.frames];
|
|
1086
|
+
}
|
|
1087
|
+
getCount() {
|
|
1088
|
+
return this.frames.length;
|
|
1089
|
+
}
|
|
1090
|
+
isComplete() {
|
|
1091
|
+
return this.frames.length >= this.maxFrames;
|
|
1092
|
+
}
|
|
1093
|
+
reset() {
|
|
1094
|
+
this.frames = [];
|
|
1095
|
+
this.startTime = Date.now();
|
|
1096
|
+
}
|
|
1097
|
+
getStartTime() {
|
|
1098
|
+
return this.startTime;
|
|
1099
|
+
}
|
|
1100
|
+
getNextIndex() {
|
|
1101
|
+
return this.frames.length;
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
349
1104
|
export {
|
|
1105
|
+
ALIGNMENT_THRESHOLD_CAPTURE,
|
|
1106
|
+
ALIGNMENT_THRESHOLD_GOOD,
|
|
1107
|
+
ALIGNMENT_THRESHOLD_PERFECT,
|
|
1108
|
+
ALIGNMENT_THRESHOLD_POOR,
|
|
350
1109
|
API_ENDPOINTS,
|
|
1110
|
+
API_PATHS,
|
|
351
1111
|
AUTH_CONFIG,
|
|
352
|
-
|
|
353
|
-
|
|
1112
|
+
BACKLIT_RATIO_THRESHOLD,
|
|
1113
|
+
BLUR_THRESHOLD_MOBILE,
|
|
1114
|
+
BaseFrameCollector,
|
|
1115
|
+
DEFAULT_BLUR_THRESHOLD,
|
|
354
1116
|
DEFAULT_ENDPOINT,
|
|
1117
|
+
DEFAULT_LIVENESS_CONFIG,
|
|
1118
|
+
DEFAULT_LOCALE,
|
|
1119
|
+
DEFAULT_OVAL_REGION,
|
|
1120
|
+
DEFAULT_STATUS_MESSAGES,
|
|
1121
|
+
ES_LOCALE,
|
|
1122
|
+
FACE_CENTER_VERTICAL_OFFSET,
|
|
1123
|
+
FACE_CROP_OUTPUT_SIZE,
|
|
1124
|
+
FEEDBACK_MESSAGES,
|
|
355
1125
|
FRAME_BUFFER_CONFIG,
|
|
356
1126
|
FRAME_CONFIG,
|
|
357
1127
|
FrameBuffer,
|
|
358
1128
|
FrameQueue,
|
|
359
|
-
|
|
360
|
-
|
|
1129
|
+
GOOD_ALIGNMENT,
|
|
1130
|
+
HIGH_ALIGNMENT,
|
|
1131
|
+
HYBRID_MODEL_CONFIGS,
|
|
1132
|
+
IDEAL_CROP_MULTIPLIER,
|
|
1133
|
+
LOW_LIGHT_THRESHOLD,
|
|
1134
|
+
LivenessApiError,
|
|
1135
|
+
LivenessClient,
|
|
1136
|
+
MAX_CROP_MULTIPLIER,
|
|
1137
|
+
MAX_FACE_PERCENTAGE_IN_CROP,
|
|
1138
|
+
MAX_FACE_RATIO,
|
|
1139
|
+
MIN_CAPTURE_ALIGNMENT,
|
|
1140
|
+
MIN_CROP_MULTIPLIER,
|
|
1141
|
+
MIN_FACE_BOTTOM_MARGIN,
|
|
1142
|
+
MIN_FACE_RATIO,
|
|
1143
|
+
MIN_FACE_SIDE_MARGIN,
|
|
1144
|
+
MIN_FACE_TOP_MARGIN,
|
|
1145
|
+
MODEL_CONFIGS,
|
|
1146
|
+
OVAL_GUIDE_COLORS,
|
|
1147
|
+
OVAL_GUIDE_STYLES,
|
|
1148
|
+
RETRY_CONFIG,
|
|
1149
|
+
TARGET_FACE_PERCENTAGE_IN_CROP,
|
|
1150
|
+
analyzeBlur,
|
|
1151
|
+
analyzeLighting,
|
|
1152
|
+
calculateAdaptiveCropMultiplier,
|
|
1153
|
+
calculateBrightness,
|
|
1154
|
+
calculateFaceAlignment,
|
|
1155
|
+
calculateFaceCropRegion,
|
|
1156
|
+
canCaptureFrame,
|
|
1157
|
+
checkFrameQuality,
|
|
361
1158
|
decodeBase64,
|
|
362
1159
|
encodeBase64,
|
|
1160
|
+
generateSessionId,
|
|
1161
|
+
getCaptureQualityFeedback,
|
|
1162
|
+
getFeedbackMessage,
|
|
1163
|
+
getMinFramesForModel,
|
|
1164
|
+
getOvalGuideState,
|
|
1165
|
+
getStatusMessage,
|
|
1166
|
+
hasEnoughFrames,
|
|
1167
|
+
isFaceCropFullyInFrame,
|
|
1168
|
+
isFaceFullyVisible,
|
|
1169
|
+
isFaceInOval,
|
|
363
1170
|
retryWithBackoff,
|
|
1171
|
+
rgbaToGrayscale,
|
|
1172
|
+
sleep,
|
|
1173
|
+
toFrameData,
|
|
1174
|
+
toHybridFrameData,
|
|
1175
|
+
toLivenessResult,
|
|
364
1176
|
validateApiKey,
|
|
1177
|
+
validateFrameCount,
|
|
365
1178
|
validateFrameData,
|
|
366
|
-
|
|
1179
|
+
validateFrameIndex,
|
|
1180
|
+
validateTimestamp,
|
|
1181
|
+
validateUUID,
|
|
1182
|
+
validateUrl
|
|
367
1183
|
};
|