@daboss2003/liveness-web 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/engine.js ADDED
@@ -0,0 +1,772 @@
1
+ export const DEFAULT_MODEL_URL = "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task";
2
+ export const DEFAULT_WASM_URL = "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm";
3
+ /** Error code when CDN/assets are unavailable after retries (internet confirmed). */
4
+ export const LIVENESS_ERROR_CDN_NOT_AVAILABLE = "cdnNotAvailable";
5
+ /** Error code when the user has no internet connection. */
6
+ export const LIVENESS_ERROR_OFFLINE = "offline";
7
+ export function isCdnNotAvailableError(reason) {
8
+ return reason === LIVENESS_ERROR_CDN_NOT_AVAILABLE;
9
+ }
10
+ export function isOfflineError(reason) {
11
+ return reason === LIVENESS_ERROR_OFFLINE;
12
+ }
13
+ export class LivenessError extends Error {
14
+ constructor(code, message) {
15
+ super(message);
16
+ this.code = code;
17
+ this.name = "LivenessError";
18
+ Object.setPrototypeOf(this, LivenessError.prototype);
19
+ }
20
+ }
21
+ const CONNECTIVITY_CHECK_URL = "https://www.gstatic.com/generate_204";
22
+ const CONNECTIVITY_CHECK_TIMEOUT_MS = 5000;
23
+ const LOAD_ATTEMPT_TIMEOUT_MS = 45000;
24
+ const MAX_CDN_RETRIES = 5;
25
+ async function checkConnectivity() {
26
+ if (typeof navigator !== "undefined" && !navigator.onLine)
27
+ return false;
28
+ try {
29
+ const res = await fetch(CONNECTIVITY_CHECK_URL, {
30
+ method: "HEAD",
31
+ signal: AbortSignal.timeout(CONNECTIVITY_CHECK_TIMEOUT_MS),
32
+ });
33
+ return res.ok;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ function isRetriableCdnError(error) {
40
+ if (error instanceof TypeError)
41
+ return true;
42
+ const msg = error instanceof Error ? error.message : String(error);
43
+ const lower = msg.toLowerCase();
44
+ const retriablePatterns = [
45
+ "fetch",
46
+ "network",
47
+ "wasm",
48
+ "webassembly",
49
+ "load",
50
+ "404",
51
+ "503",
52
+ "502",
53
+ "500",
54
+ "timeout",
55
+ "failed to load",
56
+ ];
57
+ if (retriablePatterns.some((p) => lower.includes(p)))
58
+ return true;
59
+ const status = error?.status;
60
+ if (typeof status === "number" && [404, 502, 503, 500].includes(status))
61
+ return true;
62
+ return false;
63
+ }
64
+ const STEP_LABELS = [
65
+ "Turn your head LEFT",
66
+ "Blink",
67
+ "Turn your head RIGHT",
68
+ "Nod your head",
69
+ "Open your mouth",
70
+ ];
71
+ /** Step label → sound key (filename without .mp3). Used so the correct sound plays regardless of randomized step order. */
72
+ const STEP_LABEL_TO_SOUND = {
73
+ "Turn your head LEFT": "left",
74
+ "Blink": "blink",
75
+ "Turn your head RIGHT": "right",
76
+ "Nod your head": "nod",
77
+ "Open your mouth": "mouth",
78
+ };
79
+ function shuffleArray(array) {
80
+ const out = [...array];
81
+ for (let i = out.length - 1; i > 0; i--) {
82
+ const j = Math.floor(Math.random() * (i + 1));
83
+ [out[i], out[j]] = [out[j], out[i]];
84
+ }
85
+ return out;
86
+ }
87
+ const steps = shuffleArray(STEP_LABELS).map((label, index) => ({ index, label }));
88
+ export const LIVENESS_STEP_COUNT = steps.length;
89
+ // ─────────────────────────────────────────────────────────────────────────────
90
+ // KEY DESIGN: RELATIVE MEASUREMENT
91
+ //
92
+ // Rather than fixed absolute thresholds, the engine samples the user's
93
+ // resting yaw/pitch at the start of each step (during the readyMs window)
94
+ // and measures CHANGE FROM THAT BASELINE.
95
+ //
96
+ // This fixes the core UX problem: someone sitting slightly turned or with
97
+ // a slightly tilted monitor should not need to fight their natural position.
98
+ //
99
+ // Head turn LEFT: yaw delta < -12° from baseline (a natural glance)
100
+ // Head turn RIGHT: yaw delta > +12° from baseline
101
+ // Nod down: pitch delta > +10° from baseline (chin dips toward chest)
102
+ // Nod up return: pitch delta < +3° (back near starting point, not below it)
103
+ //
104
+ // Blink and mouth use blendshapes which are already camera-relative.
105
+ // ─────────────────────────────────────────────────────────────────────────────
106
+ const config = {
107
+ readyMs: 1800, // ms to sample baseline before evaluating
108
+ sessionTimeoutMs: 120000,
109
+ // ── Baseline sampling ──────────────────────────────────────────────────────
110
+ // Number of frames averaged to produce the resting baseline per step
111
+ baselineFrames: 8,
112
+ // ── Head turns (relative to baseline) ─────────────────────────────────────
113
+ yawTurnDelta: 12, // degrees of YAW change needed from rest
114
+ yawWrongDirDelta: 16, // block if turned clearly the WRONG way
115
+ headTurnHoldMs: 120, // sustain the turned pose for this long
116
+ // ── Nod (relative to baseline) ────────────────────────────────────────────
117
+ nodDownDelta: 8, // chin must DROP by this many degrees from baseline
118
+ nodReturnFraction: 0.40, // return to 40% of peak nod depth to complete
119
+ nodReturnMaxDelta: 5, // cap: never require returning past 5° from baseline
120
+ maxYawDuringNod: 22,
121
+ // ── Blink ──────────────────────────────────────────────────────────────────
122
+ blinkClosedThreshold: 0.35, // blendshape score = eyes closed
123
+ blinkOpenThreshold: 0.20, // blendshape score = eyes open
124
+ earClosedThreshold: 0.20,
125
+ earOpenThreshold: 0.25,
126
+ blinkMaxDurationMs: 4000,
127
+ maxYawDuringBlink: 25,
128
+ maxPitchDuringBlink: 25,
129
+ // ── Mouth ──────────────────────────────────────────────────────────────────
130
+ mouthOpenThreshold: 0.28, // jawOpen blendshape
131
+ mouthOpenMarThreshold: 0.28,
132
+ mouthHoldMs: 120,
133
+ maxYawDuringMouth: 25,
134
+ maxPitchDuringMouth: 25,
135
+ // ── Face-in-oval ───────────────────────────────────────────────────────────
136
+ ovalCx: 0.50,
137
+ ovalCy: 0.42,
138
+ ovalRx: 0.32,
139
+ ovalRy: 0.40,
140
+ minFaceSize: 0.10,
141
+ maxFaceSize: 0.62,
142
+ headTurnSteps: new Set(["Turn your head LEFT", "Turn your head RIGHT"]),
143
+ // ── Capture ────────────────────────────────────────────────────────────────
144
+ captureDelayMs: 700,
145
+ captureMaxAttempts: 90,
146
+ captureMaxYaw: 18,
147
+ captureMaxPitch: 18,
148
+ captureMaxMouthScore: 0.20,
149
+ captureMaxBlinkScore: 0.25,
150
+ captureMinEar: 0.22,
151
+ captureMaxMar: 0.22,
152
+ };
153
+ // ─────────────────────────────────────────────────────────────────────────────
154
+ export class LivenessEngine {
155
+ constructor(opts) {
156
+ this.opts = opts;
157
+ this.landmarker = null;
158
+ this.running = false;
159
+ this.rafId = null;
160
+ this.stream = null;
161
+ this.stepIndex = 0;
162
+ this.stepStart = 0;
163
+ // ── Baseline (sampled during readyMs window) ───────────────────────────────
164
+ this.baselineYaw = null;
165
+ this.baselinePitch = null;
166
+ this.baselineSamples = [];
167
+ // ── Per-step sub-state ─────────────────────────────────────────────────────
168
+ this.blinkState = "waitingClose";
169
+ this.blinkCloseTs = 0;
170
+ this.nodState = "neutral";
171
+ this.holdStart = null;
172
+ this.latestMetrics = null;
173
+ this.nodPeakDPitch = 0;
174
+ this.lastDetectTs = -1;
175
+ this.lastOvalState = null;
176
+ this.stepSoundPlayedForCurrentStep = false;
177
+ this.currentStepAudio = null;
178
+ this.currentStepAudioCleanup = null;
179
+ this.sessionTimeoutId = null;
180
+ }
181
+ playSound(url, onEnded) {
182
+ const a = new Audio(url);
183
+ if (onEnded) {
184
+ const done = () => {
185
+ a.removeEventListener("ended", done);
186
+ a.removeEventListener("error", done);
187
+ onEnded();
188
+ };
189
+ a.addEventListener("ended", done);
190
+ a.addEventListener("error", done);
191
+ }
192
+ a.play().catch(() => onEnded?.());
193
+ }
194
+ getSoundUrl(key) {
195
+ const s = this.opts.sounds;
196
+ if (!s)
197
+ return undefined;
198
+ const override = s[key];
199
+ if (override)
200
+ return override;
201
+ const base = s.baseUrl;
202
+ if (!base)
203
+ return undefined;
204
+ const baseNorm = base.replace(/\/?$/, "/");
205
+ return baseNorm + key + ".mp3";
206
+ }
207
+ playStepSound(stepLabel) {
208
+ const key = STEP_LABEL_TO_SOUND[stepLabel];
209
+ if (!key)
210
+ return;
211
+ const url = this.getSoundUrl(key);
212
+ if (!url)
213
+ return;
214
+ this.stopStepSound();
215
+ const a = new Audio(url);
216
+ const done = () => {
217
+ a.removeEventListener("ended", done);
218
+ a.removeEventListener("error", done);
219
+ if (this.currentStepAudio === a)
220
+ this.currentStepAudio = null;
221
+ if (this.currentStepAudioCleanup === cleanup)
222
+ this.currentStepAudioCleanup = null;
223
+ };
224
+ const cleanup = () => {
225
+ a.removeEventListener("ended", done);
226
+ a.removeEventListener("error", done);
227
+ };
228
+ this.currentStepAudio = a;
229
+ this.currentStepAudioCleanup = cleanup;
230
+ a.addEventListener("ended", done);
231
+ a.addEventListener("error", done);
232
+ a.play().catch(() => done());
233
+ }
234
+ stopStepSound() {
235
+ if (this.currentStepAudio) {
236
+ this.currentStepAudio.pause();
237
+ this.currentStepAudio.currentTime = 0;
238
+ }
239
+ this.currentStepAudioCleanup?.();
240
+ this.currentStepAudioCleanup = null;
241
+ this.currentStepAudio = null;
242
+ }
243
+ clearSessionTimeout() {
244
+ if (this.sessionTimeoutId != null) {
245
+ clearTimeout(this.sessionTimeoutId);
246
+ this.sessionTimeoutId = null;
247
+ }
248
+ }
249
+ playGoodSound(onEnded) {
250
+ const url = this.getSoundUrl("good");
251
+ if (url)
252
+ this.playSound(url, onEnded);
253
+ else
254
+ onEnded?.();
255
+ }
256
+ playCaptureSound(onEnded) {
257
+ const url = this.getSoundUrl("capture");
258
+ if (url)
259
+ this.playSound(url, onEnded);
260
+ else
261
+ onEnded?.();
262
+ }
263
+ // ── Public ─────────────────────────────────────────────────────────────────
264
+ async start() {
265
+ this.stopDetectionOnly();
266
+ this.running = true;
267
+ this.stepIndex = 0;
268
+ this.lastDetectTs = -1;
269
+ this.lastOvalState = null;
270
+ const now = performance.now();
271
+ this.stepStart = now + config.readyMs;
272
+ this.resetStepState();
273
+ this.stepSoundPlayedForCurrentStep = false;
274
+ this.clearSessionTimeout();
275
+ this.sessionTimeoutId = setTimeout(() => {
276
+ if (this.running)
277
+ this.fail("Timed out. Please try again.");
278
+ }, config.sessionTimeoutMs);
279
+ this.opts.callbacks?.onChallengeChanged?.(steps[0].index, steps[0].label);
280
+ await this.ensureVideo();
281
+ this.landmarker = await createLandmarkerWithRetry(this.opts, MAX_CDN_RETRIES);
282
+ this.loop();
283
+ }
284
+ stop() {
285
+ this.stopDetectionOnly();
286
+ this.stream?.getTracks().forEach(t => t.stop());
287
+ this.stream = null;
288
+ }
289
+ stopDetectionOnly() {
290
+ this.running = false;
291
+ this.clearSessionTimeout();
292
+ this.stopStepSound();
293
+ if (this.rafId != null) {
294
+ cancelAnimationFrame(this.rafId);
295
+ this.rafId = null;
296
+ }
297
+ if (this.landmarker) {
298
+ this.landmarker.close();
299
+ this.landmarker = null;
300
+ }
301
+ }
302
+ // ── Video ──────────────────────────────────────────────────────────────────
303
+ async ensureVideo() {
304
+ const video = this.opts.videoElement;
305
+ if (!video.srcObject) {
306
+ this.stream = await navigator.mediaDevices.getUserMedia({
307
+ video: { facingMode: "user", width: { ideal: 640 }, height: { ideal: 480 } },
308
+ audio: false,
309
+ });
310
+ video.srcObject = this.stream;
311
+ }
312
+ else {
313
+ this.stream = video.srcObject;
314
+ }
315
+ video.playsInline = true;
316
+ await video.play();
317
+ await new Promise(resolve => {
318
+ const check = () => video.readyState >= 2 && video.videoWidth > 0
319
+ ? resolve()
320
+ : requestAnimationFrame(check);
321
+ check();
322
+ });
323
+ }
324
+ // ── Landmarker ─────────────────────────────────────────────────────────────
325
+ async createLandmarker() {
326
+ return loadLandmarkerOnce(this.opts.modelUrl ?? DEFAULT_MODEL_URL, this.opts.wasmUrl ?? DEFAULT_WASM_URL);
327
+ }
328
+ // ── Loop ───────────────────────────────────────────────────────────────────
329
+ loop() {
330
+ if (!this.running || !this.landmarker)
331
+ return;
332
+ const now = performance.now();
333
+ const ts = now > this.lastDetectTs ? now : this.lastDetectTs + 1;
334
+ this.lastDetectTs = ts;
335
+ const result = this.landmarker.detectForVideo(this.opts.videoElement, ts);
336
+ const faceCount = result.faceLandmarks?.length ?? 0;
337
+ if (faceCount > 1) {
338
+ this.fail("Multiple faces detected. Please ensure only one person is in view.");
339
+ return;
340
+ }
341
+ const hasFace = faceCount > 0;
342
+ if (hasFace) {
343
+ const metrics = extractMetrics(result);
344
+ this.latestMetrics = metrics;
345
+ const { inside, reason } = this.checkFaceInOval(metrics);
346
+ if (inside !== this.lastOvalState) {
347
+ this.lastOvalState = inside;
348
+ this.opts.callbacks?.onFaceInOval?.(inside, reason);
349
+ }
350
+ // ── Sample baseline (keep sampling until captured) ─────────────────────
351
+ if (this.baselineYaw === null && inside) {
352
+ this.baselineSamples.push({ yaw: metrics.yaw, pitch: metrics.pitch });
353
+ if (this.baselineSamples.length >= config.baselineFrames) {
354
+ const yaws = this.baselineSamples.map(s => s.yaw).sort((a, b) => a - b);
355
+ const pitches = this.baselineSamples.map(s => s.pitch).sort((a, b) => a - b);
356
+ const mid = Math.floor(yaws.length / 2);
357
+ this.baselineYaw = yaws[mid];
358
+ this.baselinePitch = pitches[mid];
359
+ }
360
+ }
361
+ this.opts.callbacks?.onDebugFrame?.({
362
+ hasFace: true, metrics,
363
+ step: steps[this.stepIndex]?.label ?? "done",
364
+ });
365
+ if (inside) {
366
+ if (!this.stepSoundPlayedForCurrentStep && this.stepIndex < steps.length) {
367
+ this.stepSoundPlayedForCurrentStep = true;
368
+ this.playStepSound(steps[this.stepIndex].label);
369
+ }
370
+ if (this.updateState(metrics, now) === "passed") {
371
+ this.scheduleCapture();
372
+ return;
373
+ }
374
+ }
375
+ }
376
+ else {
377
+ if (this.lastOvalState !== false) {
378
+ this.lastOvalState = false;
379
+ this.opts.callbacks?.onFaceInOval?.(false, "No face detected");
380
+ }
381
+ this.opts.callbacks?.onDebugFrame?.({
382
+ hasFace: false, metrics: null,
383
+ step: steps[this.stepIndex]?.label ?? "done",
384
+ });
385
+ }
386
+ this.rafId = requestAnimationFrame(() => this.loop());
387
+ }
388
+ // ── Oval check ─────────────────────────────────────────────────────────────
389
+ checkFaceInOval(m) {
390
+ const isHeadTurn = config.headTurnSteps.has(steps[this.stepIndex]?.label ?? "");
391
+ const mx = 1 - m.faceCx; // mirror x to match CSS scaleX(-1)
392
+ const dy = (m.faceCy - config.ovalCy) / config.ovalRy;
393
+ const dx = (mx - config.ovalCx) / config.ovalRx;
394
+ // During head turns only check vertical position — x drifts intentionally
395
+ const inEllipse = isHeadTurn
396
+ ? Math.abs(dy) <= 1
397
+ : dx * dx + dy * dy <= 1;
398
+ if (!inEllipse) {
399
+ if (Math.abs(dy) >= Math.abs(dx)) {
400
+ return { inside: false, reason: dy < 0 ? "Move down slightly" : "Move up slightly" };
401
+ }
402
+ return { inside: false, reason: dx < 0 ? "Move right" : "Move left" };
403
+ }
404
+ if (m.faceSize < config.minFaceSize)
405
+ return { inside: false, reason: "Move closer to the camera" };
406
+ if (m.faceSize > config.maxFaceSize)
407
+ return { inside: false, reason: "Move back a little" };
408
+ return { inside: true };
409
+ }
410
+ // ── State machine ──────────────────────────────────────────────────────────
411
+ resetStepState() {
412
+ this.blinkState = "waitingClose";
413
+ this.blinkCloseTs = 0;
414
+ this.nodState = "neutral";
415
+ this.holdStart = null;
416
+ this.baselineYaw = null;
417
+ this.baselinePitch = null;
418
+ this.baselineSamples = [];
419
+ }
420
+ updateState(metrics, now) {
421
+ if (now < this.stepStart)
422
+ return "none"; // in ready countdown
423
+ if (this.baselineYaw === null || this.baselinePitch === null)
424
+ return "none";
425
+ const bYaw = this.baselineYaw ?? metrics.yaw;
426
+ const bPitch = this.baselinePitch ?? metrics.pitch;
427
+ const dYaw = metrics.yaw - bYaw;
428
+ const dPitch = metrics.pitch - bPitch;
429
+ switch (steps[this.stepIndex].label) {
430
+ // ── LEFT turn (negative yaw delta = turning left from rest) ─────────────
431
+ case "Turn your head LEFT": {
432
+ if (dYaw > config.yawWrongDirDelta) {
433
+ this.holdStart = null;
434
+ return "none";
435
+ }
436
+ if (dYaw < -config.yawTurnDelta) {
437
+ if (this.holdStart === null)
438
+ this.holdStart = now;
439
+ if (now - this.holdStart >= config.headTurnHoldMs)
440
+ return this.advanceStep(now);
441
+ }
442
+ else {
443
+ this.holdStart = null;
444
+ }
445
+ break;
446
+ }
447
+ // ── RIGHT turn (positive yaw delta = turning right from rest) ──────────
448
+ case "Turn your head RIGHT": {
449
+ if (dYaw < -config.yawWrongDirDelta) {
450
+ this.holdStart = null;
451
+ return "none";
452
+ }
453
+ if (dYaw > config.yawTurnDelta) {
454
+ if (this.holdStart === null)
455
+ this.holdStart = now;
456
+ if (now - this.holdStart >= config.headTurnHoldMs)
457
+ return this.advanceStep(now);
458
+ }
459
+ else {
460
+ this.holdStart = null;
461
+ }
462
+ break;
463
+ }
464
+ // ── BLINK ──────────────────────────────────────────────────────────────
465
+ case "Blink": {
466
+ if (Math.abs(metrics.yaw) > config.maxYawDuringBlink ||
467
+ Math.abs(metrics.pitch) > config.maxPitchDuringBlink)
468
+ return "none";
469
+ const isEyeClosed = metrics.blinkScore > 0
470
+ ? metrics.blinkScore > config.blinkClosedThreshold
471
+ : metrics.ear < config.earClosedThreshold;
472
+ const isEyeOpen = metrics.blinkScore > 0
473
+ ? metrics.blinkScore < config.blinkOpenThreshold
474
+ : metrics.ear > config.earOpenThreshold;
475
+ if (this.blinkState === "waitingClose" && isEyeClosed) {
476
+ this.blinkState = "closed";
477
+ this.blinkCloseTs = now;
478
+ }
479
+ else if (this.blinkState === "closed" && isEyeOpen) {
480
+ if (now - this.blinkCloseTs <= config.blinkMaxDurationMs)
481
+ return this.advanceStep(now);
482
+ this.blinkState = "waitingClose";
483
+ }
484
+ break;
485
+ }
486
+ // ── NOD (dPitch > 0 = chin dropping; completion = back within nodReturnDelta) ─
487
+ case "Nod your head": {
488
+ if (Math.abs(dYaw) > config.maxYawDuringNod)
489
+ return "none";
490
+ if (this.nodState === "neutral") {
491
+ if (dPitch > config.nodDownDelta) {
492
+ this.nodState = "down";
493
+ this.nodPeakDPitch = dPitch;
494
+ }
495
+ }
496
+ else if (this.nodState === "down") {
497
+ // Keep updating peak in case they nod deeper
498
+ if (dPitch > this.nodPeakDPitch)
499
+ this.nodPeakDPitch = dPitch;
500
+ // Return target: proportional to how deep they nodded,
501
+ // capped so a very deep nod doesn't need a huge return
502
+ const returnTarget = Math.min(this.nodPeakDPitch * config.nodReturnFraction, config.nodReturnMaxDelta);
503
+ if (dPitch < returnTarget)
504
+ return this.advanceStep(now);
505
+ }
506
+ break;
507
+ }
508
+ // ── OPEN MOUTH ─────────────────────────────────────────────────────────
509
+ case "Open your mouth": {
510
+ if (Math.abs(metrics.yaw) > config.maxYawDuringMouth ||
511
+ Math.abs(metrics.pitch) > config.maxPitchDuringMouth)
512
+ return "none";
513
+ const isMouthOpen = metrics.mouthScore > 0
514
+ ? metrics.mouthScore > config.mouthOpenThreshold
515
+ : metrics.mar > config.mouthOpenMarThreshold;
516
+ if (isMouthOpen) {
517
+ if (this.holdStart === null)
518
+ this.holdStart = now;
519
+ // Short hold prevents accidental trigger from talking/yawning
520
+ if (now - this.holdStart >= config.mouthHoldMs)
521
+ return this.advanceStep(now);
522
+ }
523
+ else {
524
+ this.holdStart = null;
525
+ }
526
+ break;
527
+ }
528
+ }
529
+ return "none";
530
+ }
531
+ advanceStep(now) {
532
+ this.stopStepSound();
533
+ this.stepIndex += 1;
534
+ if (this.stepIndex >= steps.length) {
535
+ this.playGoodSound();
536
+ return "passed";
537
+ }
538
+ this.stepStart = now + config.readyMs;
539
+ this.resetStepState();
540
+ this.stepSoundPlayedForCurrentStep = true;
541
+ const step = steps[this.stepIndex];
542
+ this.opts.callbacks?.onChallengeChanged?.(step.index, step.label);
543
+ this.playGoodSound(() => this.playStepSound(step.label));
544
+ return "none";
545
+ }
546
+ fail(reason) {
547
+ this.opts.callbacks?.onFailure?.(reason);
548
+ this.stopDetectionOnly();
549
+ }
550
+ // ── Capture ────────────────────────────────────────────────────────────────
551
+ scheduleCapture() {
552
+ let attempts = 0;
553
+ // Tell the UI to prompt the user to relax their face
554
+ this.opts.callbacks?.onChallengeChanged?.(-1, "Relax and look at the camera");
555
+ const tryCapture = () => {
556
+ if (!this.running || !this.landmarker)
557
+ return;
558
+ attempts++;
559
+ const now = performance.now();
560
+ const ts = now > this.lastDetectTs ? now : this.lastDetectTs + 1;
561
+ this.lastDetectTs = ts;
562
+ const result = this.landmarker.detectForVideo(this.opts.videoElement, ts);
563
+ const faceCount = result.faceLandmarks?.length ?? 0;
564
+ if (faceCount > 1) {
565
+ this.fail("Multiple faces detected. Please ensure only one person is in view.");
566
+ return;
567
+ }
568
+ if (faceCount > 0) {
569
+ const metrics = extractMetrics(result);
570
+ this.latestMetrics = metrics;
571
+ // ── Neutral face check ─────────────────────────────────────────────
572
+ // Head must be roughly forward
573
+ const headFrontal = Math.abs(metrics.yaw) <= config.captureMaxYaw &&
574
+ Math.abs(metrics.pitch) <= config.captureMaxPitch;
575
+ // Eyes must be open (not blinking or squinting)
576
+ const eyesOpen = metrics.blinkScore > 0
577
+ ? metrics.blinkScore < config.captureMaxBlinkScore
578
+ : metrics.ear >= config.captureMinEar;
579
+ // Mouth must be closed — this is the key fix
580
+ const mouthClosed = metrics.mouthScore > 0
581
+ ? metrics.mouthScore < config.captureMaxMouthScore
582
+ : metrics.mar < config.captureMaxMar;
583
+ if (headFrontal && eyesOpen && mouthClosed) {
584
+ this.captureImage();
585
+ return;
586
+ }
587
+ }
588
+ if (attempts >= config.captureMaxAttempts) {
589
+ this.fail("Please look straight at the camera with a relaxed expression.");
590
+ return;
591
+ }
592
+ this.rafId = requestAnimationFrame(tryCapture);
593
+ };
594
+ const startCaptureLoop = () => {
595
+ // Short delay so the user has time to close their mouth after the last step
596
+ setTimeout(() => { this.rafId = requestAnimationFrame(tryCapture); }, config.captureDelayMs);
597
+ };
598
+ // Play capture sound and only start the capture loop after it finishes
599
+ this.playCaptureSound(startCaptureLoop);
600
+ }
601
+ captureImage() {
602
+ const canvas = this.opts.canvasElement;
603
+ const video = this.opts.videoElement;
604
+ canvas.width = video.videoWidth;
605
+ canvas.height = video.videoHeight;
606
+ const ctx = canvas.getContext("2d");
607
+ if (!ctx) {
608
+ this.fail("Canvas unavailable");
609
+ return;
610
+ }
611
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
612
+ const base64 = canvas.toDataURL("image/jpeg", 0.95).split(",")[1] ?? "";
613
+ this.opts.callbacks?.onSuccess?.(base64);
614
+ this.stop();
615
+ }
616
+ }
617
+ // ── Metric extraction ────────────────────────────────────────────────────────
618
+ function extractMetrics(result) {
619
+ const lks = result.faceLandmarks[0];
620
+ const { yaw, pitch } = extractPose(result, lks);
621
+ const { leftEar, rightEar } = computeEar(lks);
622
+ const mar = computeMar(lks);
623
+ const bs = result.faceBlendshapes?.[0]?.categories ?? [];
624
+ const getBS = (name) => bs.find(c => c.categoryName === name)?.score ?? 0;
625
+ const blinkL = getBS("eyeBlinkLeft"), blinkR = getBS("eyeBlinkRight");
626
+ const blinkScore = (blinkL > 0 || blinkR > 0) ? (blinkL + blinkR) / 2 : 0;
627
+ const mouthScore = getBS("jawOpen");
628
+ // Face centre: mean of all landmarks
629
+ let sumX = 0, sumY = 0;
630
+ for (const lm of lks) {
631
+ sumX += lm.x;
632
+ sumY += lm.y;
633
+ }
634
+ const faceCx = sumX / lks.length;
635
+ const faceCy = sumY / lks.length;
636
+ // Face size: normalised inter-eye distance
637
+ const faceSize = dist(lks[33], lks[263]);
638
+ return {
639
+ yaw, pitch,
640
+ ear: (leftEar + rightEar) / 2,
641
+ mar, blinkScore, mouthScore,
642
+ faceCx, faceCy, faceSize,
643
+ };
644
+ }
645
+ function extractPose(result, lks) {
646
+ const mats = result.facialTransformationMatrixes;
647
+ const first = Array.isArray(mats) ? mats[0] : undefined;
648
+ const data = Array.isArray(first) ? first
649
+ : first && "data" in first
650
+ ? first.data
651
+ : undefined;
652
+ const layout = !Array.isArray(first) && first && "layout" in first
653
+ ? first.layout
654
+ : undefined;
655
+ if (data && data.length >= 16) {
656
+ // MatrixData is column-major by default; handle row-major if provided.
657
+ const rowMajor = layout === 1;
658
+ const r00 = rowMajor ? data[0] : data[0];
659
+ const r02 = rowMajor ? data[2] : data[8];
660
+ const r10 = rowMajor ? data[4] : data[1];
661
+ const r12 = rowMajor ? data[6] : data[9];
662
+ const r20 = rowMajor ? data[8] : data[2];
663
+ const r22 = rowMajor ? data[10] : data[10];
664
+ // Use the face forward vector (column 2) for stable yaw/pitch.
665
+ let fx = r02, fy = r12, fz = r22;
666
+ const fLen = Math.hypot(fx, fy, fz) || 1;
667
+ fx /= fLen;
668
+ fy /= fLen;
669
+ fz /= fLen;
670
+ const yaw = -Math.atan2(fx, fz); // negative=left, positive=right
671
+ const pitch = Math.atan2(-fy, Math.hypot(fx, fz)); // negative=up, positive=down
672
+ // Approximate roll from the right vector (column 0).
673
+ const rLen = Math.hypot(r00, r10, r20) || 1;
674
+ const roll = Math.atan2(r10 / rLen, r00 / rLen);
675
+ return {
676
+ yaw: toDeg(yaw),
677
+ pitch: toDeg(pitch),
678
+ roll: toDeg(roll),
679
+ };
680
+ }
681
+ // Landmark fallback
682
+ const le = lks[33], re = lks[263], n = lks[1], ch = lks[152];
683
+ return {
684
+ yaw: toDeg(Math.atan2(re.z - le.z, re.x - le.x)),
685
+ pitch: toDeg(Math.atan2(ch.y - n.y, ch.z - n.z)),
686
+ roll: toDeg(Math.atan2(re.y - le.y, re.x - le.x)),
687
+ };
688
+ }
689
+ function computeEar(lks) {
690
+ return {
691
+ leftEar: ear(lks[33], lks[133], lks[160], lks[158], lks[153], lks[144]),
692
+ rightEar: ear(lks[362], lks[263], lks[385], lks[387], lks[373], lks[380]),
693
+ };
694
+ }
695
+ function computeMar(lks) {
696
+ const h = dist(lks[61], lks[291]);
697
+ return h === 0 ? 0 : dist(lks[13], lks[14]) / h;
698
+ }
699
+ function ear(o, i, t1, t2, b1, b2) {
700
+ const h = dist(o, i);
701
+ return h === 0 ? 0 : (dist(t1, b1) + dist(t2, b2)) / (2 * h);
702
+ }
703
+ function dist(a, b) {
704
+ return Math.hypot(a.x - b.x, a.y - b.y);
705
+ }
706
+ function toDeg(r) { return (r * 180) / Math.PI; }
707
+ async function loadTasksVision() {
708
+ return (await import("https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest"));
709
+ }
710
+ async function loadLandmarkerOnce(modelUrl, wasmUrl) {
711
+ const module = await loadTasksVision();
712
+ const vision = await module.FilesetResolver.forVisionTasks(wasmUrl);
713
+ return module.FaceLandmarker.createFromOptions(vision, {
714
+ baseOptions: {
715
+ modelAssetPath: modelUrl,
716
+ delegate: "GPU",
717
+ },
718
+ runningMode: "VIDEO",
719
+ numFaces: 2,
720
+ outputFaceBlendshapes: true,
721
+ outputFacialTransformationMatrixes: true,
722
+ });
723
+ }
724
+ function withTimeout(p, ms) {
725
+ return new Promise((resolve, reject) => {
726
+ const t = setTimeout(() => reject(new Error("timeout")), ms);
727
+ p.then((v) => {
728
+ clearTimeout(t);
729
+ resolve(v);
730
+ }, (e) => {
731
+ clearTimeout(t);
732
+ reject(e);
733
+ });
734
+ });
735
+ }
736
+ async function createLandmarkerWithRetry(opts, maxAttempts) {
737
+ const modelUrl = opts.modelUrl ?? DEFAULT_MODEL_URL;
738
+ const wasmUrl = opts.wasmUrl ?? DEFAULT_WASM_URL;
739
+ let lastError;
740
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
741
+ try {
742
+ const landmarker = await withTimeout(loadLandmarkerOnce(modelUrl, wasmUrl), LOAD_ATTEMPT_TIMEOUT_MS);
743
+ return landmarker;
744
+ }
745
+ catch (err) {
746
+ lastError = err;
747
+ if (!isRetriableCdnError(err))
748
+ throw err;
749
+ if (attempt === 1) {
750
+ const online = await checkConnectivity();
751
+ if (!online) {
752
+ if (typeof console !== "undefined" && console.debug) {
753
+ console.debug("liveness: connectivity check failed (offline)");
754
+ }
755
+ throw new LivenessError(LIVENESS_ERROR_OFFLINE, "No internet connection");
756
+ }
757
+ }
758
+ if (attempt < maxAttempts) {
759
+ if (typeof console !== "undefined" && console.debug) {
760
+ console.debug(`liveness: cdn-retry attempt ${attempt + 1}/${maxAttempts}`);
761
+ }
762
+ }
763
+ else {
764
+ if (typeof console !== "undefined" && console.debug) {
765
+ console.debug("liveness: cdnNotAvailable after max retries");
766
+ }
767
+ throw new LivenessError(LIVENESS_ERROR_CDN_NOT_AVAILABLE, "CDN not available. Please try again later.");
768
+ }
769
+ }
770
+ }
771
+ throw lastError;
772
+ }