@sekyro/web 0.0.0-stage → 0.1.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/LICENSE +20 -0
- package/README.md +92 -2
- package/dist/capture-constraints-BujkZcfb.d.cts +21 -0
- package/dist/capture-constraints-BujkZcfb.d.ts +21 -0
- package/dist/capture-session-v3-BJ8ciaWl.d.ts +457 -0
- package/dist/capture-session-v3-CAoWM8iT.d.cts +457 -0
- package/dist/chunk-BPYVZUJA.js +852 -0
- package/dist/chunk-BPYVZUJA.js.map +1 -0
- package/dist/chunk-LOTAJDRE.js +1094 -0
- package/dist/chunk-LOTAJDRE.js.map +1 -0
- package/dist/chunk-MWNFSNMP.js +52 -0
- package/dist/chunk-MWNFSNMP.js.map +1 -0
- package/dist/chunk-NILNKLK6.js +50 -0
- package/dist/chunk-NILNKLK6.js.map +1 -0
- package/dist/chunk-NWF46H7K.js +359 -0
- package/dist/chunk-NWF46H7K.js.map +1 -0
- package/dist/chunk-WY6U7AIK.js +193 -0
- package/dist/chunk-WY6U7AIK.js.map +1 -0
- package/dist/chunk-YVFF4Q4G.js +327 -0
- package/dist/chunk-YVFF4Q4G.js.map +1 -0
- package/dist/client/index.cjs +357 -0
- package/dist/client/index.cjs.map +1 -0
- package/dist/client/index.d.cts +39 -0
- package/dist/client/index.d.ts +39 -0
- package/dist/client/index.js +15 -0
- package/dist/client/index.js.map +1 -0
- package/dist/core/index.cjs +1605 -0
- package/dist/core/index.cjs.map +1 -0
- package/dist/core/index.d.cts +87 -0
- package/dist/core/index.d.ts +87 -0
- package/dist/core/index.js +139 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/v3/detect-worker.cjs +392 -0
- package/dist/core/v3/detect-worker.cjs.map +1 -0
- package/dist/core/v3/detect-worker.d.cts +1 -0
- package/dist/core/v3/detect-worker.d.ts +1 -0
- package/dist/core/v3/detect-worker.js +13 -0
- package/dist/core/v3/detect-worker.js.map +1 -0
- package/dist/create-verification-CjLnJ0w6.d.ts +348 -0
- package/dist/create-verification-EnZ13uY4.d.cts +348 -0
- package/dist/detect-worker-CKXHlXx9.d.cts +105 -0
- package/dist/detect-worker-CKXHlXx9.d.ts +105 -0
- package/dist/detector-scanic-4C6IEW2K.js +787 -0
- package/dist/detector-scanic-4C6IEW2K.js.map +1 -0
- package/dist/hosted-client-BpvBqu9f.d.cts +408 -0
- package/dist/hosted-client-BpvBqu9f.d.ts +408 -0
- package/dist/index.cjs +2636 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/index.cjs +4166 -0
- package/dist/internal/index.cjs.map +1 -0
- package/dist/internal/index.d.cts +431 -0
- package/dist/internal/index.d.ts +431 -0
- package/dist/internal/index.js +647 -0
- package/dist/internal/index.js.map +1 -0
- package/dist/popup/index.cjs +76 -0
- package/dist/popup/index.cjs.map +1 -0
- package/dist/popup/index.d.cts +66 -0
- package/dist/popup/index.d.ts +66 -0
- package/dist/popup/index.js +11 -0
- package/dist/popup/index.js.map +1 -0
- package/dist/scanic-ml-stub-S2OZ6HEF.js +10 -0
- package/dist/scanic-ml-stub-S2OZ6HEF.js.map +1 -0
- package/package.json +118 -4
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2636 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var src_exports = {};
|
|
32
|
+
__export(src_exports, {
|
|
33
|
+
DEFAULT_API_ORIGIN: () => DEFAULT_API_ORIGIN,
|
|
34
|
+
HostedApiError: () => HostedApiError,
|
|
35
|
+
KNOWN_CODES: () => KNOWN_CODES,
|
|
36
|
+
SEKYRO_EVENTS: () => SEKYRO_EVENTS,
|
|
37
|
+
createLivenessCapture: () => createLivenessCapture,
|
|
38
|
+
createVerification: () => createVerification,
|
|
39
|
+
isKnownCode: () => isKnownCode,
|
|
40
|
+
openHosted: () => openHosted
|
|
41
|
+
});
|
|
42
|
+
module.exports = __toCommonJS(src_exports);
|
|
43
|
+
|
|
44
|
+
// src/core/capture-constraints.ts
|
|
45
|
+
function buildCaptureConstraints(isMobile, facingMode) {
|
|
46
|
+
void isMobile;
|
|
47
|
+
return {
|
|
48
|
+
video: {
|
|
49
|
+
width: { ideal: 1280, max: 1280 },
|
|
50
|
+
height: { ideal: 720, max: 720 },
|
|
51
|
+
facingMode,
|
|
52
|
+
frameRate: { ideal: 24, max: 30 }
|
|
53
|
+
},
|
|
54
|
+
audio: false
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function detectIsMobile() {
|
|
58
|
+
if (typeof window === "undefined" || typeof navigator === "undefined") return false;
|
|
59
|
+
return window.innerWidth <= 768 || /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
|
60
|
+
String(navigator.userAgent ?? "")
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/core/media.ts
|
|
65
|
+
var MIME_CANDIDATES = [
|
|
66
|
+
"video/mp4;codecs=avc1.42E01E,mp4a.40.2",
|
|
67
|
+
"video/mp4;codecs=avc1.42E01E",
|
|
68
|
+
"video/mp4",
|
|
69
|
+
"video/webm;codecs=vp9,opus",
|
|
70
|
+
"video/webm;codecs=vp9",
|
|
71
|
+
"video/webm;codecs=vp8,opus",
|
|
72
|
+
"video/webm;codecs=vp8",
|
|
73
|
+
"video/webm"
|
|
74
|
+
];
|
|
75
|
+
function pickMimeType() {
|
|
76
|
+
const MR = globalThis.MediaRecorder;
|
|
77
|
+
if (!MR || typeof MR.isTypeSupported !== "function") return "";
|
|
78
|
+
return MIME_CANDIDATES.find((t) => MR.isTypeSupported(t)) ?? "";
|
|
79
|
+
}
|
|
80
|
+
var DEFAULT_CLIP_MIME = "video/webm";
|
|
81
|
+
function mapGetUserMediaError(err) {
|
|
82
|
+
if (err instanceof TypeError) return "insecure";
|
|
83
|
+
const name = err?.name ?? "";
|
|
84
|
+
switch (name) {
|
|
85
|
+
case "NotAllowedError":
|
|
86
|
+
case "PermissionDeniedError":
|
|
87
|
+
return "permission";
|
|
88
|
+
case "NotFoundError":
|
|
89
|
+
case "DevicesNotFoundError":
|
|
90
|
+
return "notfound";
|
|
91
|
+
case "NotReadableError":
|
|
92
|
+
case "TrackStartError":
|
|
93
|
+
return "inuse";
|
|
94
|
+
case "OverconstrainedError":
|
|
95
|
+
case "ConstraintNotSatisfiedError":
|
|
96
|
+
return "overconstrained";
|
|
97
|
+
default:
|
|
98
|
+
return "generic";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function cameraPreflight() {
|
|
102
|
+
const w = globalThis;
|
|
103
|
+
if (typeof w.window !== "undefined" && w.window.isSecureContext === false)
|
|
104
|
+
return "INSECURE_CONTEXT";
|
|
105
|
+
const md = w.navigator?.mediaDevices;
|
|
106
|
+
if (!md || typeof md.getUserMedia !== "function") return "UNSUPPORTED";
|
|
107
|
+
return "OK";
|
|
108
|
+
}
|
|
109
|
+
function isMediaRecorderSupported() {
|
|
110
|
+
const MR = globalThis.MediaRecorder;
|
|
111
|
+
return typeof MR !== "undefined" && typeof MR.isTypeSupported === "function";
|
|
112
|
+
}
|
|
113
|
+
var MAX_CLIP_BYTES = 8 * 1024 * 1024;
|
|
114
|
+
function isClipTooLarge(blob) {
|
|
115
|
+
return blob.size > MAX_CLIP_BYTES;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/core/clip-recorder.ts
|
|
119
|
+
var CLIP_MS = 2500;
|
|
120
|
+
var TIMESLICE_MS = 1e3;
|
|
121
|
+
var STOP_GRACE_MS = 1500;
|
|
122
|
+
var VIDEO_BPS = 25e5;
|
|
123
|
+
var ClipError = class extends Error {
|
|
124
|
+
constructor(kind, msg) {
|
|
125
|
+
super(msg ?? kind);
|
|
126
|
+
this.kind = kind;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
var ClipRecorder = class {
|
|
130
|
+
constructor() {
|
|
131
|
+
this.recorder = null;
|
|
132
|
+
this.stream = null;
|
|
133
|
+
// own field so teardown can stop tracks (REVIEW FIX 8)
|
|
134
|
+
this.chunks = [];
|
|
135
|
+
this.bytes = 0;
|
|
136
|
+
this.hardStopTimer = null;
|
|
137
|
+
this.graceTimer = null;
|
|
138
|
+
this.settled = false;
|
|
139
|
+
}
|
|
140
|
+
clearTimers() {
|
|
141
|
+
if (this.hardStopTimer) clearTimeout(this.hardStopTimer);
|
|
142
|
+
if (this.graceTimer) clearTimeout(this.graceTimer);
|
|
143
|
+
this.hardStopTimer = null;
|
|
144
|
+
this.graceTimer = null;
|
|
145
|
+
}
|
|
146
|
+
/** Records CLIP_MS of the stream. Resolves with the clip blob + its container mime;
|
|
147
|
+
* rejects with ClipError. Behavior contract is the v1 hook's `start`, verbatim
|
|
148
|
+
* (iOS workarounds included).
|
|
149
|
+
*
|
|
150
|
+
* ONE RECORDING AT A TIME: chunk/byte/timer state is shared per instance, so a
|
|
151
|
+
* second record() while one is in flight rejects ClipError("generic") instead of
|
|
152
|
+
* corrupting the in-flight clip. (v2 guard — v1's hook was gated by its owning
|
|
153
|
+
* component; a class API invites reuse. Zero impact on clip bytes.)
|
|
154
|
+
*
|
|
155
|
+
* `onStopped` (optional) fires ONCE the moment the CLIP_MS recording window
|
|
156
|
+
* elapses (the hard-stop), BEFORE finalize — which on iOS can lag up to
|
|
157
|
+
* STOP_GRACE_MS while onstop flushes the last chunk. It lets the host freeze
|
|
158
|
+
* the display on the already-captured keyframe the instant recording ends,
|
|
159
|
+
* instead of waiting for the blob. PURE NOTIFICATION — it does not touch
|
|
160
|
+
* timers, chunks, or the finalize path, so it is calibration-neutral (no
|
|
161
|
+
* effect on clip bytes). Never fires on a rejected capture (error /
|
|
162
|
+
* cliptoolarge settle before the hard-stop and clear the timer). */
|
|
163
|
+
record(stream, onStopped) {
|
|
164
|
+
if (this.recorder && !this.settled) {
|
|
165
|
+
return Promise.reject(
|
|
166
|
+
new ClipError(
|
|
167
|
+
"generic",
|
|
168
|
+
"record() called while a recording is in flight \u2014 ClipRecorder handles one recording at a time"
|
|
169
|
+
)
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
this.recorder = null;
|
|
173
|
+
this.clearTimers();
|
|
174
|
+
this.chunks = [];
|
|
175
|
+
this.bytes = 0;
|
|
176
|
+
this.settled = false;
|
|
177
|
+
this.stream = stream;
|
|
178
|
+
const picked = pickMimeType();
|
|
179
|
+
return new Promise((resolve, reject) => {
|
|
180
|
+
let recorder;
|
|
181
|
+
try {
|
|
182
|
+
recorder = new MediaRecorder(
|
|
183
|
+
stream,
|
|
184
|
+
picked ? { mimeType: picked, videoBitsPerSecond: VIDEO_BPS } : { videoBitsPerSecond: VIDEO_BPS }
|
|
185
|
+
);
|
|
186
|
+
} catch {
|
|
187
|
+
try {
|
|
188
|
+
recorder = new MediaRecorder(stream);
|
|
189
|
+
} catch {
|
|
190
|
+
reject(new ClipError("unsupported"));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
this.recorder = recorder;
|
|
195
|
+
const settle = (fn) => {
|
|
196
|
+
if (this.settled) return;
|
|
197
|
+
this.settled = true;
|
|
198
|
+
this.clearTimers();
|
|
199
|
+
fn();
|
|
200
|
+
};
|
|
201
|
+
const finalize = () => settle(() => {
|
|
202
|
+
const type = recorder.mimeType || picked || DEFAULT_CLIP_MIME;
|
|
203
|
+
const blob = new Blob(this.chunks, { type });
|
|
204
|
+
if (isClipTooLarge(blob)) {
|
|
205
|
+
reject(new ClipError("cliptoolarge"));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
resolve({ blob, mimeType: type });
|
|
209
|
+
});
|
|
210
|
+
recorder.ondataavailable = (e) => {
|
|
211
|
+
if (e.data && e.data.size) {
|
|
212
|
+
this.chunks.push(e.data);
|
|
213
|
+
this.bytes += e.data.size;
|
|
214
|
+
if (this.bytes > MAX_CLIP_BYTES) {
|
|
215
|
+
settle(() => {
|
|
216
|
+
try {
|
|
217
|
+
if (recorder.state !== "inactive") recorder.stop();
|
|
218
|
+
} catch {
|
|
219
|
+
}
|
|
220
|
+
reject(new ClipError("cliptoolarge"));
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
recorder.onstop = finalize;
|
|
226
|
+
recorder.onerror = () => settle(() => reject(new ClipError("generic")));
|
|
227
|
+
try {
|
|
228
|
+
recorder.start(TIMESLICE_MS);
|
|
229
|
+
} catch {
|
|
230
|
+
settle(() => reject(new ClipError("generic")));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
this.hardStopTimer = setTimeout(() => {
|
|
234
|
+
if (!this.settled) onStopped?.();
|
|
235
|
+
if (recorder.state !== "inactive") {
|
|
236
|
+
try {
|
|
237
|
+
recorder.stop();
|
|
238
|
+
} catch {
|
|
239
|
+
finalize();
|
|
240
|
+
}
|
|
241
|
+
this.graceTimer = setTimeout(finalize, STOP_GRACE_MS);
|
|
242
|
+
} else {
|
|
243
|
+
finalize();
|
|
244
|
+
}
|
|
245
|
+
}, CLIP_MS);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
/** Idempotent teardown: stop recorder + ALL tracks (v1 `stopAndCleanup` + the unmount
|
|
249
|
+
* effect). Optional `stream` override mirrors v1's `stopAndCleanup(stream?)`.
|
|
250
|
+
*
|
|
251
|
+
* MID-RECORDING SEMANTICS (verbatim v1): calling stop() while a record() is in
|
|
252
|
+
* flight may LATER resolve the pending promise with a TRUNCATED clip (recorder.stop()
|
|
253
|
+
* fires onstop → finalize from whatever chunks arrived so far), or — because this
|
|
254
|
+
* method clears the hard-stop/grace timers — never settle it at all if onstop never
|
|
255
|
+
* fires (the iOS grace path has been torn down). Callers MUST NOT treat a resolution
|
|
256
|
+
* arriving after stop() as a full CLIP_MS clip, and must not await a record()
|
|
257
|
+
* promise past stop() without their own timeout. (Task 4's CaptureSession owns
|
|
258
|
+
* sequencing on top of this contract.) */
|
|
259
|
+
stop(stream) {
|
|
260
|
+
this.clearTimers();
|
|
261
|
+
const rec = this.recorder;
|
|
262
|
+
if (rec && rec.state !== "inactive") {
|
|
263
|
+
try {
|
|
264
|
+
rec.stop();
|
|
265
|
+
} catch {
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
this.recorder = null;
|
|
269
|
+
const s = stream ?? this.stream;
|
|
270
|
+
s?.getTracks().forEach((t) => t.stop());
|
|
271
|
+
this.stream = null;
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// src/core/keyframe.ts
|
|
276
|
+
function captureMirroredKeyframe(video, canvas, quality = 0.92) {
|
|
277
|
+
return new Promise((resolve, reject) => {
|
|
278
|
+
const w = video.videoWidth;
|
|
279
|
+
const h = video.videoHeight;
|
|
280
|
+
if (!w || !h) {
|
|
281
|
+
reject(new Error("video-not-ready"));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const ctx = canvas.getContext("2d");
|
|
285
|
+
if (!ctx) {
|
|
286
|
+
reject(new Error("no-2d-context"));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
canvas.width = w;
|
|
290
|
+
canvas.height = h;
|
|
291
|
+
ctx.scale(-1, 1);
|
|
292
|
+
ctx.drawImage(video, -w, 0);
|
|
293
|
+
canvas.toBlob(
|
|
294
|
+
(blob) => blob ? resolve(blob) : reject(new Error("empty-keyframe")),
|
|
295
|
+
"image/jpeg",
|
|
296
|
+
quality
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// src/core/v3/detector-blaze.ts
|
|
302
|
+
var BLAZE_INIT_TIMEOUT_MS = 1e4;
|
|
303
|
+
var BlazeDetector = class _BlazeDetector {
|
|
304
|
+
constructor(inner, fallbackReason) {
|
|
305
|
+
this.tier = "blaze";
|
|
306
|
+
this.inner = inner;
|
|
307
|
+
if (fallbackReason !== void 0) this.fallbackReason = fallbackReason;
|
|
308
|
+
}
|
|
309
|
+
static async create(assetBaseUrl, fallbackReason) {
|
|
310
|
+
const vision = await import("@mediapipe/tasks-vision");
|
|
311
|
+
const fileset = await vision.FilesetResolver.forVisionTasks(assetBaseUrl);
|
|
312
|
+
const inner = await vision.FaceDetector.createFromOptions(fileset, {
|
|
313
|
+
baseOptions: {
|
|
314
|
+
modelAssetPath: `${assetBaseUrl}/blaze_face_short_range.tflite`,
|
|
315
|
+
// CPU delegate — the iOS-Safari-safe path (see header). NEVER 'GPU'.
|
|
316
|
+
delegate: "CPU"
|
|
317
|
+
},
|
|
318
|
+
runningMode: "IMAGE"
|
|
319
|
+
});
|
|
320
|
+
return new _BlazeDetector(inner, fallbackReason);
|
|
321
|
+
}
|
|
322
|
+
async detect(source, sourceWidth, sourceHeight) {
|
|
323
|
+
let detections;
|
|
324
|
+
try {
|
|
325
|
+
detections = this.inner.detect(source).detections;
|
|
326
|
+
} catch {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
const best = pickMostConfident(detections);
|
|
330
|
+
if (best?.boundingBox === void 0) return null;
|
|
331
|
+
const size = intrinsicSize(source);
|
|
332
|
+
const eyes = extractEyes(best.keypoints, size);
|
|
333
|
+
const observation = {
|
|
334
|
+
box: {
|
|
335
|
+
x: best.boundingBox.originX,
|
|
336
|
+
y: best.boundingBox.originY,
|
|
337
|
+
width: best.boundingBox.width,
|
|
338
|
+
height: best.boundingBox.height
|
|
339
|
+
},
|
|
340
|
+
...eyes !== void 0 ? { eyes } : {},
|
|
341
|
+
score: best.categories[0]?.score ?? 0
|
|
342
|
+
};
|
|
343
|
+
return toStreamPixels(observation, source, sourceWidth, sourceHeight);
|
|
344
|
+
}
|
|
345
|
+
destroy() {
|
|
346
|
+
try {
|
|
347
|
+
this.inner.close();
|
|
348
|
+
} catch {
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
function pickMostConfident(detections) {
|
|
353
|
+
let best;
|
|
354
|
+
let bestScore = -1;
|
|
355
|
+
for (const detection of detections) {
|
|
356
|
+
const score = detection.categories[0]?.score ?? 0;
|
|
357
|
+
if (score > bestScore) {
|
|
358
|
+
best = detection;
|
|
359
|
+
bestScore = score;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return best;
|
|
363
|
+
}
|
|
364
|
+
function extractEyes(keypoints, size) {
|
|
365
|
+
const a = keypoints?.[0];
|
|
366
|
+
const b = keypoints?.[1];
|
|
367
|
+
if (a === void 0 || b === void 0) return void 0;
|
|
368
|
+
const toSource = (p) => ({ x: p.x * size.width, y: p.y * size.height });
|
|
369
|
+
const [pa, pb] = [toSource(a), toSource(b)];
|
|
370
|
+
return pa.x <= pb.x ? { left: pa, right: pb } : { left: pb, right: pa };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// src/core/v3/detector-native.ts
|
|
374
|
+
function smokeSource() {
|
|
375
|
+
if (typeof OffscreenCanvas === "function") return new OffscreenCanvas(2, 2);
|
|
376
|
+
const canvas = document.createElement("canvas");
|
|
377
|
+
canvas.width = 2;
|
|
378
|
+
canvas.height = 2;
|
|
379
|
+
return canvas;
|
|
380
|
+
}
|
|
381
|
+
async function probeNativeDetector() {
|
|
382
|
+
const ctor = globalThis.FaceDetector;
|
|
383
|
+
if (typeof ctor !== "function") return { ok: false, reason: "unsupported" };
|
|
384
|
+
try {
|
|
385
|
+
const instance = new ctor({ fastMode: true, maxDetectedFaces: 1 });
|
|
386
|
+
await instance.detect(smokeSource());
|
|
387
|
+
return { ok: true, instance };
|
|
388
|
+
} catch (err) {
|
|
389
|
+
return { ok: false, reason: `smoke_failed:${err instanceof Error ? err.message : String(err)}` };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
var NativeDetector = class {
|
|
393
|
+
constructor(inner, fallbackReason) {
|
|
394
|
+
this.tier = "native";
|
|
395
|
+
this.inner = inner;
|
|
396
|
+
if (fallbackReason !== void 0) this.fallbackReason = fallbackReason;
|
|
397
|
+
}
|
|
398
|
+
async detect(source, sourceWidth, sourceHeight) {
|
|
399
|
+
let faces;
|
|
400
|
+
try {
|
|
401
|
+
faces = await this.inner.detect(source);
|
|
402
|
+
} catch {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
const face = faces[0];
|
|
406
|
+
if (face === void 0) return null;
|
|
407
|
+
const eyes = extractEyes2(face.landmarks);
|
|
408
|
+
const observation = {
|
|
409
|
+
box: {
|
|
410
|
+
x: face.boundingBox.x,
|
|
411
|
+
y: face.boundingBox.y,
|
|
412
|
+
width: face.boundingBox.width,
|
|
413
|
+
height: face.boundingBox.height
|
|
414
|
+
},
|
|
415
|
+
...eyes !== void 0 ? { eyes } : {},
|
|
416
|
+
// The Shape Detection API exposes no confidence — the smoke-tested
|
|
417
|
+
// native tier treats any detection as confident.
|
|
418
|
+
score: 1
|
|
419
|
+
};
|
|
420
|
+
return toStreamPixels(observation, source, sourceWidth, sourceHeight);
|
|
421
|
+
}
|
|
422
|
+
destroy() {
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
function extractEyes2(landmarks) {
|
|
426
|
+
if (landmarks === void 0) return void 0;
|
|
427
|
+
const eyePoints = landmarks.filter((landmark) => landmark.type === "eye").map((landmark) => landmark.locations[0]).filter((point) => point !== void 0);
|
|
428
|
+
const [a, b] = eyePoints;
|
|
429
|
+
if (a === void 0 || b === void 0) return void 0;
|
|
430
|
+
return a.x <= b.x ? { left: a, right: b } : { left: b, right: a };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// src/core/v3/detector.ts
|
|
434
|
+
var DEFAULT_V3_ASSET_BASE_URL = "/models-v3";
|
|
435
|
+
var DEFAULT_TIER_ORDER = ["native", "blaze"];
|
|
436
|
+
var DetectorLadderError = class extends Error {
|
|
437
|
+
constructor(fallbackReason) {
|
|
438
|
+
super(`no detector tier available (${fallbackReason})`);
|
|
439
|
+
this.name = "DetectorLadderError";
|
|
440
|
+
this.fallbackReason = fallbackReason;
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
function intrinsicSize(source) {
|
|
444
|
+
const candidate = source;
|
|
445
|
+
if (typeof candidate.videoWidth === "number" && candidate.videoWidth > 0) {
|
|
446
|
+
return { width: candidate.videoWidth, height: candidate.videoHeight ?? 0 };
|
|
447
|
+
}
|
|
448
|
+
if (typeof candidate.naturalWidth === "number" && candidate.naturalWidth > 0) {
|
|
449
|
+
return { width: candidate.naturalWidth, height: candidate.naturalHeight ?? 0 };
|
|
450
|
+
}
|
|
451
|
+
const width = typeof candidate.width === "number" ? candidate.width : 0;
|
|
452
|
+
const height = typeof candidate.height === "number" ? candidate.height : 0;
|
|
453
|
+
return { width, height };
|
|
454
|
+
}
|
|
455
|
+
function toStreamPixels(observation, source, streamWidth, streamHeight) {
|
|
456
|
+
const size = intrinsicSize(source);
|
|
457
|
+
const scaleX = size.width > 0 ? streamWidth / size.width : 1;
|
|
458
|
+
const scaleY = size.height > 0 ? streamHeight / size.height : 1;
|
|
459
|
+
const scalePoint = (p) => ({ x: p.x * scaleX, y: p.y * scaleY });
|
|
460
|
+
return {
|
|
461
|
+
box: {
|
|
462
|
+
x: observation.box.x * scaleX,
|
|
463
|
+
y: observation.box.y * scaleY,
|
|
464
|
+
width: observation.box.width * scaleX,
|
|
465
|
+
height: observation.box.height * scaleY
|
|
466
|
+
},
|
|
467
|
+
...observation.eyes !== void 0 ? { eyes: { left: scalePoint(observation.eyes.left), right: scalePoint(observation.eyes.right) } } : {},
|
|
468
|
+
score: observation.score
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
function describeError(err) {
|
|
472
|
+
return err instanceof Error ? err.message : String(err);
|
|
473
|
+
}
|
|
474
|
+
async function createDetector(options) {
|
|
475
|
+
const order = options.preferred ?? DEFAULT_TIER_ORDER;
|
|
476
|
+
const reasons = [];
|
|
477
|
+
for (const tier of order) {
|
|
478
|
+
if (tier === "native") {
|
|
479
|
+
const probed = await probeNativeDetector();
|
|
480
|
+
if (probed.ok) {
|
|
481
|
+
return new NativeDetector(probed.instance, reasonOf(reasons));
|
|
482
|
+
}
|
|
483
|
+
reasons.push(`native:${probed.reason}`);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
return await withTimeout(
|
|
488
|
+
BlazeDetector.create(
|
|
489
|
+
options.v3AssetBaseUrl ?? DEFAULT_V3_ASSET_BASE_URL,
|
|
490
|
+
reasonOf(reasons)
|
|
491
|
+
),
|
|
492
|
+
options.blazeInitTimeoutMs ?? BLAZE_INIT_TIMEOUT_MS
|
|
493
|
+
);
|
|
494
|
+
} catch (err) {
|
|
495
|
+
reasons.push(
|
|
496
|
+
err instanceof InitTimeoutError ? "blaze:blaze_init_timeout" : `blaze:${describeError(err)}`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
throw new DetectorLadderError(reasonOf(reasons) ?? "no_tier_available");
|
|
501
|
+
}
|
|
502
|
+
function reasonOf(reasons) {
|
|
503
|
+
return reasons.length > 0 ? reasons.join("; ") : void 0;
|
|
504
|
+
}
|
|
505
|
+
var InitTimeoutError = class extends Error {
|
|
506
|
+
};
|
|
507
|
+
function withTimeout(promise, deadlineMs) {
|
|
508
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
509
|
+
const timer = setTimeout(() => {
|
|
510
|
+
rejectPromise(new InitTimeoutError(`init exceeded ${deadlineMs}ms`));
|
|
511
|
+
promise.then(
|
|
512
|
+
(value) => value.destroy(),
|
|
513
|
+
() => void 0
|
|
514
|
+
);
|
|
515
|
+
}, deadlineMs);
|
|
516
|
+
promise.then(
|
|
517
|
+
(value) => {
|
|
518
|
+
clearTimeout(timer);
|
|
519
|
+
resolvePromise(value);
|
|
520
|
+
},
|
|
521
|
+
(err) => {
|
|
522
|
+
clearTimeout(timer);
|
|
523
|
+
rejectPromise(err);
|
|
524
|
+
}
|
|
525
|
+
);
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// src/core/v3/detect-worker.ts
|
|
530
|
+
var WORKER_PROTOCOL_VERSION = 1;
|
|
531
|
+
function workerOffloadSupported() {
|
|
532
|
+
return typeof OffscreenCanvas === "function" && typeof createImageBitmap === "function" && typeof Worker === "function";
|
|
533
|
+
}
|
|
534
|
+
function installWorkerDetectorHost(scope, deps = {}) {
|
|
535
|
+
const create = deps.createDetector ?? createDetector;
|
|
536
|
+
let detector = null;
|
|
537
|
+
scope.onmessage = (event) => {
|
|
538
|
+
const message = event.data;
|
|
539
|
+
if (message.v !== WORKER_PROTOCOL_VERSION) {
|
|
540
|
+
message.bitmap?.close?.();
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (message.type === "init") {
|
|
544
|
+
void create(message.options).then((created) => {
|
|
545
|
+
detector = created;
|
|
546
|
+
const ready = {
|
|
547
|
+
v: WORKER_PROTOCOL_VERSION,
|
|
548
|
+
type: "ready",
|
|
549
|
+
tier: created.tier,
|
|
550
|
+
...created.fallbackReason !== void 0 ? { fallbackReason: created.fallbackReason } : {}
|
|
551
|
+
};
|
|
552
|
+
scope.postMessage(ready);
|
|
553
|
+
});
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (message.type === "detect") {
|
|
557
|
+
const respond = (observation) => {
|
|
558
|
+
const result = {
|
|
559
|
+
v: WORKER_PROTOCOL_VERSION,
|
|
560
|
+
type: "result",
|
|
561
|
+
id: message.id,
|
|
562
|
+
observation
|
|
563
|
+
};
|
|
564
|
+
scope.postMessage(result);
|
|
565
|
+
message.bitmap.close?.();
|
|
566
|
+
};
|
|
567
|
+
if (detector === null) {
|
|
568
|
+
respond(null);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
void detector.detect(message.bitmap, message.streamWidth, message.streamHeight).then(respond, () => respond(null));
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
if (typeof self !== "undefined" && typeof self.importScripts === "function") {
|
|
576
|
+
installWorkerDetectorHost(self);
|
|
577
|
+
}
|
|
578
|
+
var WorkerDetectorProxy = class _WorkerDetectorProxy {
|
|
579
|
+
constructor(worker, ready) {
|
|
580
|
+
this.pending = /* @__PURE__ */ new Map();
|
|
581
|
+
this.nextId = 1;
|
|
582
|
+
this.worker = worker;
|
|
583
|
+
this.tier = ready.tier;
|
|
584
|
+
if (ready.fallbackReason !== void 0) this.fallbackReason = ready.fallbackReason;
|
|
585
|
+
worker.addEventListener("message", (event) => {
|
|
586
|
+
const message = event.data;
|
|
587
|
+
if (message.v !== WORKER_PROTOCOL_VERSION || message.type !== "result") return;
|
|
588
|
+
const resolve = this.pending.get(message.id);
|
|
589
|
+
this.pending.delete(message.id);
|
|
590
|
+
resolve?.(message.observation);
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
static create(worker, options, readyTimeoutMs = 1e4) {
|
|
594
|
+
return new Promise((resolve, reject) => {
|
|
595
|
+
const timer = setTimeout(() => {
|
|
596
|
+
worker.removeEventListener("message", onReady);
|
|
597
|
+
worker.terminate();
|
|
598
|
+
reject(new Error(`worker detector not ready within ${readyTimeoutMs}ms`));
|
|
599
|
+
}, readyTimeoutMs);
|
|
600
|
+
const onReady = (event) => {
|
|
601
|
+
const message = event.data;
|
|
602
|
+
if (message.v !== WORKER_PROTOCOL_VERSION || message.type !== "ready") return;
|
|
603
|
+
clearTimeout(timer);
|
|
604
|
+
worker.removeEventListener("message", onReady);
|
|
605
|
+
resolve(new _WorkerDetectorProxy(worker, message));
|
|
606
|
+
};
|
|
607
|
+
worker.addEventListener("message", onReady);
|
|
608
|
+
const init = { v: WORKER_PROTOCOL_VERSION, type: "init", options };
|
|
609
|
+
worker.postMessage(init);
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
async detect(source, sourceWidth, sourceHeight) {
|
|
613
|
+
let bitmap;
|
|
614
|
+
try {
|
|
615
|
+
if (typeof ImageBitmap !== "undefined" && source instanceof ImageBitmap) {
|
|
616
|
+
bitmap = source;
|
|
617
|
+
} else if (typeof createImageBitmap === "function") {
|
|
618
|
+
bitmap = await createImageBitmap(source);
|
|
619
|
+
} else {
|
|
620
|
+
bitmap = source;
|
|
621
|
+
}
|
|
622
|
+
} catch {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
const id = this.nextId;
|
|
626
|
+
this.nextId += 1;
|
|
627
|
+
return new Promise((resolve) => {
|
|
628
|
+
this.pending.set(id, resolve);
|
|
629
|
+
const message = {
|
|
630
|
+
v: WORKER_PROTOCOL_VERSION,
|
|
631
|
+
type: "detect",
|
|
632
|
+
id,
|
|
633
|
+
bitmap,
|
|
634
|
+
streamWidth: sourceWidth,
|
|
635
|
+
streamHeight: sourceHeight
|
|
636
|
+
};
|
|
637
|
+
this.worker.postMessage(message, [bitmap]);
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
destroy() {
|
|
641
|
+
for (const resolve of this.pending.values()) resolve(null);
|
|
642
|
+
this.pending.clear();
|
|
643
|
+
this.worker.terminate();
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
// src/core/v3/device-profile.ts
|
|
648
|
+
var DEVICE_PROFILES = Object.freeze({
|
|
649
|
+
LOW: Object.freeze({ cadenceHz: 5, inputWidth: 128, animations: "reduced" }),
|
|
650
|
+
MID: Object.freeze({ cadenceHz: 8, inputWidth: 160, animations: "full" }),
|
|
651
|
+
HIGH: Object.freeze({ cadenceHz: 10, inputWidth: 192, animations: "full" })
|
|
652
|
+
});
|
|
653
|
+
var SIMD_TEST_BYTES = new Uint8Array([
|
|
654
|
+
0,
|
|
655
|
+
97,
|
|
656
|
+
115,
|
|
657
|
+
109,
|
|
658
|
+
1,
|
|
659
|
+
0,
|
|
660
|
+
0,
|
|
661
|
+
0,
|
|
662
|
+
1,
|
|
663
|
+
5,
|
|
664
|
+
1,
|
|
665
|
+
96,
|
|
666
|
+
0,
|
|
667
|
+
1,
|
|
668
|
+
123,
|
|
669
|
+
3,
|
|
670
|
+
2,
|
|
671
|
+
1,
|
|
672
|
+
0,
|
|
673
|
+
10,
|
|
674
|
+
10,
|
|
675
|
+
1,
|
|
676
|
+
8,
|
|
677
|
+
0,
|
|
678
|
+
65,
|
|
679
|
+
0,
|
|
680
|
+
253,
|
|
681
|
+
15,
|
|
682
|
+
253,
|
|
683
|
+
98,
|
|
684
|
+
11
|
|
685
|
+
]);
|
|
686
|
+
function probeDevice() {
|
|
687
|
+
const nav = typeof navigator === "undefined" ? void 0 : navigator;
|
|
688
|
+
let simd = false;
|
|
689
|
+
try {
|
|
690
|
+
simd = typeof WebAssembly !== "undefined" && WebAssembly.validate(SIMD_TEST_BYTES);
|
|
691
|
+
} catch {
|
|
692
|
+
simd = false;
|
|
693
|
+
}
|
|
694
|
+
return {
|
|
695
|
+
cores: nav?.hardwareConcurrency ?? 2,
|
|
696
|
+
memGB: nav?.deviceMemory ?? null,
|
|
697
|
+
simd
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
function classifyDevice(probe) {
|
|
701
|
+
const memGB = probe.memGB ?? 4;
|
|
702
|
+
if (probe.cores < 4 || memGB < 3) return "LOW";
|
|
703
|
+
if (probe.cores >= 8 && memGB >= 6) return "HIGH";
|
|
704
|
+
return "MID";
|
|
705
|
+
}
|
|
706
|
+
var ORDER = ["LOW", "MID", "HIGH"];
|
|
707
|
+
var DeviceProfiler = class {
|
|
708
|
+
constructor(initial) {
|
|
709
|
+
this.samples = [];
|
|
710
|
+
this.adjusted = false;
|
|
711
|
+
this.current = initial;
|
|
712
|
+
}
|
|
713
|
+
get deviceClass() {
|
|
714
|
+
return this.current;
|
|
715
|
+
}
|
|
716
|
+
get profile() {
|
|
717
|
+
return DEVICE_PROFILES[this.current];
|
|
718
|
+
}
|
|
719
|
+
record(detectMs) {
|
|
720
|
+
if (this.adjusted || this.samples.length >= 10) return this.current;
|
|
721
|
+
this.samples.push(detectMs);
|
|
722
|
+
if (this.samples.length < 10) return this.current;
|
|
723
|
+
this.adjusted = true;
|
|
724
|
+
const mean = this.samples.reduce((sum, value) => sum + value, 0) / this.samples.length;
|
|
725
|
+
if (mean > 70) {
|
|
726
|
+
this.current = "LOW";
|
|
727
|
+
} else if (mean < 25) {
|
|
728
|
+
const index = ORDER.indexOf(this.current);
|
|
729
|
+
this.current = ORDER[Math.min(ORDER.length - 1, index + 1)] ?? this.current;
|
|
730
|
+
}
|
|
731
|
+
return this.current;
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
// src/core/v3/detect-scheduler.ts
|
|
736
|
+
var STATS_WINDOW = 20;
|
|
737
|
+
var DetectScheduler = class {
|
|
738
|
+
constructor(options) {
|
|
739
|
+
this.timer = null;
|
|
740
|
+
this.frameCallbackId = null;
|
|
741
|
+
this.running = false;
|
|
742
|
+
this.busy = false;
|
|
743
|
+
this.lastRunAt = Number.NEGATIVE_INFINITY;
|
|
744
|
+
this.degradeCount = 0;
|
|
745
|
+
this.durations = [];
|
|
746
|
+
this.options = options;
|
|
747
|
+
this.periodMs = 1e3 / options.cadenceHz;
|
|
748
|
+
}
|
|
749
|
+
start() {
|
|
750
|
+
if (this.running) return;
|
|
751
|
+
this.running = true;
|
|
752
|
+
const video = this.options.video;
|
|
753
|
+
if (typeof video?.requestVideoFrameCallback === "function") {
|
|
754
|
+
const loop = () => {
|
|
755
|
+
if (!this.running) return;
|
|
756
|
+
this.frameCallbackId = video.requestVideoFrameCallback?.(() => {
|
|
757
|
+
this.frameCallbackId = null;
|
|
758
|
+
if (this.now() - this.lastRunAt >= this.periodMs) this.tick();
|
|
759
|
+
loop();
|
|
760
|
+
});
|
|
761
|
+
};
|
|
762
|
+
loop();
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
this.timer = setInterval(() => this.tick(), this.periodMs);
|
|
766
|
+
}
|
|
767
|
+
stop() {
|
|
768
|
+
this.running = false;
|
|
769
|
+
if (this.timer !== null) {
|
|
770
|
+
clearInterval(this.timer);
|
|
771
|
+
this.timer = null;
|
|
772
|
+
}
|
|
773
|
+
if (this.frameCallbackId !== null) {
|
|
774
|
+
this.options.video?.cancelVideoFrameCallback?.(
|
|
775
|
+
this.frameCallbackId
|
|
776
|
+
);
|
|
777
|
+
this.frameCallbackId = null;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
stats() {
|
|
781
|
+
const meanDetectMs = this.durations.length === 0 ? 0 : this.durations.reduce((sum, value) => sum + value, 0) / this.durations.length;
|
|
782
|
+
return { degradeCount: this.degradeCount, meanDetectMs };
|
|
783
|
+
}
|
|
784
|
+
now() {
|
|
785
|
+
return this.options.now?.() ?? performance.now();
|
|
786
|
+
}
|
|
787
|
+
tick() {
|
|
788
|
+
if (!this.running) return;
|
|
789
|
+
if (this.busy) {
|
|
790
|
+
this.degradeCount += 1;
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
this.busy = true;
|
|
794
|
+
this.lastRunAt = this.now();
|
|
795
|
+
const startedAt = this.now();
|
|
796
|
+
this.options.run().catch(() => void 0).finally(() => {
|
|
797
|
+
this.busy = false;
|
|
798
|
+
this.durations.push(this.now() - startedAt);
|
|
799
|
+
if (this.durations.length > STATS_WINDOW) this.durations.shift();
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
// src/core/v3/frame-quality.ts
|
|
805
|
+
var SHARP_REF = 180;
|
|
806
|
+
var EXPOSURE_BAND = [100, 190];
|
|
807
|
+
var EXPOSURE_FALLOFF = 64;
|
|
808
|
+
function lumaAt(data, index) {
|
|
809
|
+
return 0.299 * (data[index] ?? 0) + 0.587 * (data[index + 1] ?? 0) + 0.114 * (data[index + 2] ?? 0);
|
|
810
|
+
}
|
|
811
|
+
function meanLuma(image) {
|
|
812
|
+
const { data, width, height } = image;
|
|
813
|
+
const pixels = width * height;
|
|
814
|
+
if (pixels === 0) return 0;
|
|
815
|
+
let sum = 0;
|
|
816
|
+
for (let i = 0; i < pixels; i += 1) sum += lumaAt(data, i * 4);
|
|
817
|
+
return sum / pixels;
|
|
818
|
+
}
|
|
819
|
+
function sharpness(image) {
|
|
820
|
+
const { data, width, height } = image;
|
|
821
|
+
if (width < 3 || height < 3) return 0;
|
|
822
|
+
const gray = new Float32Array(width * height);
|
|
823
|
+
for (let i = 0; i < gray.length; i += 1) gray[i] = lumaAt(data, i * 4);
|
|
824
|
+
let sum = 0;
|
|
825
|
+
let sumSquares = 0;
|
|
826
|
+
let count = 0;
|
|
827
|
+
for (let y = 1; y < height - 1; y += 1) {
|
|
828
|
+
for (let x = 1; x < width - 1; x += 1) {
|
|
829
|
+
const i = y * width + x;
|
|
830
|
+
const response = (gray[i - width] ?? 0) + (gray[i - 1] ?? 0) + (gray[i + 1] ?? 0) + (gray[i + width] ?? 0) - 4 * (gray[i] ?? 0);
|
|
831
|
+
sum += response;
|
|
832
|
+
sumSquares += response * response;
|
|
833
|
+
count += 1;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
if (count === 0) return 0;
|
|
837
|
+
const mean = sum / count;
|
|
838
|
+
const variance = sumSquares / count - mean * mean;
|
|
839
|
+
return Math.min(1, Math.max(0, variance) / SHARP_REF);
|
|
840
|
+
}
|
|
841
|
+
function exposure(image) {
|
|
842
|
+
const mean = meanLuma(image);
|
|
843
|
+
const [low, high] = EXPOSURE_BAND;
|
|
844
|
+
const distance = mean < low ? low - mean : mean > high ? mean - high : 0;
|
|
845
|
+
return Math.max(0, 1 - distance / EXPOSURE_FALLOFF);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// src/core/v3/face-gate-v3.ts
|
|
849
|
+
var V3_GATE_DEFAULTS = Object.freeze({
|
|
850
|
+
Q_GOOD: 0.6,
|
|
851
|
+
RISE_PER_S: 0.9,
|
|
852
|
+
FALL_PER_S: 0.25,
|
|
853
|
+
CENTER_FALLOFF_IOD_MULTIPLE: 1.35,
|
|
854
|
+
BOX_EYE_OFFSET_FACTOR: 0.12,
|
|
855
|
+
IOD_BOX_PROXY_FACTOR: 0.42,
|
|
856
|
+
SIZE_IDEAL_MIN: 0.12,
|
|
857
|
+
SIZE_IDEAL_MAX: 0.3,
|
|
858
|
+
SIZE_HARD_MIN: 0.08,
|
|
859
|
+
SIZE_HARD_MAX: 0.45,
|
|
860
|
+
ROLL_MAX_DEG: 25,
|
|
861
|
+
MOTION_VELOCITY_IOD_PER_S: 0.5,
|
|
862
|
+
MOTION_TAU_S: 0.4,
|
|
863
|
+
MOTION_DEADBAND_IOD_PER_S: 0.1,
|
|
864
|
+
READY_WINDOW_MS: 500,
|
|
865
|
+
// 0.7→0.75 with the trimmed window: trimming forgives exactly ONE flaky
|
|
866
|
+
// tick; the raised mean keeps a SECOND bad tick (a real sustained wobble)
|
|
867
|
+
// from diluting through at 10 Hz cadences.
|
|
868
|
+
READY_MEAN_Q: 0.75,
|
|
869
|
+
GUIDE_SIZE: 0.6,
|
|
870
|
+
GUIDE_CENTERING: 0.6,
|
|
871
|
+
GUIDE_POSE: 0.6,
|
|
872
|
+
GUIDE_EXPOSURE: 0.6,
|
|
873
|
+
GUIDE_SHARPNESS: 0.35,
|
|
874
|
+
GUIDE_MOTION: 0.5
|
|
875
|
+
});
|
|
876
|
+
var clamp01 = (value) => Math.min(1, Math.max(0, value));
|
|
877
|
+
var FaceGateV3 = class {
|
|
878
|
+
constructor(overrides = {}) {
|
|
879
|
+
this.p = 0;
|
|
880
|
+
this.lastTickAt = null;
|
|
881
|
+
this.lastCenter = null;
|
|
882
|
+
this.velocityEma = 0;
|
|
883
|
+
this.qWindow = [];
|
|
884
|
+
/** Which side of the ideal band the last size measurement fell on. */
|
|
885
|
+
this.lastSizeDirection = "ideal";
|
|
886
|
+
this.t = { ...V3_GATE_DEFAULTS, ...overrides };
|
|
887
|
+
}
|
|
888
|
+
reset() {
|
|
889
|
+
this.p = 0;
|
|
890
|
+
this.lastTickAt = null;
|
|
891
|
+
this.lastCenter = null;
|
|
892
|
+
this.velocityEma = 0;
|
|
893
|
+
this.qWindow = [];
|
|
894
|
+
this.lastSizeDirection = "ideal";
|
|
895
|
+
}
|
|
896
|
+
tick(input) {
|
|
897
|
+
const { observation, quality, timestampMs } = input;
|
|
898
|
+
const dtSeconds = this.lastTickAt === null ? 0 : Math.max(0, (timestampMs - this.lastTickAt) / 1e3);
|
|
899
|
+
this.lastTickAt = timestampMs;
|
|
900
|
+
const factors = {
|
|
901
|
+
presence: 0,
|
|
902
|
+
centering: 0,
|
|
903
|
+
size: 0,
|
|
904
|
+
pose: 0,
|
|
905
|
+
sharpness: clamp01(quality.sharpness),
|
|
906
|
+
exposure: clamp01(quality.exposure),
|
|
907
|
+
motion: 1
|
|
908
|
+
};
|
|
909
|
+
if (observation !== null) {
|
|
910
|
+
factors.presence = 1;
|
|
911
|
+
const geometry = this.geometryOf(observation, input);
|
|
912
|
+
factors.centering = geometry.centering;
|
|
913
|
+
factors.size = geometry.size;
|
|
914
|
+
factors.pose = geometry.pose;
|
|
915
|
+
factors.motion = this.motionOf(
|
|
916
|
+
geometry.center,
|
|
917
|
+
geometry.iod,
|
|
918
|
+
timestampMs
|
|
919
|
+
);
|
|
920
|
+
} else {
|
|
921
|
+
this.lastCenter = null;
|
|
922
|
+
this.velocityEma = 0;
|
|
923
|
+
}
|
|
924
|
+
const q = Math.min(
|
|
925
|
+
factors.presence,
|
|
926
|
+
factors.centering,
|
|
927
|
+
factors.size,
|
|
928
|
+
factors.pose,
|
|
929
|
+
factors.sharpness,
|
|
930
|
+
factors.exposure,
|
|
931
|
+
factors.motion
|
|
932
|
+
);
|
|
933
|
+
this.p = clamp01(
|
|
934
|
+
this.p + (q >= this.t.Q_GOOD ? this.t.RISE_PER_S * q : -this.t.FALL_PER_S * (1 - q)) * dtSeconds
|
|
935
|
+
);
|
|
936
|
+
this.qWindow.push({ at: timestampMs, q });
|
|
937
|
+
this.qWindow = this.qWindow.filter(
|
|
938
|
+
(entry) => entry.at >= timestampMs - this.t.READY_WINDOW_MS
|
|
939
|
+
);
|
|
940
|
+
let windowSamples = this.qWindow.map((entry) => entry.q);
|
|
941
|
+
if (windowSamples.length >= 3) {
|
|
942
|
+
const worst = Math.min(...windowSamples);
|
|
943
|
+
const worstIndex = windowSamples.indexOf(worst);
|
|
944
|
+
windowSamples = windowSamples.filter((_, index) => index !== worstIndex);
|
|
945
|
+
}
|
|
946
|
+
const windowMean = windowSamples.reduce((sum, value) => sum + value, 0) / Math.max(1, windowSamples.length);
|
|
947
|
+
const ready = this.p >= 1 && windowMean >= this.t.READY_MEAN_Q;
|
|
948
|
+
return {
|
|
949
|
+
at: timestampMs,
|
|
950
|
+
q,
|
|
951
|
+
p: this.p,
|
|
952
|
+
factors,
|
|
953
|
+
guidanceKey: this.guidanceOf(factors, quality, q),
|
|
954
|
+
ready
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
geometryOf(observation, input) {
|
|
958
|
+
const { box, eyes } = observation;
|
|
959
|
+
const frameCenter = { x: input.frameWidth / 2, y: input.frameHeight / 2 };
|
|
960
|
+
const shortSide = Math.min(input.frameWidth, input.frameHeight);
|
|
961
|
+
const center = eyes !== void 0 ? {
|
|
962
|
+
x: (eyes.left.x + eyes.right.x) / 2,
|
|
963
|
+
y: (eyes.left.y + eyes.right.y) / 2
|
|
964
|
+
} : {
|
|
965
|
+
x: box.x + box.width / 2,
|
|
966
|
+
y: box.y + box.height / 2 - this.t.BOX_EYE_OFFSET_FACTOR * box.height
|
|
967
|
+
};
|
|
968
|
+
const iod = eyes !== void 0 ? Math.hypot(eyes.right.x - eyes.left.x, eyes.right.y - eyes.left.y) : this.t.IOD_BOX_PROXY_FACTOR * box.width;
|
|
969
|
+
const distance = Math.hypot(
|
|
970
|
+
center.x - frameCenter.x,
|
|
971
|
+
center.y - frameCenter.y
|
|
972
|
+
);
|
|
973
|
+
const centering = iod <= 0 ? 0 : 1 - clamp01(distance / (this.t.CENTER_FALLOFF_IOD_MULTIPLE * iod));
|
|
974
|
+
const ratio = shortSide > 0 ? iod / shortSide : 0;
|
|
975
|
+
this.lastSizeDirection = ratio < this.t.SIZE_IDEAL_MIN ? "far" : ratio > this.t.SIZE_IDEAL_MAX ? "close" : "ideal";
|
|
976
|
+
let size;
|
|
977
|
+
if (ratio < this.t.SIZE_HARD_MIN || ratio > this.t.SIZE_HARD_MAX) {
|
|
978
|
+
size = 0;
|
|
979
|
+
} else if (ratio < this.t.SIZE_IDEAL_MIN) {
|
|
980
|
+
size = (ratio - this.t.SIZE_HARD_MIN) / (this.t.SIZE_IDEAL_MIN - this.t.SIZE_HARD_MIN);
|
|
981
|
+
} else if (ratio > this.t.SIZE_IDEAL_MAX) {
|
|
982
|
+
size = (this.t.SIZE_HARD_MAX - ratio) / (this.t.SIZE_HARD_MAX - this.t.SIZE_IDEAL_MAX);
|
|
983
|
+
} else {
|
|
984
|
+
size = 1;
|
|
985
|
+
}
|
|
986
|
+
let pose = 1;
|
|
987
|
+
if (eyes !== void 0) {
|
|
988
|
+
const rollDeg = Math.abs(
|
|
989
|
+
Math.atan2(eyes.right.y - eyes.left.y, eyes.right.x - eyes.left.x)
|
|
990
|
+
) * 180 / Math.PI;
|
|
991
|
+
pose = 1 - clamp01(rollDeg / this.t.ROLL_MAX_DEG);
|
|
992
|
+
}
|
|
993
|
+
return { center, iod, centering, size, pose };
|
|
994
|
+
}
|
|
995
|
+
motionOf(center, iod, at) {
|
|
996
|
+
const previous = this.lastCenter;
|
|
997
|
+
this.lastCenter = { ...center, at };
|
|
998
|
+
if (previous === null || iod <= 0) return 1;
|
|
999
|
+
const dtSeconds = (at - previous.at) / 1e3;
|
|
1000
|
+
if (dtSeconds <= 0) return 1;
|
|
1001
|
+
let velocityIodPerS = Math.hypot(center.x - previous.x, center.y - previous.y) / iod / dtSeconds;
|
|
1002
|
+
if (velocityIodPerS <= this.t.MOTION_DEADBAND_IOD_PER_S)
|
|
1003
|
+
velocityIodPerS = 0;
|
|
1004
|
+
const alpha = 1 - Math.exp(-dtSeconds / this.t.MOTION_TAU_S);
|
|
1005
|
+
this.velocityEma = alpha * velocityIodPerS + (1 - alpha) * this.velocityEma;
|
|
1006
|
+
return 1 - clamp01(this.velocityEma / this.t.MOTION_VELOCITY_IOD_PER_S);
|
|
1007
|
+
}
|
|
1008
|
+
/** Lowest failing factor speaks — fixed priority (diagnostic, incl. light):
|
|
1009
|
+
* presence → size → centering → pose → exposure → sharpness → hold.
|
|
1010
|
+
*
|
|
1011
|
+
* HONESTY FALLBACK (operator ruling 2026-07-12): when every factor clears
|
|
1012
|
+
* its guide threshold but q (= min factor) is still below Q_GOOD — i.e.
|
|
1013
|
+
* progress is DRAINING — "hold still" would be a lie ("Perfect" while
|
|
1014
|
+
* nothing advances; the field symptom was users discovering by accident
|
|
1015
|
+
* that only leaning in fires the gate). In that state the WORST factor
|
|
1016
|
+
* speaks even though it passed its guide level, so the user always hears
|
|
1017
|
+
* the change that actually unblocks recording. */
|
|
1018
|
+
guidanceOf(factors, quality, q) {
|
|
1019
|
+
if (factors.presence <= 0) return "fcv3.no_face";
|
|
1020
|
+
if (factors.size < this.t.GUIDE_SIZE) return this.sizeGuidance();
|
|
1021
|
+
if (factors.centering < this.t.GUIDE_CENTERING) return "fcv3.center_face";
|
|
1022
|
+
if (factors.pose < this.t.GUIDE_POSE) return "fcv3.straighten";
|
|
1023
|
+
if (factors.exposure < this.t.GUIDE_EXPOSURE)
|
|
1024
|
+
return this.exposureGuidance(quality);
|
|
1025
|
+
if (factors.sharpness < this.t.GUIDE_SHARPNESS) return "fcv3.steady_camera";
|
|
1026
|
+
if (factors.motion < this.t.GUIDE_MOTION) return "fcv3.hold_still";
|
|
1027
|
+
if (q < this.t.Q_GOOD) {
|
|
1028
|
+
const coachable = [
|
|
1029
|
+
["size", this.sizeGuidance()],
|
|
1030
|
+
["centering", "fcv3.center_face"],
|
|
1031
|
+
["pose", "fcv3.straighten"],
|
|
1032
|
+
["exposure", this.exposureGuidance(quality)],
|
|
1033
|
+
["sharpness", "fcv3.steady_camera"],
|
|
1034
|
+
["motion", "fcv3.hold_still"]
|
|
1035
|
+
];
|
|
1036
|
+
let worst = coachable[0];
|
|
1037
|
+
for (const entry of coachable) {
|
|
1038
|
+
if (factors[entry[0]] < factors[worst[0]]) worst = entry;
|
|
1039
|
+
}
|
|
1040
|
+
return worst[1];
|
|
1041
|
+
}
|
|
1042
|
+
return "fcv3.hold_still";
|
|
1043
|
+
}
|
|
1044
|
+
/** Direction from the size band set THIS tick (never inverted at the hard
|
|
1045
|
+
* limits: past SIZE_HARD_MAX the direction is 'close' → move_back). */
|
|
1046
|
+
sizeGuidance() {
|
|
1047
|
+
return this.lastSizeDirection === "far" ? "fcv3.come_closer" : "fcv3.move_back";
|
|
1048
|
+
}
|
|
1049
|
+
/** Band midpoint — direction only (tracks EXPOSURE_BAND automatically). */
|
|
1050
|
+
exposureGuidance(quality) {
|
|
1051
|
+
const bandMid = (EXPOSURE_BAND[0] + EXPOSURE_BAND[1]) / 2;
|
|
1052
|
+
return quality.meanLuma < bandMid ? "fcv3.more_light" : "fcv3.too_bright";
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
// src/core/v3/capture-session-v3.ts
|
|
1057
|
+
var KEYFRAME_CANDIDATES = 4;
|
|
1058
|
+
var KEYFRAME_WINDOW_MS = 600;
|
|
1059
|
+
var NEUTRAL_QUALITY = { sharpness: 1, exposure: 1, meanLuma: 128 };
|
|
1060
|
+
var CaptureSessionV3 = class {
|
|
1061
|
+
constructor(options) {
|
|
1062
|
+
this.handlers = /* @__PURE__ */ new Map();
|
|
1063
|
+
this.stateValue = "idle";
|
|
1064
|
+
this.attempt = 0;
|
|
1065
|
+
this.stream = null;
|
|
1066
|
+
this.detector = null;
|
|
1067
|
+
this.scheduler = null;
|
|
1068
|
+
this.gate = null;
|
|
1069
|
+
this.profiler = null;
|
|
1070
|
+
this.lastGuidanceKey = null;
|
|
1071
|
+
this.positioningStartedAt = 0;
|
|
1072
|
+
this.fired = false;
|
|
1073
|
+
this.carriedDegrade = 0;
|
|
1074
|
+
this.carriedMeans = [];
|
|
1075
|
+
/** Reused downscale canvas — falls back to the raw video where canvas 2D
|
|
1076
|
+
* is unavailable (jsdom) so tests and exotic hosts keep detecting. */
|
|
1077
|
+
this.snapshotCanvas = null;
|
|
1078
|
+
this.options = options;
|
|
1079
|
+
this.facing = options.facingMode ?? "user";
|
|
1080
|
+
}
|
|
1081
|
+
get state() {
|
|
1082
|
+
return this.stateValue;
|
|
1083
|
+
}
|
|
1084
|
+
on(event, handler) {
|
|
1085
|
+
let set = this.handlers.get(event);
|
|
1086
|
+
if (!set) {
|
|
1087
|
+
set = /* @__PURE__ */ new Set();
|
|
1088
|
+
this.handlers.set(event, set);
|
|
1089
|
+
}
|
|
1090
|
+
set.add(handler);
|
|
1091
|
+
return () => set?.delete(handler);
|
|
1092
|
+
}
|
|
1093
|
+
emit(event, payload) {
|
|
1094
|
+
for (const handler of [...this.handlers.get(event) ?? []]) {
|
|
1095
|
+
handler(payload);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
async start() {
|
|
1099
|
+
this.attempt += 1;
|
|
1100
|
+
await this.run(this.attempt);
|
|
1101
|
+
}
|
|
1102
|
+
/** Full reset + camera restart — the documented recovery path (v1 parity). */
|
|
1103
|
+
retry() {
|
|
1104
|
+
this.teardownAttempt();
|
|
1105
|
+
void this.start();
|
|
1106
|
+
}
|
|
1107
|
+
switchCamera() {
|
|
1108
|
+
this.facing = this.facing === "user" ? "environment" : "user";
|
|
1109
|
+
this.teardownAttempt();
|
|
1110
|
+
void this.start();
|
|
1111
|
+
}
|
|
1112
|
+
destroy() {
|
|
1113
|
+
this.attempt += 1;
|
|
1114
|
+
this.teardownAttempt();
|
|
1115
|
+
this.detector?.destroy();
|
|
1116
|
+
this.detector = null;
|
|
1117
|
+
this.options.video.srcObject = null;
|
|
1118
|
+
}
|
|
1119
|
+
// ── attempt lifecycle ──────────────────────────────────────────────────────
|
|
1120
|
+
async run(attempt) {
|
|
1121
|
+
const { options } = this;
|
|
1122
|
+
this.fired = false;
|
|
1123
|
+
this.lastGuidanceKey = null;
|
|
1124
|
+
this.carriedDegrade = 0;
|
|
1125
|
+
this.carriedMeans = [];
|
|
1126
|
+
this.setState("preflight");
|
|
1127
|
+
const preflight = cameraPreflight();
|
|
1128
|
+
if (preflight !== "OK") {
|
|
1129
|
+
this.fail(preflight === "INSECURE_CONTEXT" ? "insecure" : "unsupported");
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
this.setState("acquiring");
|
|
1133
|
+
const getUserMedia = options.getUserMedia ?? ((constraints) => navigator.mediaDevices.getUserMedia(constraints));
|
|
1134
|
+
let stream;
|
|
1135
|
+
try {
|
|
1136
|
+
stream = await getUserMedia(buildCaptureConstraints(detectIsMobile(), this.facing));
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
if (attempt !== this.attempt) return;
|
|
1139
|
+
this.fail(mapGetUserMediaError(err));
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
if (attempt !== this.attempt) {
|
|
1143
|
+
for (const track of stream.getTracks()) track.stop();
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
this.stream = stream;
|
|
1147
|
+
options.video.srcObject = stream;
|
|
1148
|
+
await options.video.play().catch(() => void 0);
|
|
1149
|
+
if (attempt !== this.attempt) return;
|
|
1150
|
+
if (this.detector === null) {
|
|
1151
|
+
const ladderOptions = {
|
|
1152
|
+
...options.v3AssetBaseUrl !== void 0 ? { v3AssetBaseUrl: options.v3AssetBaseUrl } : {}
|
|
1153
|
+
};
|
|
1154
|
+
const create = options.createDetector ?? (options.createWorker !== void 0 && workerOffloadSupported() ? async () => {
|
|
1155
|
+
try {
|
|
1156
|
+
return await WorkerDetectorProxy.create(
|
|
1157
|
+
options.createWorker(),
|
|
1158
|
+
ladderOptions
|
|
1159
|
+
);
|
|
1160
|
+
} catch {
|
|
1161
|
+
return createDetector(ladderOptions);
|
|
1162
|
+
}
|
|
1163
|
+
} : () => createDetector(ladderOptions));
|
|
1164
|
+
let created;
|
|
1165
|
+
try {
|
|
1166
|
+
created = await create();
|
|
1167
|
+
} catch (err) {
|
|
1168
|
+
if (attempt === this.attempt) {
|
|
1169
|
+
this.fail(
|
|
1170
|
+
"models",
|
|
1171
|
+
err instanceof DetectorLadderError ? err.fallbackReason : describeError2(err)
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
if (attempt !== this.attempt) {
|
|
1177
|
+
created.destroy();
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
this.detector = created;
|
|
1181
|
+
this.options.telemetry?.({
|
|
1182
|
+
type: "detector",
|
|
1183
|
+
tier: created.tier,
|
|
1184
|
+
...created.fallbackReason !== void 0 ? { fallbackReason: created.fallbackReason } : {}
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
const probe = probeDevice();
|
|
1188
|
+
const staticClass = options.deviceClass ?? classifyDevice(probe);
|
|
1189
|
+
this.profiler = new DeviceProfiler(staticClass);
|
|
1190
|
+
this.options.telemetry?.({
|
|
1191
|
+
type: "profile",
|
|
1192
|
+
staticClass,
|
|
1193
|
+
activeClass: this.profiler.deviceClass,
|
|
1194
|
+
simd: probe.simd
|
|
1195
|
+
});
|
|
1196
|
+
this.gate = new FaceGateV3(options.thresholds ?? {});
|
|
1197
|
+
this.setState("positioning");
|
|
1198
|
+
this.positioningStartedAt = this.now();
|
|
1199
|
+
this.startScheduler(attempt);
|
|
1200
|
+
}
|
|
1201
|
+
/** Chrome hint (Task 11): 'reduced' on LOW even without the media query. */
|
|
1202
|
+
get animations() {
|
|
1203
|
+
return this.profiler?.profile.animations ?? "full";
|
|
1204
|
+
}
|
|
1205
|
+
startScheduler(attempt) {
|
|
1206
|
+
const profiler = this.profiler;
|
|
1207
|
+
if (profiler === null) return;
|
|
1208
|
+
if (this.scheduler !== null) {
|
|
1209
|
+
const stats = this.scheduler.stats();
|
|
1210
|
+
this.carriedDegrade += stats.degradeCount;
|
|
1211
|
+
if (stats.meanDetectMs > 0) this.carriedMeans.push(stats.meanDetectMs);
|
|
1212
|
+
this.scheduler.stop();
|
|
1213
|
+
}
|
|
1214
|
+
const scheduler = new DetectScheduler({
|
|
1215
|
+
cadenceHz: profiler.profile.cadenceHz,
|
|
1216
|
+
video: this.options.video,
|
|
1217
|
+
...this.options.now !== void 0 ? { now: this.options.now } : {},
|
|
1218
|
+
run: () => this.detectTick(attempt)
|
|
1219
|
+
});
|
|
1220
|
+
this.scheduler = scheduler;
|
|
1221
|
+
scheduler.start();
|
|
1222
|
+
}
|
|
1223
|
+
async detectTick(attempt) {
|
|
1224
|
+
const { detector, gate, profiler } = this;
|
|
1225
|
+
const video = this.options.video;
|
|
1226
|
+
if (attempt !== this.attempt || detector === null || gate === null || profiler === null)
|
|
1227
|
+
return;
|
|
1228
|
+
if (this.fired || video.videoWidth === 0) return;
|
|
1229
|
+
const before = profiler.deviceClass;
|
|
1230
|
+
const t0 = this.now();
|
|
1231
|
+
const source = this.options.makeSnapshot?.(video, profiler.profile.inputWidth) ?? this.defaultSnapshot(video, profiler.profile.inputWidth);
|
|
1232
|
+
let observation = null;
|
|
1233
|
+
try {
|
|
1234
|
+
observation = await detector.detect(source, video.videoWidth, video.videoHeight);
|
|
1235
|
+
} catch {
|
|
1236
|
+
observation = null;
|
|
1237
|
+
}
|
|
1238
|
+
if (attempt !== this.attempt || this.fired) return;
|
|
1239
|
+
profiler.record(this.now() - t0);
|
|
1240
|
+
if (profiler.deviceClass !== before) this.startScheduler(attempt);
|
|
1241
|
+
const quality = observation === null ? NEUTRAL_QUALITY : this.options.measureQuality?.(video, observation.box) ?? this.defaultQuality(video, observation.box);
|
|
1242
|
+
const tick = gate.tick({
|
|
1243
|
+
timestampMs: this.now(),
|
|
1244
|
+
observation,
|
|
1245
|
+
quality,
|
|
1246
|
+
frameWidth: video.videoWidth,
|
|
1247
|
+
frameHeight: video.videoHeight
|
|
1248
|
+
});
|
|
1249
|
+
this.emit("checks", {
|
|
1250
|
+
engine: "v3",
|
|
1251
|
+
progress: tick.p,
|
|
1252
|
+
q: tick.q,
|
|
1253
|
+
factors: tick.factors,
|
|
1254
|
+
attempt
|
|
1255
|
+
});
|
|
1256
|
+
if (tick.guidanceKey !== this.lastGuidanceKey) {
|
|
1257
|
+
this.lastGuidanceKey = tick.guidanceKey;
|
|
1258
|
+
this.emit("guidance", { key: tick.guidanceKey });
|
|
1259
|
+
}
|
|
1260
|
+
if (tick.ready && this.stateValue === "positioning") {
|
|
1261
|
+
this.fired = true;
|
|
1262
|
+
void this.fire(attempt);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
async fire(attempt) {
|
|
1266
|
+
const { options } = this;
|
|
1267
|
+
const scheduler = this.scheduler;
|
|
1268
|
+
scheduler?.stop();
|
|
1269
|
+
const stats = scheduler?.stats() ?? { degradeCount: 0, meanDetectMs: 0 };
|
|
1270
|
+
const means = [...this.carriedMeans, ...stats.meanDetectMs > 0 ? [stats.meanDetectMs] : []];
|
|
1271
|
+
options.telemetry?.({
|
|
1272
|
+
type: "fired",
|
|
1273
|
+
timeToGateMs: this.now() - this.positioningStartedAt,
|
|
1274
|
+
degradeCount: this.carriedDegrade + stats.degradeCount,
|
|
1275
|
+
meanDetectMs: means.length === 0 ? 0 : means.reduce((sum, value) => sum + value, 0) / means.length
|
|
1276
|
+
});
|
|
1277
|
+
const mode = options.mode ?? "clip";
|
|
1278
|
+
const bestOf = this.collectKeyframes(attempt);
|
|
1279
|
+
if (mode === "photo") {
|
|
1280
|
+
const best2 = await bestOf;
|
|
1281
|
+
if (attempt !== this.attempt) return;
|
|
1282
|
+
if (best2 === null) {
|
|
1283
|
+
this.fail("generic");
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
this.finishCapture({ keyframe: best2.blob, previewDataUrl: best2.previewDataUrl });
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
this.setState("recording");
|
|
1290
|
+
const recorder = options.createRecorder?.() ?? new ClipRecorder();
|
|
1291
|
+
const stream = this.stream;
|
|
1292
|
+
if (stream === null) {
|
|
1293
|
+
this.fail("generic");
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
let frozenBest = null;
|
|
1297
|
+
void bestOf.then(
|
|
1298
|
+
(candidate) => {
|
|
1299
|
+
frozenBest = candidate;
|
|
1300
|
+
},
|
|
1301
|
+
() => {
|
|
1302
|
+
}
|
|
1303
|
+
);
|
|
1304
|
+
const onRecordingStopped = () => {
|
|
1305
|
+
if (attempt !== this.attempt) return;
|
|
1306
|
+
if (frozenBest !== null) {
|
|
1307
|
+
this.emit("frozen", { previewDataUrl: frozenBest.previewDataUrl });
|
|
1308
|
+
}
|
|
1309
|
+
};
|
|
1310
|
+
let clip;
|
|
1311
|
+
let best;
|
|
1312
|
+
try {
|
|
1313
|
+
[clip, best] = await Promise.all([recorder.record(stream, onRecordingStopped), bestOf]);
|
|
1314
|
+
} catch (err) {
|
|
1315
|
+
if (attempt !== this.attempt) return;
|
|
1316
|
+
const kind = err.kind;
|
|
1317
|
+
this.fail(kind === "cliptoolarge" ? "cliptoolarge" : "recorder");
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
if (attempt !== this.attempt) return;
|
|
1321
|
+
if (isClipTooLarge(clip.blob)) {
|
|
1322
|
+
this.fail("cliptoolarge");
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
if (best === null) {
|
|
1326
|
+
this.fail("generic");
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
this.finishCapture({
|
|
1330
|
+
clip: clip.blob,
|
|
1331
|
+
clipMimeType: clip.mimeType,
|
|
1332
|
+
keyframe: best.blob,
|
|
1333
|
+
previewDataUrl: best.previewDataUrl
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
/** Up to KEYFRAME_CANDIDATES mirrored frames over KEYFRAME_WINDOW_MS —
|
|
1337
|
+
* sharpest wins (reuses frame-quality.sharpness via the capture seam). */
|
|
1338
|
+
async collectKeyframes(attempt) {
|
|
1339
|
+
const capture = this.options.captureKeyframe ?? ((video) => this.defaultKeyframe(video));
|
|
1340
|
+
const stepMs = KEYFRAME_WINDOW_MS / (KEYFRAME_CANDIDATES - 1);
|
|
1341
|
+
let best = null;
|
|
1342
|
+
for (let i = 0; i < KEYFRAME_CANDIDATES; i += 1) {
|
|
1343
|
+
if (attempt !== this.attempt) return best;
|
|
1344
|
+
try {
|
|
1345
|
+
const candidate = await capture(this.options.video);
|
|
1346
|
+
if (best === null || candidate.sharpness > best.sharpness) best = candidate;
|
|
1347
|
+
} catch {
|
|
1348
|
+
}
|
|
1349
|
+
if (i < KEYFRAME_CANDIDATES - 1) await delay(stepMs);
|
|
1350
|
+
}
|
|
1351
|
+
return best;
|
|
1352
|
+
}
|
|
1353
|
+
finishCapture(artifacts) {
|
|
1354
|
+
this.stopTracks();
|
|
1355
|
+
this.setState("captured");
|
|
1356
|
+
this.emit("artifacts", artifacts);
|
|
1357
|
+
}
|
|
1358
|
+
// ── defaults for the canvas-bound seams ────────────────────────────────────
|
|
1359
|
+
async defaultKeyframe(video) {
|
|
1360
|
+
const canvas = document.createElement("canvas");
|
|
1361
|
+
const blob = await captureMirroredKeyframe(video, canvas);
|
|
1362
|
+
const previewDataUrl = canvas.toDataURL("image/jpeg", 0.92);
|
|
1363
|
+
return { blob, previewDataUrl, sharpness: this.centerSharpness(video) };
|
|
1364
|
+
}
|
|
1365
|
+
defaultSnapshot(video, targetWidth) {
|
|
1366
|
+
try {
|
|
1367
|
+
const canvas = this.snapshotCanvas ?? document.createElement("canvas");
|
|
1368
|
+
const scale = targetWidth / Math.max(1, video.videoWidth);
|
|
1369
|
+
const width = Math.max(1, Math.round(video.videoWidth * scale));
|
|
1370
|
+
const height = Math.max(1, Math.round(video.videoHeight * scale));
|
|
1371
|
+
const ctx = canvas.getContext("2d");
|
|
1372
|
+
if (ctx === null) return video;
|
|
1373
|
+
if (canvas.width !== width) canvas.width = width;
|
|
1374
|
+
if (canvas.height !== height) canvas.height = height;
|
|
1375
|
+
ctx.drawImage(video, 0, 0, width, height);
|
|
1376
|
+
this.snapshotCanvas = canvas;
|
|
1377
|
+
return canvas;
|
|
1378
|
+
} catch {
|
|
1379
|
+
return video;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
defaultQuality(video, box) {
|
|
1383
|
+
const crop = cropImageData(video, box, 96);
|
|
1384
|
+
if (crop === null) return NEUTRAL_QUALITY;
|
|
1385
|
+
return { sharpness: sharpness(crop), exposure: exposure(crop), meanLuma: meanLuma(crop) };
|
|
1386
|
+
}
|
|
1387
|
+
centerSharpness(video) {
|
|
1388
|
+
const size = intrinsicSize(video);
|
|
1389
|
+
const crop = cropImageData(
|
|
1390
|
+
video,
|
|
1391
|
+
{ x: size.width * 0.25, y: size.height * 0.25, width: size.width * 0.5, height: size.height * 0.5 },
|
|
1392
|
+
96
|
|
1393
|
+
);
|
|
1394
|
+
return crop === null ? 0 : sharpness(crop);
|
|
1395
|
+
}
|
|
1396
|
+
// ── teardown ──────────────────────────────────────────────────────────────
|
|
1397
|
+
teardownAttempt() {
|
|
1398
|
+
this.scheduler?.stop();
|
|
1399
|
+
this.scheduler = null;
|
|
1400
|
+
this.gate = null;
|
|
1401
|
+
this.stopTracks();
|
|
1402
|
+
}
|
|
1403
|
+
stopTracks() {
|
|
1404
|
+
if (this.stream === null) return;
|
|
1405
|
+
for (const track of this.stream.getTracks()) track.stop();
|
|
1406
|
+
this.stream = null;
|
|
1407
|
+
}
|
|
1408
|
+
fail(kind, fallbackReason) {
|
|
1409
|
+
this.scheduler?.stop();
|
|
1410
|
+
this.stopTracks();
|
|
1411
|
+
this.setState("error");
|
|
1412
|
+
this.options.telemetry?.({
|
|
1413
|
+
type: "error",
|
|
1414
|
+
kind,
|
|
1415
|
+
...fallbackReason !== void 0 ? { fallbackReason } : {}
|
|
1416
|
+
});
|
|
1417
|
+
this.emit("error", { kind, retryable: kind !== "insecure" && kind !== "unsupported" });
|
|
1418
|
+
}
|
|
1419
|
+
setState(state) {
|
|
1420
|
+
this.stateValue = state;
|
|
1421
|
+
this.emit("statechange", { state });
|
|
1422
|
+
}
|
|
1423
|
+
now() {
|
|
1424
|
+
return this.options.now?.() ?? performance.now();
|
|
1425
|
+
}
|
|
1426
|
+
};
|
|
1427
|
+
function delay(ms) {
|
|
1428
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1429
|
+
}
|
|
1430
|
+
function describeError2(err) {
|
|
1431
|
+
return err instanceof Error ? err.message : String(err);
|
|
1432
|
+
}
|
|
1433
|
+
function cropImageData(video, box, targetWidth) {
|
|
1434
|
+
try {
|
|
1435
|
+
const canvas = document.createElement("canvas");
|
|
1436
|
+
const scale = targetWidth / Math.max(1, box.width);
|
|
1437
|
+
canvas.width = Math.max(1, Math.round(box.width * scale));
|
|
1438
|
+
canvas.height = Math.max(1, Math.round(box.height * scale));
|
|
1439
|
+
const ctx = canvas.getContext("2d");
|
|
1440
|
+
if (ctx === null) return null;
|
|
1441
|
+
ctx.drawImage(
|
|
1442
|
+
video,
|
|
1443
|
+
box.x,
|
|
1444
|
+
box.y,
|
|
1445
|
+
box.width,
|
|
1446
|
+
box.height,
|
|
1447
|
+
0,
|
|
1448
|
+
0,
|
|
1449
|
+
canvas.width,
|
|
1450
|
+
canvas.height
|
|
1451
|
+
);
|
|
1452
|
+
return ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
1453
|
+
} catch {
|
|
1454
|
+
return null;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// src/verification/emitter.ts
|
|
1459
|
+
function createEmitter() {
|
|
1460
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
1461
|
+
return {
|
|
1462
|
+
on(event, handler) {
|
|
1463
|
+
let set = handlers.get(event);
|
|
1464
|
+
if (set === void 0) {
|
|
1465
|
+
set = /* @__PURE__ */ new Set();
|
|
1466
|
+
handlers.set(event, set);
|
|
1467
|
+
}
|
|
1468
|
+
const h = handler;
|
|
1469
|
+
set.add(h);
|
|
1470
|
+
return () => {
|
|
1471
|
+
set?.delete(h);
|
|
1472
|
+
};
|
|
1473
|
+
},
|
|
1474
|
+
emit(event, payload) {
|
|
1475
|
+
const set = handlers.get(event);
|
|
1476
|
+
if (set === void 0) return;
|
|
1477
|
+
for (const handler of [...set]) {
|
|
1478
|
+
handler(payload);
|
|
1479
|
+
}
|
|
1480
|
+
},
|
|
1481
|
+
clear() {
|
|
1482
|
+
handlers.clear();
|
|
1483
|
+
}
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// src/capture/create-liveness-capture.ts
|
|
1488
|
+
function mapGuidanceHint(key) {
|
|
1489
|
+
switch (key) {
|
|
1490
|
+
case "fcv3.no_face":
|
|
1491
|
+
case "fcv3.center_face":
|
|
1492
|
+
case "fcv3.straighten":
|
|
1493
|
+
return "center";
|
|
1494
|
+
case "fcv3.come_closer":
|
|
1495
|
+
return "closer";
|
|
1496
|
+
case "fcv3.move_back":
|
|
1497
|
+
return "farther";
|
|
1498
|
+
case "fcv3.more_light":
|
|
1499
|
+
case "fcv3.too_bright":
|
|
1500
|
+
return "light";
|
|
1501
|
+
case "fcv3.hold_still":
|
|
1502
|
+
case "fcv3.steady_camera":
|
|
1503
|
+
return "still";
|
|
1504
|
+
default:
|
|
1505
|
+
return null;
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
function mapEngineState(state) {
|
|
1509
|
+
switch (state) {
|
|
1510
|
+
case "preflight":
|
|
1511
|
+
case "acquiring":
|
|
1512
|
+
return "starting";
|
|
1513
|
+
case "positioning":
|
|
1514
|
+
return "gating";
|
|
1515
|
+
case "idle":
|
|
1516
|
+
case "recording":
|
|
1517
|
+
case "captured":
|
|
1518
|
+
case "error":
|
|
1519
|
+
return state;
|
|
1520
|
+
default: {
|
|
1521
|
+
const unhandled = state;
|
|
1522
|
+
throw new Error(`mapEngineState: unhandled state ${unhandled}`);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
function mapErrorKind(kind) {
|
|
1527
|
+
switch (kind) {
|
|
1528
|
+
case "permission":
|
|
1529
|
+
case "notfound":
|
|
1530
|
+
case "inuse":
|
|
1531
|
+
case "insecure":
|
|
1532
|
+
case "unsupported":
|
|
1533
|
+
return kind;
|
|
1534
|
+
case "overconstrained":
|
|
1535
|
+
case "generic":
|
|
1536
|
+
case "models":
|
|
1537
|
+
case "cliptoolarge":
|
|
1538
|
+
case "recorder":
|
|
1539
|
+
return "generic";
|
|
1540
|
+
default: {
|
|
1541
|
+
const unhandled = kind;
|
|
1542
|
+
throw new Error(`mapErrorKind: unhandled kind ${unhandled}`);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
function createLivenessCaptureInternal(options, internals = {}) {
|
|
1547
|
+
const now = internals.now ?? (() => performance.now());
|
|
1548
|
+
const emitter = createEmitter();
|
|
1549
|
+
const degraded = !isMediaRecorderSupported();
|
|
1550
|
+
const engineOptions = {
|
|
1551
|
+
video: options.video,
|
|
1552
|
+
mode: degraded ? "photo" : "clip",
|
|
1553
|
+
...options.facing !== void 0 ? { facingMode: options.facing } : {},
|
|
1554
|
+
...options.assetBaseUrl !== void 0 ? { v3AssetBaseUrl: options.assetBaseUrl } : {},
|
|
1555
|
+
...internals.engineOptions ?? {}
|
|
1556
|
+
};
|
|
1557
|
+
const engine = internals.createEngine?.(engineOptions) ?? new CaptureSessionV3(engineOptions);
|
|
1558
|
+
let state = "idle";
|
|
1559
|
+
let hint = null;
|
|
1560
|
+
let progress = 0;
|
|
1561
|
+
let faceDetected = false;
|
|
1562
|
+
let animations = "full";
|
|
1563
|
+
let recordingStartedAt = null;
|
|
1564
|
+
let frozenAt = null;
|
|
1565
|
+
const setState = (next) => {
|
|
1566
|
+
state = next;
|
|
1567
|
+
emitter.emit("state", next);
|
|
1568
|
+
};
|
|
1569
|
+
const emitGate = () => emitter.emit("gate", { progress, hint, faceDetected });
|
|
1570
|
+
const syncProfile = () => {
|
|
1571
|
+
const next = engine.animations ?? "full";
|
|
1572
|
+
if (next === animations) return;
|
|
1573
|
+
animations = next;
|
|
1574
|
+
emitter.emit("profile", { animations });
|
|
1575
|
+
};
|
|
1576
|
+
const unsubscribes = [
|
|
1577
|
+
engine.on("statechange", ({ state: engineState }) => {
|
|
1578
|
+
const next = mapEngineState(engineState);
|
|
1579
|
+
if (next === "starting" && state !== "starting") {
|
|
1580
|
+
hint = null;
|
|
1581
|
+
progress = 0;
|
|
1582
|
+
faceDetected = false;
|
|
1583
|
+
recordingStartedAt = null;
|
|
1584
|
+
frozenAt = null;
|
|
1585
|
+
}
|
|
1586
|
+
setState(next);
|
|
1587
|
+
syncProfile();
|
|
1588
|
+
if (next === "recording") {
|
|
1589
|
+
recordingStartedAt = now();
|
|
1590
|
+
hint = "hold";
|
|
1591
|
+
progress = 1;
|
|
1592
|
+
emitGate();
|
|
1593
|
+
}
|
|
1594
|
+
}),
|
|
1595
|
+
engine.on("checks", (payload) => {
|
|
1596
|
+
progress = payload.progress;
|
|
1597
|
+
faceDetected = payload.factors.presence > 0;
|
|
1598
|
+
emitGate();
|
|
1599
|
+
syncProfile();
|
|
1600
|
+
}),
|
|
1601
|
+
engine.on("guidance", ({ key }) => {
|
|
1602
|
+
hint = mapGuidanceHint(key);
|
|
1603
|
+
emitGate();
|
|
1604
|
+
}),
|
|
1605
|
+
engine.on("frozen", ({ previewDataUrl }) => {
|
|
1606
|
+
frozenAt = now();
|
|
1607
|
+
emitter.emit("frozen", { previewDataUrl });
|
|
1608
|
+
}),
|
|
1609
|
+
engine.on("artifacts", (artifacts) => {
|
|
1610
|
+
const clipMs = recordingStartedAt !== null && frozenAt !== null ? Math.max(0, Math.round(frozenAt - recordingStartedAt)) : void 0;
|
|
1611
|
+
emitter.emit("captured", {
|
|
1612
|
+
keyframe: artifacts.keyframe,
|
|
1613
|
+
...artifacts.clip !== void 0 ? { clip: artifacts.clip } : {},
|
|
1614
|
+
...artifacts.clipMimeType !== void 0 ? { clipMime: artifacts.clipMimeType } : {},
|
|
1615
|
+
width: options.video.videoWidth,
|
|
1616
|
+
height: options.video.videoHeight,
|
|
1617
|
+
...clipMs !== void 0 ? { clipMs } : {},
|
|
1618
|
+
captureChannel: detectIsMobile() ? "mobile" : "desktop",
|
|
1619
|
+
...degraded ? { degraded: "NO_RECORDER" } : {}
|
|
1620
|
+
});
|
|
1621
|
+
}),
|
|
1622
|
+
engine.on("error", ({ kind, retryable }) => {
|
|
1623
|
+
emitter.emit("error", { kind: mapErrorKind(kind), retryable });
|
|
1624
|
+
})
|
|
1625
|
+
];
|
|
1626
|
+
const handle = {
|
|
1627
|
+
get state() {
|
|
1628
|
+
return state;
|
|
1629
|
+
},
|
|
1630
|
+
on: (event, handler) => emitter.on(event, handler),
|
|
1631
|
+
start: () => engine.start(),
|
|
1632
|
+
retry: () => engine.retry(),
|
|
1633
|
+
switchCamera: () => engine.switchCamera(),
|
|
1634
|
+
destroy: () => {
|
|
1635
|
+
for (const unsubscribe of unsubscribes) unsubscribe();
|
|
1636
|
+
engine.destroy();
|
|
1637
|
+
emitter.clear();
|
|
1638
|
+
}
|
|
1639
|
+
};
|
|
1640
|
+
return {
|
|
1641
|
+
handle,
|
|
1642
|
+
setFlowState: (next) => {
|
|
1643
|
+
if (state !== next) setState(next);
|
|
1644
|
+
}
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
function createLivenessCapture(options) {
|
|
1648
|
+
return createLivenessCaptureInternal(options).handle;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// src/client/errors.ts
|
|
1652
|
+
var KNOWN_CODES = [
|
|
1653
|
+
// auth / addressing (session-token.guard, controllers' own-session guards)
|
|
1654
|
+
"session_token_invalid",
|
|
1655
|
+
// 401 — missing/invalid/expired/revoked token
|
|
1656
|
+
"session_token_required",
|
|
1657
|
+
// 403 — non-session-token principal on a hosted route (defensive)
|
|
1658
|
+
"session_token_session_mismatch",
|
|
1659
|
+
// 403 — route :id differs from the token's bound session
|
|
1660
|
+
"session_not_found",
|
|
1661
|
+
// 404 — token's session missing/foreign (findOwned)
|
|
1662
|
+
// rate limiting (session-token.guard — per-token + per-IP buckets)
|
|
1663
|
+
"rate_limit_exceeded",
|
|
1664
|
+
// 429 + Retry-After header (seconds)
|
|
1665
|
+
// consent gate (Task 0 — submit AND media presign/complete)
|
|
1666
|
+
"consent_required",
|
|
1667
|
+
// 409 — record consent before uploading/submitting
|
|
1668
|
+
// submit flow (sessions.service.submitCheck)
|
|
1669
|
+
"session_not_active",
|
|
1670
|
+
// 409 — session is terminal/review; no further submissions
|
|
1671
|
+
"check_not_found",
|
|
1672
|
+
// 404 — session has no such checkType
|
|
1673
|
+
"check_already_finalized",
|
|
1674
|
+
// 409 — re-submit of a terminal/review check
|
|
1675
|
+
"check_out_of_order",
|
|
1676
|
+
// 409 — only the frontier check accepts a submission
|
|
1677
|
+
"check_type_unsupported",
|
|
1678
|
+
// 400 — no registered executor for the checkType
|
|
1679
|
+
"force_outcome_forbidden",
|
|
1680
|
+
// 403 — forceOutcome on a live session (plan D-C)
|
|
1681
|
+
"attestation_invalid",
|
|
1682
|
+
// 400 (Task 1) — Turnstile/attestation failed; reload and retry
|
|
1683
|
+
"nni_locked",
|
|
1684
|
+
// 409 (Plan A Task 2) — session is bound to a server-provided NNI; a different client NNI was sent
|
|
1685
|
+
// media (media.service)
|
|
1686
|
+
"media_content_type_not_allowed",
|
|
1687
|
+
// 400 — outside the per-kind MIME allowlist
|
|
1688
|
+
"media_too_large",
|
|
1689
|
+
// 413 — over the per-kind byte cap
|
|
1690
|
+
"media_key_invalid",
|
|
1691
|
+
// 400 — not a well-formed pending key
|
|
1692
|
+
"media_key_forbidden",
|
|
1693
|
+
// 403 — key names another session's pending space
|
|
1694
|
+
"media_not_found",
|
|
1695
|
+
// 404 — no uploaded object behind the key
|
|
1696
|
+
"media_unreadable",
|
|
1697
|
+
// 422 — magic-byte sniff rejected the object
|
|
1698
|
+
"media_session_invalid",
|
|
1699
|
+
// 400 — sessionId unusable as a key segment (never in practice)
|
|
1700
|
+
// idempotency (idempotency.interceptor — Task 2)
|
|
1701
|
+
"idempotency_key_collision",
|
|
1702
|
+
// 409 — same key, different body hash
|
|
1703
|
+
"idempotency_in_progress",
|
|
1704
|
+
// 409 — concurrent identical request mid-flight (client auto-retries)
|
|
1705
|
+
"idempotency_key_required",
|
|
1706
|
+
// 400 — only on @Idempotent({required:true}) routes (none hosted yet)
|
|
1707
|
+
// validation + fallback
|
|
1708
|
+
"validation_error",
|
|
1709
|
+
// 400 — ValibotPipe rejection (camelToSnake of 'ValidationError')
|
|
1710
|
+
"internal_error",
|
|
1711
|
+
// 500 — unhandled server error
|
|
1712
|
+
// client-synthesized (never sent by the backend)
|
|
1713
|
+
"unknown_error",
|
|
1714
|
+
// unparseable (non-JSON) response body — error OR 2xx (defensive)
|
|
1715
|
+
"upload_failed",
|
|
1716
|
+
// the bare Spaces PUT answered non-2xx
|
|
1717
|
+
"network_error",
|
|
1718
|
+
// fetch rejected: connection drop / DNS / CORS / mid-transfer (status 0)
|
|
1719
|
+
"timeout"
|
|
1720
|
+
// AbortSignal.timeout fired — API calls 30 s default, Spaces PUT 15 min (status 0)
|
|
1721
|
+
];
|
|
1722
|
+
function isKnownCode(code) {
|
|
1723
|
+
return KNOWN_CODES.includes(code);
|
|
1724
|
+
}
|
|
1725
|
+
var HostedApiError = class extends Error {
|
|
1726
|
+
constructor(args) {
|
|
1727
|
+
super(args.message);
|
|
1728
|
+
this.name = "HostedApiError";
|
|
1729
|
+
this.status = args.status;
|
|
1730
|
+
this.code = args.code;
|
|
1731
|
+
if (args.retryAfterSeconds !== void 0) this.retryAfterSeconds = args.retryAfterSeconds;
|
|
1732
|
+
}
|
|
1733
|
+
};
|
|
1734
|
+
|
|
1735
|
+
// src/client/hosted-client.ts
|
|
1736
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
|
|
1737
|
+
var SPACES_PUT_TIMEOUT_MS = 9e5;
|
|
1738
|
+
var UPLOAD_ERROR_SNIPPET_CHARS = 200;
|
|
1739
|
+
var IN_PROGRESS_RETRY_ATTEMPTS = 5;
|
|
1740
|
+
var IN_PROGRESS_RETRY_DELAY_MS = 400;
|
|
1741
|
+
function stripMimeParams(contentType) {
|
|
1742
|
+
return (contentType.split(";")[0] ?? "").trim();
|
|
1743
|
+
}
|
|
1744
|
+
var MEDIA_INPUT_FIELD = {
|
|
1745
|
+
liveness: "clipKey",
|
|
1746
|
+
face_match: "imageKey",
|
|
1747
|
+
doc_ocr: "imageKey"
|
|
1748
|
+
};
|
|
1749
|
+
var HostedClient = class {
|
|
1750
|
+
constructor(opts) {
|
|
1751
|
+
this.sessionId = null;
|
|
1752
|
+
this.bootstrap = null;
|
|
1753
|
+
this.apiOrigin = opts.apiOrigin.replace(/\/+$/, "");
|
|
1754
|
+
this.sessionToken = opts.sessionToken;
|
|
1755
|
+
const impl = opts.fetch;
|
|
1756
|
+
this.fetchImpl = (input, init) => (impl ?? globalThis.fetch).call(globalThis, input, init);
|
|
1757
|
+
this.requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
1758
|
+
this.retryAttempts = opts.inProgressRetry?.attempts ?? IN_PROGRESS_RETRY_ATTEMPTS;
|
|
1759
|
+
this.retryDelayMs = opts.inProgressRetry?.delayMs ?? IN_PROGRESS_RETRY_DELAY_MS;
|
|
1760
|
+
}
|
|
1761
|
+
/** Token-addressed bootstrap — the hosted page's first call. Caches the sessionId. */
|
|
1762
|
+
async getSession() {
|
|
1763
|
+
const view = await this.api("GET", "/hosted/session");
|
|
1764
|
+
this.sessionId = view.sessionId;
|
|
1765
|
+
return view;
|
|
1766
|
+
}
|
|
1767
|
+
/**
|
|
1768
|
+
* Record consent once (201; an idempotent replay answers 200 with
|
|
1769
|
+
* `alreadyRecorded: true`). Until recorded, media presign/complete AND check
|
|
1770
|
+
* submission answer 409 `consent_required`.
|
|
1771
|
+
*/
|
|
1772
|
+
async recordConsent(locale) {
|
|
1773
|
+
return this.api("POST", "/hosted/consent", { body: { locale } });
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* Presign a Spaces PUT for a new pending object. `contentType` is stripped of
|
|
1777
|
+
* MIME parameters defensively (the allowlist holds bare types; the engine's
|
|
1778
|
+
* `clipMimeType` carries codec params) — PUT with the SAME bare type, or the
|
|
1779
|
+
* signed-header check rejects the upload.
|
|
1780
|
+
*/
|
|
1781
|
+
async presign(kind, contentType, contentLength) {
|
|
1782
|
+
const id = await this.ensureSessionId();
|
|
1783
|
+
const body = { kind, contentType: stripMimeParams(contentType), contentLength };
|
|
1784
|
+
return this.api("POST", `/sessions/${id}/media`, { body });
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* Bare PUT to the presigned Spaces URL: EXACTLY the signed headers — the
|
|
1788
|
+
* declared Content-Type and the blob body. NO `x-session-token` (the
|
|
1789
|
+
* signature IS the credential; an extra header breaks nothing but leaks the
|
|
1790
|
+
* token to a third-party host) and no JSON envelope.
|
|
1791
|
+
*
|
|
1792
|
+
* `contentType` is param-stripped here too (idempotent for bare types): the
|
|
1793
|
+
* presign strips internally, so a caller composing manually with the engine's
|
|
1794
|
+
* parameterful `clipMimeType` would otherwise sign `video/webm` but PUT
|
|
1795
|
+
* `video/webm;codecs=…` — a signed-header mismatch S3 rejects at runtime.
|
|
1796
|
+
*
|
|
1797
|
+
* Timeout: 15 min (`AbortSignal.timeout(900_000)`, matching the presign TTL)
|
|
1798
|
+
* rather than the API calls' 30 s — a 15 MB clip on a slow link legitimately
|
|
1799
|
+
* exceeds 30 s, but a stalled in-flight socket must still be bounded (the TTL
|
|
1800
|
+
* alone only bounds when a PUT can START).
|
|
1801
|
+
*
|
|
1802
|
+
* Failures are ALWAYS typed: non-2xx throws {@link HostedApiError} with the
|
|
1803
|
+
* client-synthesized `upload_failed` (message carries a truncated S3 body
|
|
1804
|
+
* snippet — e.g. SignatureDoesNotMatch); a mid-transfer drop → `network_error`;
|
|
1805
|
+
* the timeout → `timeout`.
|
|
1806
|
+
*/
|
|
1807
|
+
async uploadToSpaces(uploadUrl, blob, contentType) {
|
|
1808
|
+
let res;
|
|
1809
|
+
try {
|
|
1810
|
+
res = await this.fetchImpl(uploadUrl, {
|
|
1811
|
+
method: "PUT",
|
|
1812
|
+
headers: { "content-type": stripMimeParams(contentType) },
|
|
1813
|
+
body: blob,
|
|
1814
|
+
signal: AbortSignal.timeout(SPACES_PUT_TIMEOUT_MS)
|
|
1815
|
+
});
|
|
1816
|
+
} catch (err) {
|
|
1817
|
+
throw asTransportError(err);
|
|
1818
|
+
}
|
|
1819
|
+
if (!res.ok) {
|
|
1820
|
+
const snippet = (await res.text().catch(() => "")).slice(0, UPLOAD_ERROR_SNIPPET_CHARS);
|
|
1821
|
+
throw new HostedApiError({
|
|
1822
|
+
status: res.status,
|
|
1823
|
+
code: "upload_failed",
|
|
1824
|
+
message: `media upload failed (HTTP ${res.status})${snippet ? `: ${snippet}` : ""}`
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
/** Finalize an upload: the backend sniffs + promotes; returns the PROMOTED key to submit. */
|
|
1829
|
+
async completeMedia(key) {
|
|
1830
|
+
const id = await this.ensureSessionId();
|
|
1831
|
+
return this.api("POST", `/sessions/${id}/media/complete`, {
|
|
1832
|
+
body: { key }
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
/**
|
|
1836
|
+
* Submit one check. Sends `{input, forceOutcome?}` (SubmitCheckSchema) and the
|
|
1837
|
+
* optional `x-idempotency-key` header. Returns the whitelisted SessionView.
|
|
1838
|
+
*/
|
|
1839
|
+
async submitCheck(checkType, input, opts = {}) {
|
|
1840
|
+
const id = await this.ensureSessionId();
|
|
1841
|
+
const body = {
|
|
1842
|
+
input,
|
|
1843
|
+
...opts.forceOutcome !== void 0 ? { forceOutcome: opts.forceOutcome } : {}
|
|
1844
|
+
};
|
|
1845
|
+
return this.api("POST", `/sessions/${id}/checks/${checkType}`, {
|
|
1846
|
+
body,
|
|
1847
|
+
...opts.idempotencyKey !== void 0 ? { idempotencyKey: opts.idempotencyKey } : {}
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
/**
|
|
1851
|
+
* Convenience composition: presign → PUT → complete → submit, passing the
|
|
1852
|
+
* PROMOTED key (from complete's response — never the pending presign key)
|
|
1853
|
+
* into the check's media input field (`clipKey` for liveness, `imageKey` for
|
|
1854
|
+
* face_match/doc_ocr). `extraInput` carries the remaining input fields
|
|
1855
|
+
* (liveness `attestation`, doc_ocr `side`). The media content type is
|
|
1856
|
+
* param-stripped ONCE and used for both presign and PUT, so the signed
|
|
1857
|
+
* header always matches.
|
|
1858
|
+
*/
|
|
1859
|
+
async uploadAndSubmit(checkType, media, extraInput, opts = {}) {
|
|
1860
|
+
const bareType = stripMimeParams(media.contentType);
|
|
1861
|
+
const presigned = await this.presign(media.kind, bareType, media.blob.size);
|
|
1862
|
+
await this.uploadToSpaces(presigned.uploadUrl, media.blob, bareType);
|
|
1863
|
+
const completed = await this.completeMedia(presigned.key);
|
|
1864
|
+
const input = {
|
|
1865
|
+
...extraInput,
|
|
1866
|
+
[MEDIA_INPUT_FIELD[checkType]]: completed.key
|
|
1867
|
+
};
|
|
1868
|
+
return this.submitCheck(checkType, input, opts);
|
|
1869
|
+
}
|
|
1870
|
+
// ── transport ──────────────────────────────────────────────────────────────
|
|
1871
|
+
/** The token's own sessionId — cached from getSession(), lazily bootstrapped. */
|
|
1872
|
+
async ensureSessionId() {
|
|
1873
|
+
if (this.sessionId !== null) return this.sessionId;
|
|
1874
|
+
this.bootstrap ?? (this.bootstrap = this.getSession().finally(() => {
|
|
1875
|
+
this.bootstrap = null;
|
|
1876
|
+
}));
|
|
1877
|
+
return (await this.bootstrap).sessionId;
|
|
1878
|
+
}
|
|
1879
|
+
/**
|
|
1880
|
+
* One API request: `${apiOrigin}/api/v2${path}`, `x-session-token` always,
|
|
1881
|
+
* AbortSignal timeout (30 s default), `{success, data}` unwrap on 2xx, flat
|
|
1882
|
+
* `{code, message}` → {@link HostedApiError} on non-2xx. EVERY failure is
|
|
1883
|
+
* typed: a rejected fetch → `network_error`, the timeout → `timeout`, an
|
|
1884
|
+
* unparseable 2xx body → `unknown_error` — the page never sees a raw
|
|
1885
|
+
* TypeError/DOMException. The ONLY automatic retry is 409
|
|
1886
|
+
* `idempotency_in_progress` (bounded, short delay).
|
|
1887
|
+
*/
|
|
1888
|
+
async api(method, path, opts = {}) {
|
|
1889
|
+
const url = `${this.apiOrigin}/api/v2${path}`;
|
|
1890
|
+
const headers = { "x-session-token": this.sessionToken };
|
|
1891
|
+
if (opts.body !== void 0) headers["content-type"] = "application/json";
|
|
1892
|
+
if (opts.idempotencyKey !== void 0) headers["x-idempotency-key"] = opts.idempotencyKey;
|
|
1893
|
+
for (let attempt = 0; ; attempt++) {
|
|
1894
|
+
let res;
|
|
1895
|
+
try {
|
|
1896
|
+
res = await this.fetchImpl(url, {
|
|
1897
|
+
method,
|
|
1898
|
+
headers,
|
|
1899
|
+
...opts.body !== void 0 ? { body: JSON.stringify(opts.body) } : {},
|
|
1900
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs)
|
|
1901
|
+
});
|
|
1902
|
+
} catch (err2) {
|
|
1903
|
+
throw asTransportError(err2);
|
|
1904
|
+
}
|
|
1905
|
+
if (res.ok) {
|
|
1906
|
+
let raw;
|
|
1907
|
+
try {
|
|
1908
|
+
raw = await res.json();
|
|
1909
|
+
} catch {
|
|
1910
|
+
throw new HostedApiError({
|
|
1911
|
+
status: res.status,
|
|
1912
|
+
code: "unknown_error",
|
|
1913
|
+
message: `response body was not valid JSON (HTTP ${res.status})`
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
return unwrap(raw);
|
|
1917
|
+
}
|
|
1918
|
+
const err = await parseErrorResponse(res);
|
|
1919
|
+
if (err.code === "idempotency_in_progress" && attempt < this.retryAttempts) {
|
|
1920
|
+
if (this.retryDelayMs > 0) await sleep(this.retryDelayMs);
|
|
1921
|
+
continue;
|
|
1922
|
+
}
|
|
1923
|
+
throw err;
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
};
|
|
1927
|
+
function asTransportError(err) {
|
|
1928
|
+
const name = typeof err === "object" && err !== null ? err.name : void 0;
|
|
1929
|
+
if (name === "TimeoutError") {
|
|
1930
|
+
return new HostedApiError({ status: 0, code: "timeout", message: "request timed out" });
|
|
1931
|
+
}
|
|
1932
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1933
|
+
return new HostedApiError({
|
|
1934
|
+
status: 0,
|
|
1935
|
+
code: "network_error",
|
|
1936
|
+
message: `network request failed: ${detail}`
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
function unwrap(raw) {
|
|
1940
|
+
if (raw !== null && typeof raw === "object" && "success" in raw && "data" in raw) {
|
|
1941
|
+
return raw.data;
|
|
1942
|
+
}
|
|
1943
|
+
return raw;
|
|
1944
|
+
}
|
|
1945
|
+
async function parseErrorResponse(res) {
|
|
1946
|
+
let code = "unknown_error";
|
|
1947
|
+
let message = `request failed (HTTP ${res.status})`;
|
|
1948
|
+
try {
|
|
1949
|
+
const body = await res.json();
|
|
1950
|
+
if (body !== null && typeof body === "object") {
|
|
1951
|
+
const env = body;
|
|
1952
|
+
if (typeof env.code === "string" && env.code.length > 0) code = env.code;
|
|
1953
|
+
if (typeof env.message === "string" && env.message.length > 0) message = env.message;
|
|
1954
|
+
}
|
|
1955
|
+
} catch {
|
|
1956
|
+
}
|
|
1957
|
+
const retryAfterRaw = res.headers.get("retry-after");
|
|
1958
|
+
const retryAfter = retryAfterRaw === null ? Number.NaN : Number.parseInt(retryAfterRaw, 10);
|
|
1959
|
+
return new HostedApiError({
|
|
1960
|
+
status: res.status,
|
|
1961
|
+
code,
|
|
1962
|
+
message,
|
|
1963
|
+
...Number.isFinite(retryAfter) && retryAfter >= 0 ? { retryAfterSeconds: retryAfter } : {}
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
function sleep(ms) {
|
|
1967
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
// src/verification/result.ts
|
|
1971
|
+
var RETRYABLE_REASONS = /* @__PURE__ */ new Set([
|
|
1972
|
+
"no_face",
|
|
1973
|
+
"media_unreadable",
|
|
1974
|
+
"clip_too_short",
|
|
1975
|
+
"no_face_source",
|
|
1976
|
+
"side_missing",
|
|
1977
|
+
"unreadable",
|
|
1978
|
+
"registry_unavailable",
|
|
1979
|
+
"otp_mismatch",
|
|
1980
|
+
"otp_expired",
|
|
1981
|
+
"otp_send_failed"
|
|
1982
|
+
]);
|
|
1983
|
+
var BIOMETRIC_CHECKS = /* @__PURE__ */ new Set(["liveness", "face_match"]);
|
|
1984
|
+
function isExecutable(step) {
|
|
1985
|
+
return step !== void 0 && (step.state === "pending" || step.state === "input_required");
|
|
1986
|
+
}
|
|
1987
|
+
function isTerminalView(view) {
|
|
1988
|
+
return view.status !== "created" && view.status !== "in_progress";
|
|
1989
|
+
}
|
|
1990
|
+
function frontierStep(view) {
|
|
1991
|
+
return view.steps.find((step) => isExecutable(step));
|
|
1992
|
+
}
|
|
1993
|
+
function stepOf(view, checkType) {
|
|
1994
|
+
return view.steps.find((step) => step.checkType === checkType);
|
|
1995
|
+
}
|
|
1996
|
+
function mergeSessionView(prev, next, submitted) {
|
|
1997
|
+
const byType = new Map(prev.steps.map((step) => [step.checkType, step]));
|
|
1998
|
+
const steps = next.steps.map((step) => {
|
|
1999
|
+
const before = byType.get(step.checkType);
|
|
2000
|
+
const attemptsBefore = before?.attemptsRemaining ?? 0;
|
|
2001
|
+
const merged = {
|
|
2002
|
+
...before ?? {},
|
|
2003
|
+
checkType: step.checkType,
|
|
2004
|
+
state: step.state,
|
|
2005
|
+
attemptsRemaining: step.checkType === submitted ? Math.max(0, attemptsBefore - 1) : attemptsBefore
|
|
2006
|
+
};
|
|
2007
|
+
if (step.reason !== void 0) merged.reason = step.reason;
|
|
2008
|
+
else delete merged.reason;
|
|
2009
|
+
return merged;
|
|
2010
|
+
});
|
|
2011
|
+
return { ...prev, status: next.status, outcome: next.outcome, steps, expiresAt: next.expiresAt };
|
|
2012
|
+
}
|
|
2013
|
+
function unavailable(sessionId, reason) {
|
|
2014
|
+
return reason === void 0 ? { sessionId, status: "unavailable" } : { sessionId, status: "unavailable", reason };
|
|
2015
|
+
}
|
|
2016
|
+
function withReason(sessionId, status, reason) {
|
|
2017
|
+
return reason === void 0 ? { sessionId, status } : { sessionId, status, reason };
|
|
2018
|
+
}
|
|
2019
|
+
function mapSessionResult(view, opts) {
|
|
2020
|
+
const { sessionId } = view;
|
|
2021
|
+
switch (view.status) {
|
|
2022
|
+
case "completed":
|
|
2023
|
+
if (view.outcome === "approved") return { sessionId, status: "approved" };
|
|
2024
|
+
if (view.outcome === "review") return withReason(sessionId, "review", reasonIn(view, "review"));
|
|
2025
|
+
return withReason(sessionId, "rejected", reasonIn(view, "failed"));
|
|
2026
|
+
case "failed":
|
|
2027
|
+
return withReason(sessionId, "rejected", reasonIn(view, "failed"));
|
|
2028
|
+
case "review_pending":
|
|
2029
|
+
return withReason(sessionId, "review", reasonIn(view, "review"));
|
|
2030
|
+
case "expired":
|
|
2031
|
+
return unavailable(sessionId);
|
|
2032
|
+
case "created":
|
|
2033
|
+
case "in_progress":
|
|
2034
|
+
break;
|
|
2035
|
+
default: {
|
|
2036
|
+
const unhandled = view.status;
|
|
2037
|
+
throw new Error(`mapSessionResult: unhandled status ${unhandled}`);
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
const parked = view.steps.find(
|
|
2041
|
+
(step) => BIOMETRIC_CHECKS.has(step.checkType) && step.state === "input_required" && step.reason !== void 0
|
|
2042
|
+
);
|
|
2043
|
+
if (parked?.reason !== void 0) {
|
|
2044
|
+
const canRetry = opts.autoRetry && RETRYABLE_REASONS.has(parked.reason) && parked.attemptsRemaining > 0;
|
|
2045
|
+
return {
|
|
2046
|
+
sessionId,
|
|
2047
|
+
status: canRetry ? "retry" : "rejected",
|
|
2048
|
+
reason: parked.reason,
|
|
2049
|
+
attemptsRemaining: parked.attemptsRemaining
|
|
2050
|
+
};
|
|
2051
|
+
}
|
|
2052
|
+
if (frontierStep(view) === void 0 && view.steps.some((step) => step.state === "review")) {
|
|
2053
|
+
return withReason(sessionId, "review", reasonIn(view, "review"));
|
|
2054
|
+
}
|
|
2055
|
+
return unavailable(sessionId);
|
|
2056
|
+
}
|
|
2057
|
+
function reasonIn(view, state) {
|
|
2058
|
+
return view.steps.find((step) => step.state === state && step.reason !== void 0)?.reason;
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
// src/verification/liveness-flow.ts
|
|
2062
|
+
var CLIP_MIME_ALLOWLIST = ["video/webm", "video/mp4"];
|
|
2063
|
+
var KEYFRAME_MIME = "image/jpeg";
|
|
2064
|
+
var TRANSPORT_REPLAY_ATTEMPTS = 2;
|
|
2065
|
+
var TRANSPORT_REPLAY_DELAY_MS = 1e3;
|
|
2066
|
+
var MAX_RETRY_AFTER_MS = 1e4;
|
|
2067
|
+
var RESYNC_REENTRY_MAX = 3;
|
|
2068
|
+
var TRANSPORT_CODES = /* @__PURE__ */ new Set([
|
|
2069
|
+
"network_error",
|
|
2070
|
+
"timeout",
|
|
2071
|
+
"upload_failed",
|
|
2072
|
+
"unknown_error",
|
|
2073
|
+
"rate_limit_exceeded"
|
|
2074
|
+
]);
|
|
2075
|
+
var RESYNC_CODES = /* @__PURE__ */ new Set([
|
|
2076
|
+
"session_not_active",
|
|
2077
|
+
"check_not_found",
|
|
2078
|
+
"check_already_finalized",
|
|
2079
|
+
"check_out_of_order"
|
|
2080
|
+
]);
|
|
2081
|
+
var AttestationError = class extends Error {
|
|
2082
|
+
constructor(message) {
|
|
2083
|
+
super(message);
|
|
2084
|
+
this.name = "AttestationError";
|
|
2085
|
+
}
|
|
2086
|
+
};
|
|
2087
|
+
async function runSubmission(rt, executor, hooks = {}) {
|
|
2088
|
+
let key = rt.uuid();
|
|
2089
|
+
let consentRecovered = false;
|
|
2090
|
+
let attestationRetried = false;
|
|
2091
|
+
let transportReplays = 0;
|
|
2092
|
+
for (; ; ) {
|
|
2093
|
+
try {
|
|
2094
|
+
return await executor(rt.client, key);
|
|
2095
|
+
} catch (err) {
|
|
2096
|
+
if (err instanceof AttestationError) {
|
|
2097
|
+
if (attestationRetried) throw err;
|
|
2098
|
+
attestationRetried = true;
|
|
2099
|
+
hooks.onAttestationInvalid?.();
|
|
2100
|
+
key = rt.uuid();
|
|
2101
|
+
continue;
|
|
2102
|
+
}
|
|
2103
|
+
if (!(err instanceof HostedApiError)) throw err;
|
|
2104
|
+
if (err.code === "consent_required" && !consentRecovered) {
|
|
2105
|
+
consentRecovered = true;
|
|
2106
|
+
await rt.ensureConsent({ force: true });
|
|
2107
|
+
key = rt.uuid();
|
|
2108
|
+
continue;
|
|
2109
|
+
}
|
|
2110
|
+
if (err.code === "attestation_invalid" && !attestationRetried) {
|
|
2111
|
+
attestationRetried = true;
|
|
2112
|
+
hooks.onAttestationInvalid?.();
|
|
2113
|
+
key = rt.uuid();
|
|
2114
|
+
continue;
|
|
2115
|
+
}
|
|
2116
|
+
if (TRANSPORT_CODES.has(err.code) && transportReplays < TRANSPORT_REPLAY_ATTEMPTS) {
|
|
2117
|
+
transportReplays += 1;
|
|
2118
|
+
const retryAfterMs = err.retryAfterSeconds !== void 0 ? err.retryAfterSeconds * 1e3 : void 0;
|
|
2119
|
+
await rt.sleep(
|
|
2120
|
+
retryAfterMs !== void 0 ? Math.min(retryAfterMs, MAX_RETRY_AFTER_MS) : TRANSPORT_REPLAY_DELAY_MS
|
|
2121
|
+
);
|
|
2122
|
+
continue;
|
|
2123
|
+
}
|
|
2124
|
+
throw err;
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
var LIVENESS_CHAINABLE = /* @__PURE__ */ new Set(["passed", "review", "processing", "skipped"]);
|
|
2129
|
+
async function runLivenessFlow(deps) {
|
|
2130
|
+
const { artifacts } = deps;
|
|
2131
|
+
const clip = artifacts.clip;
|
|
2132
|
+
const bareMime = stripMimeParams(artifacts.clipMime ?? "");
|
|
2133
|
+
if (artifacts.degraded === "NO_RECORDER" || clip === void 0 || !CLIP_MIME_ALLOWLIST.includes(bareMime)) {
|
|
2134
|
+
return { kind: "unavailable", reason: "no_recorder" };
|
|
2135
|
+
}
|
|
2136
|
+
const submitLiveness = buildLivenessSubmit(deps, clip, bareMime);
|
|
2137
|
+
const submitFaceMatch = buildFaceMatchSubmit(deps, artifacts.keyframe);
|
|
2138
|
+
let livenessSubmitted = false;
|
|
2139
|
+
let faceMatchSubmitted = false;
|
|
2140
|
+
let livenessChainable = true;
|
|
2141
|
+
let resyncs = 0;
|
|
2142
|
+
for (; ; ) {
|
|
2143
|
+
if (deps.isCancelled()) return { kind: "cancelled" };
|
|
2144
|
+
const view = deps.getView();
|
|
2145
|
+
if (isTerminalView(view)) return { kind: "settled" };
|
|
2146
|
+
const frontier = frontierStep(view)?.checkType;
|
|
2147
|
+
const next = frontier === "liveness" && !livenessSubmitted ? "liveness" : frontier === "face_match" && !faceMatchSubmitted && livenessChainable ? "face_match" : null;
|
|
2148
|
+
if (next === null) return { kind: "settled" };
|
|
2149
|
+
try {
|
|
2150
|
+
if (next === "liveness") {
|
|
2151
|
+
livenessSubmitted = true;
|
|
2152
|
+
const response = await submitLiveness();
|
|
2153
|
+
if (deps.isCancelled()) return { kind: "cancelled" };
|
|
2154
|
+
deps.applySubmit(response, "liveness");
|
|
2155
|
+
const state = stepOf(deps.getView(), "liveness")?.state;
|
|
2156
|
+
livenessChainable = state === void 0 || LIVENESS_CHAINABLE.has(state);
|
|
2157
|
+
} else {
|
|
2158
|
+
faceMatchSubmitted = true;
|
|
2159
|
+
const response = await submitFaceMatch();
|
|
2160
|
+
if (deps.isCancelled()) return { kind: "cancelled" };
|
|
2161
|
+
deps.applySubmit(response, "face_match");
|
|
2162
|
+
}
|
|
2163
|
+
} catch (err) {
|
|
2164
|
+
if (deps.isCancelled()) return { kind: "cancelled" };
|
|
2165
|
+
const outcome = classifyFailure(err);
|
|
2166
|
+
if (outcome.kind !== "resync" || resyncs >= RESYNC_REENTRY_MAX) return outcome;
|
|
2167
|
+
resyncs += 1;
|
|
2168
|
+
await deps.refreshView();
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
function buildLivenessSubmit(deps, clip, bareMime) {
|
|
2173
|
+
let clipKey = null;
|
|
2174
|
+
let attestation = null;
|
|
2175
|
+
return () => runSubmission(
|
|
2176
|
+
deps,
|
|
2177
|
+
async (client, idempotencyKey) => {
|
|
2178
|
+
if (clipKey === null) {
|
|
2179
|
+
deps.onPhase("uploading");
|
|
2180
|
+
const presigned = await client.presign("clip", bareMime, clip.size);
|
|
2181
|
+
await client.uploadToSpaces(presigned.uploadUrl, clip, bareMime);
|
|
2182
|
+
clipKey = (await client.completeMedia(presigned.key)).key;
|
|
2183
|
+
}
|
|
2184
|
+
if (attestation === null) {
|
|
2185
|
+
try {
|
|
2186
|
+
attestation = await deps.getAttestation();
|
|
2187
|
+
} catch (err) {
|
|
2188
|
+
throw new AttestationError(err instanceof Error ? err.message : String(err));
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
deps.onPhase("checking");
|
|
2192
|
+
return client.submitCheck(
|
|
2193
|
+
"liveness",
|
|
2194
|
+
{ clipKey, attestation, captureChannel: deps.artifacts.captureChannel },
|
|
2195
|
+
{ idempotencyKey }
|
|
2196
|
+
);
|
|
2197
|
+
},
|
|
2198
|
+
{
|
|
2199
|
+
onAttestationInvalid: () => {
|
|
2200
|
+
attestation = null;
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
);
|
|
2204
|
+
}
|
|
2205
|
+
function buildFaceMatchSubmit(deps, keyframe) {
|
|
2206
|
+
let imageKey = null;
|
|
2207
|
+
return () => runSubmission(deps, async (client, idempotencyKey) => {
|
|
2208
|
+
if (imageKey === null) {
|
|
2209
|
+
deps.onPhase("uploading");
|
|
2210
|
+
const presigned = await client.presign("image", KEYFRAME_MIME, keyframe.size);
|
|
2211
|
+
await client.uploadToSpaces(presigned.uploadUrl, keyframe, KEYFRAME_MIME);
|
|
2212
|
+
imageKey = (await client.completeMedia(presigned.key)).key;
|
|
2213
|
+
}
|
|
2214
|
+
deps.onPhase("checking");
|
|
2215
|
+
return client.submitCheck("face_match", { imageKey }, { idempotencyKey });
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2218
|
+
function classifyFailure(err) {
|
|
2219
|
+
if (err instanceof AttestationError) return { kind: "unavailable", reason: "attestation" };
|
|
2220
|
+
if (err instanceof HostedApiError) {
|
|
2221
|
+
if (err.code === "attestation_invalid") return { kind: "unavailable", reason: "attestation" };
|
|
2222
|
+
if (RESYNC_CODES.has(err.code)) return { kind: "resync" };
|
|
2223
|
+
}
|
|
2224
|
+
return { kind: "unavailable", reason: "network" };
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
// src/verification/turnstile.ts
|
|
2228
|
+
var TURNSTILE_SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js";
|
|
2229
|
+
var TURNSTILE_EXECUTE_TIMEOUT_MS = 3e4;
|
|
2230
|
+
function installedApi() {
|
|
2231
|
+
return window.turnstile;
|
|
2232
|
+
}
|
|
2233
|
+
var loaderPromise = null;
|
|
2234
|
+
function loadTurnstile() {
|
|
2235
|
+
loaderPromise ?? (loaderPromise = new Promise((resolve, reject) => {
|
|
2236
|
+
const present = installedApi();
|
|
2237
|
+
if (present) {
|
|
2238
|
+
resolve(present);
|
|
2239
|
+
return;
|
|
2240
|
+
}
|
|
2241
|
+
const script = document.createElement("script");
|
|
2242
|
+
script.src = TURNSTILE_SCRIPT_SRC;
|
|
2243
|
+
script.async = true;
|
|
2244
|
+
script.onload = () => {
|
|
2245
|
+
const api = installedApi();
|
|
2246
|
+
if (api) {
|
|
2247
|
+
resolve(api);
|
|
2248
|
+
} else {
|
|
2249
|
+
loaderPromise = null;
|
|
2250
|
+
reject(new Error("Turnstile script loaded but exposed no API"));
|
|
2251
|
+
}
|
|
2252
|
+
};
|
|
2253
|
+
script.onerror = () => {
|
|
2254
|
+
loaderPromise = null;
|
|
2255
|
+
script.remove();
|
|
2256
|
+
reject(new Error("Turnstile script failed to load"));
|
|
2257
|
+
};
|
|
2258
|
+
document.head.appendChild(script);
|
|
2259
|
+
}));
|
|
2260
|
+
return loaderPromise;
|
|
2261
|
+
}
|
|
2262
|
+
function abortError() {
|
|
2263
|
+
return new Error("Turnstile challenge aborted");
|
|
2264
|
+
}
|
|
2265
|
+
async function executeTurnstile(siteKey, opts = {}) {
|
|
2266
|
+
const timeoutMs = opts.timeoutMs ?? TURNSTILE_EXECUTE_TIMEOUT_MS;
|
|
2267
|
+
const signal = opts.signal;
|
|
2268
|
+
if (signal?.aborted) throw abortError();
|
|
2269
|
+
const api = await loadTurnstile();
|
|
2270
|
+
if (signal?.aborted) throw abortError();
|
|
2271
|
+
const container = document.createElement("div");
|
|
2272
|
+
document.body.appendChild(container);
|
|
2273
|
+
let widgetId;
|
|
2274
|
+
let timer;
|
|
2275
|
+
let onAbort;
|
|
2276
|
+
try {
|
|
2277
|
+
return await new Promise((resolve, reject) => {
|
|
2278
|
+
timer = setTimeout(
|
|
2279
|
+
() => reject(new Error(`Turnstile challenge timed out (${timeoutMs} ms)`)),
|
|
2280
|
+
timeoutMs
|
|
2281
|
+
);
|
|
2282
|
+
if (signal !== void 0) {
|
|
2283
|
+
onAbort = () => reject(abortError());
|
|
2284
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2285
|
+
}
|
|
2286
|
+
widgetId = api.render(container, {
|
|
2287
|
+
sitekey: siteKey,
|
|
2288
|
+
execution: "execute",
|
|
2289
|
+
appearance: "execute",
|
|
2290
|
+
callback: resolve,
|
|
2291
|
+
"error-callback": (code) => {
|
|
2292
|
+
reject(new Error(`Turnstile challenge failed${code ? ` (${code})` : ""}`));
|
|
2293
|
+
}
|
|
2294
|
+
});
|
|
2295
|
+
if (widgetId === void 0) {
|
|
2296
|
+
reject(new Error("Turnstile render returned no widget id"));
|
|
2297
|
+
return;
|
|
2298
|
+
}
|
|
2299
|
+
api.execute(widgetId);
|
|
2300
|
+
});
|
|
2301
|
+
} finally {
|
|
2302
|
+
clearTimeout(timer);
|
|
2303
|
+
if (signal !== void 0 && onAbort !== void 0) signal.removeEventListener("abort", onAbort);
|
|
2304
|
+
if (widgetId !== void 0) {
|
|
2305
|
+
try {
|
|
2306
|
+
api.remove(widgetId);
|
|
2307
|
+
} catch {
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
container.remove();
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
// src/verification/create-verification.ts
|
|
2315
|
+
var DEFAULT_API_ORIGIN = "https://api.sekyro.ai";
|
|
2316
|
+
var REGISTRY_RETRY_DELAY_MS = 5e3;
|
|
2317
|
+
var PROCESSING_POLL_DELAY_MS = 1500;
|
|
2318
|
+
var PROCESSING_POLL_MAX = 10;
|
|
2319
|
+
function defaultSleep(ms) {
|
|
2320
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2321
|
+
}
|
|
2322
|
+
function randomUuid() {
|
|
2323
|
+
const c = globalThis.crypto;
|
|
2324
|
+
if (c !== void 0 && typeof c.randomUUID === "function") return c.randomUUID();
|
|
2325
|
+
const bytes = new Uint8Array(16);
|
|
2326
|
+
if (c !== void 0 && typeof c.getRandomValues === "function") c.getRandomValues(bytes);
|
|
2327
|
+
else for (let i = 0; i < 16; i += 1) bytes[i] = Math.floor(Math.random() * 256);
|
|
2328
|
+
bytes[6] = (bytes[6] ?? 0) & 15 | 64;
|
|
2329
|
+
bytes[8] = (bytes[8] ?? 0) & 63 | 128;
|
|
2330
|
+
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
2331
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
2332
|
+
}
|
|
2333
|
+
function createVerification(options) {
|
|
2334
|
+
return createVerificationInternal(options, {});
|
|
2335
|
+
}
|
|
2336
|
+
function createVerificationInternal(options, internals) {
|
|
2337
|
+
const apiOrigin = options.apiOrigin ?? DEFAULT_API_ORIGIN;
|
|
2338
|
+
const autoRetry = options.autoRetry ?? true;
|
|
2339
|
+
const rearm = options.rearm ?? "auto";
|
|
2340
|
+
const client = internals.client ?? new HostedClient({
|
|
2341
|
+
apiOrigin,
|
|
2342
|
+
sessionToken: options.clientToken,
|
|
2343
|
+
...options.fetch !== void 0 ? { fetch: options.fetch } : {}
|
|
2344
|
+
});
|
|
2345
|
+
const sleep2 = internals.sleep ?? defaultSleep;
|
|
2346
|
+
const uuid = internals.uuid ?? randomUuid;
|
|
2347
|
+
const getTurnstileToken = internals.getTurnstileToken ?? executeTurnstile;
|
|
2348
|
+
const emitter = createEmitter();
|
|
2349
|
+
let destroyed = false;
|
|
2350
|
+
const lifetime = new AbortController();
|
|
2351
|
+
let view = null;
|
|
2352
|
+
let bootstrap = null;
|
|
2353
|
+
const setView = (next) => {
|
|
2354
|
+
view = next;
|
|
2355
|
+
emitter.emit("view", next);
|
|
2356
|
+
};
|
|
2357
|
+
const ensureView = () => {
|
|
2358
|
+
if (view !== null) return Promise.resolve(view);
|
|
2359
|
+
bootstrap ?? (bootstrap = client.getSession().then((next) => {
|
|
2360
|
+
setView(next);
|
|
2361
|
+
return next;
|
|
2362
|
+
}).finally(() => {
|
|
2363
|
+
bootstrap = null;
|
|
2364
|
+
}));
|
|
2365
|
+
return bootstrap;
|
|
2366
|
+
};
|
|
2367
|
+
const refreshView = async () => {
|
|
2368
|
+
const next = await client.getSession();
|
|
2369
|
+
setView(next);
|
|
2370
|
+
return next;
|
|
2371
|
+
};
|
|
2372
|
+
const applySubmit = (response, submitted) => {
|
|
2373
|
+
if (view !== null) setView(mergeSessionView(view, response, submitted));
|
|
2374
|
+
};
|
|
2375
|
+
let consentInFlight = null;
|
|
2376
|
+
const recordConsent = async (force) => {
|
|
2377
|
+
const current = await ensureView();
|
|
2378
|
+
if (!force && current.consentRecorded) return;
|
|
2379
|
+
await client.recordConsent(options.locale);
|
|
2380
|
+
if (view !== null) setView({ ...view, consentRecorded: true });
|
|
2381
|
+
};
|
|
2382
|
+
const ensureConsent = (opts) => {
|
|
2383
|
+
if (opts?.force === true) return recordConsent(true);
|
|
2384
|
+
consentInFlight ?? (consentInFlight = recordConsent(false).finally(() => {
|
|
2385
|
+
consentInFlight = null;
|
|
2386
|
+
}));
|
|
2387
|
+
return consentInFlight;
|
|
2388
|
+
};
|
|
2389
|
+
const runtime = {
|
|
2390
|
+
client,
|
|
2391
|
+
ensureConsent: (opts) => ensureConsent(opts),
|
|
2392
|
+
sleep: sleep2,
|
|
2393
|
+
uuid
|
|
2394
|
+
};
|
|
2395
|
+
let terminalEmitted = false;
|
|
2396
|
+
const emitResult = (result) => {
|
|
2397
|
+
if (result.status === "approved" || result.status === "rejected" || result.status === "review") {
|
|
2398
|
+
if (terminalEmitted) return;
|
|
2399
|
+
terminalEmitted = true;
|
|
2400
|
+
}
|
|
2401
|
+
emitter.emit("result", result);
|
|
2402
|
+
};
|
|
2403
|
+
let registry = null;
|
|
2404
|
+
let registryFailed = false;
|
|
2405
|
+
let registryUnbound = false;
|
|
2406
|
+
const submitRegistry = () => runSubmission(
|
|
2407
|
+
runtime,
|
|
2408
|
+
(c, idempotencyKey) => (
|
|
2409
|
+
// `{}` input: the session is bound to a SERVER-provided NNI (Plan A Task 2).
|
|
2410
|
+
c.submitCheck("population_registry", {}, { idempotencyKey })
|
|
2411
|
+
)
|
|
2412
|
+
);
|
|
2413
|
+
const runRegistry = async () => {
|
|
2414
|
+
try {
|
|
2415
|
+
const current = await ensureView();
|
|
2416
|
+
if (isTerminalView(current) || frontierStep(current)?.checkType !== "population_registry") {
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
if (current.nniBound !== true) {
|
|
2420
|
+
registryUnbound = true;
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
await ensureConsent();
|
|
2424
|
+
applySubmit(await submitRegistry(), "population_registry");
|
|
2425
|
+
const step = view === null ? void 0 : stepOf(view, "population_registry");
|
|
2426
|
+
if (step?.state === "input_required" && step.reason === "registry_unavailable" && step.attemptsRemaining > 0) {
|
|
2427
|
+
await sleep2(REGISTRY_RETRY_DELAY_MS);
|
|
2428
|
+
if (destroyed) return;
|
|
2429
|
+
applySubmit(await submitRegistry(), "population_registry");
|
|
2430
|
+
}
|
|
2431
|
+
} catch {
|
|
2432
|
+
registryFailed = true;
|
|
2433
|
+
return;
|
|
2434
|
+
}
|
|
2435
|
+
if (view !== null && isTerminalView(view)) emitResult(mapSessionResult(view, { autoRetry }));
|
|
2436
|
+
};
|
|
2437
|
+
const startRegistry = () => {
|
|
2438
|
+
registry ?? (registry = runRegistry());
|
|
2439
|
+
return registry;
|
|
2440
|
+
};
|
|
2441
|
+
const getAttestation = async () => {
|
|
2442
|
+
const current = await ensureView();
|
|
2443
|
+
const siteKey = current.turnstileSiteKey;
|
|
2444
|
+
if (siteKey === null) return { sandbox: true };
|
|
2445
|
+
return {
|
|
2446
|
+
channel: "hosted_web",
|
|
2447
|
+
siteKey,
|
|
2448
|
+
turnstileToken: await getTurnstileToken(siteKey, { signal: lifetime.signal })
|
|
2449
|
+
};
|
|
2450
|
+
};
|
|
2451
|
+
let active = null;
|
|
2452
|
+
let chain = Promise.resolve();
|
|
2453
|
+
let chainDepth = 0;
|
|
2454
|
+
const settleProcessing = async () => {
|
|
2455
|
+
let current = await ensureView();
|
|
2456
|
+
for (let i = 0; i < PROCESSING_POLL_MAX && !destroyed && !isTerminalView(current) && frontierStep(current) === void 0 && current.steps.some((step) => step.state === "processing"); i += 1) {
|
|
2457
|
+
await sleep2(PROCESSING_POLL_DELAY_MS);
|
|
2458
|
+
if (destroyed) break;
|
|
2459
|
+
current = await refreshView();
|
|
2460
|
+
}
|
|
2461
|
+
return current;
|
|
2462
|
+
};
|
|
2463
|
+
const runFlow = async (bound, artifacts, capturedAttempt) => {
|
|
2464
|
+
const { capture } = bound;
|
|
2465
|
+
const isCancelled = () => destroyed;
|
|
2466
|
+
const ownsHandle = () => bound.alive && bound.attempt === capturedAttempt;
|
|
2467
|
+
const setPhase = (phase) => {
|
|
2468
|
+
if (ownsHandle()) capture.setFlowState(phase);
|
|
2469
|
+
};
|
|
2470
|
+
const finish = (result) => {
|
|
2471
|
+
if (isCancelled()) return;
|
|
2472
|
+
emitResult(result);
|
|
2473
|
+
if (!ownsHandle()) return;
|
|
2474
|
+
if (result.status === "retry" && rearm === "auto") {
|
|
2475
|
+
capture.handle.retry();
|
|
2476
|
+
return;
|
|
2477
|
+
}
|
|
2478
|
+
capture.setFlowState("done");
|
|
2479
|
+
};
|
|
2480
|
+
if (artifacts.degraded === "NO_RECORDER") {
|
|
2481
|
+
const sessionId = view?.sessionId ?? (await ensureView().catch(() => null))?.sessionId ?? "";
|
|
2482
|
+
finish(unavailable(sessionId, "no_recorder"));
|
|
2483
|
+
return;
|
|
2484
|
+
}
|
|
2485
|
+
setPhase("uploading");
|
|
2486
|
+
try {
|
|
2487
|
+
await startRegistry();
|
|
2488
|
+
if (isCancelled()) return;
|
|
2489
|
+
let current = await ensureView();
|
|
2490
|
+
if (!isTerminalView(current)) {
|
|
2491
|
+
if (frontierStep(current)?.checkType === "population_registry") {
|
|
2492
|
+
finish(
|
|
2493
|
+
unavailable(
|
|
2494
|
+
current.sessionId,
|
|
2495
|
+
registryUnbound ? "nni_unbound" : registryFailed ? "network" : "registry_unavailable"
|
|
2496
|
+
)
|
|
2497
|
+
);
|
|
2498
|
+
return;
|
|
2499
|
+
}
|
|
2500
|
+
await ensureConsent();
|
|
2501
|
+
if (isCancelled()) return;
|
|
2502
|
+
const outcome = await runLivenessFlow({
|
|
2503
|
+
...runtime,
|
|
2504
|
+
artifacts,
|
|
2505
|
+
getAttestation,
|
|
2506
|
+
getView: () => view ?? current,
|
|
2507
|
+
refreshView,
|
|
2508
|
+
applySubmit,
|
|
2509
|
+
onPhase: setPhase,
|
|
2510
|
+
isCancelled
|
|
2511
|
+
});
|
|
2512
|
+
if (isCancelled()) return;
|
|
2513
|
+
if (outcome.kind === "unavailable") {
|
|
2514
|
+
finish(unavailable(current.sessionId, outcome.reason));
|
|
2515
|
+
return;
|
|
2516
|
+
}
|
|
2517
|
+
if (outcome.kind === "resync") await refreshView();
|
|
2518
|
+
current = await settleProcessing();
|
|
2519
|
+
}
|
|
2520
|
+
finish(mapSessionResult(current, { autoRetry }));
|
|
2521
|
+
} catch {
|
|
2522
|
+
if (isCancelled()) return;
|
|
2523
|
+
finish(unavailable(view?.sessionId ?? "", "network"));
|
|
2524
|
+
}
|
|
2525
|
+
};
|
|
2526
|
+
const enqueueFlow = (bound, artifacts) => {
|
|
2527
|
+
const capturedAttempt = bound.attempt;
|
|
2528
|
+
if (chainDepth > 0 && bound.alive) {
|
|
2529
|
+
bound.capture.setFlowState("uploading");
|
|
2530
|
+
}
|
|
2531
|
+
chainDepth += 1;
|
|
2532
|
+
chain = chain.then(() => runFlow(bound, artifacts, capturedAttempt)).finally(() => {
|
|
2533
|
+
chainDepth -= 1;
|
|
2534
|
+
}).catch(() => void 0);
|
|
2535
|
+
};
|
|
2536
|
+
const liveness = (captureOptions) => {
|
|
2537
|
+
active?.capture.handle.destroy();
|
|
2538
|
+
if (active !== null) active.alive = false;
|
|
2539
|
+
const capture = createLivenessCaptureInternal(captureOptions, internals.capture);
|
|
2540
|
+
const bound = { capture, alive: true, attempt: 0 };
|
|
2541
|
+
active = bound;
|
|
2542
|
+
capture.handle.on("state", (state) => {
|
|
2543
|
+
if (state === "starting") bound.attempt += 1;
|
|
2544
|
+
});
|
|
2545
|
+
capture.handle.on("captured", (artifacts) => {
|
|
2546
|
+
void Promise.resolve().then(() => enqueueFlow(bound, artifacts));
|
|
2547
|
+
});
|
|
2548
|
+
const inner = capture.handle;
|
|
2549
|
+
return {
|
|
2550
|
+
get state() {
|
|
2551
|
+
return inner.state;
|
|
2552
|
+
},
|
|
2553
|
+
on: (event, handler) => inner.on(event, handler),
|
|
2554
|
+
start: () => inner.start(),
|
|
2555
|
+
retry: () => inner.retry(),
|
|
2556
|
+
switchCamera: () => inner.switchCamera(),
|
|
2557
|
+
destroy: () => {
|
|
2558
|
+
bound.alive = false;
|
|
2559
|
+
if (active === bound) active = null;
|
|
2560
|
+
inner.destroy();
|
|
2561
|
+
}
|
|
2562
|
+
};
|
|
2563
|
+
};
|
|
2564
|
+
return {
|
|
2565
|
+
consent: () => ensureConsent(),
|
|
2566
|
+
startRegistry,
|
|
2567
|
+
liveness,
|
|
2568
|
+
on: (event, handler) => emitter.on(event, handler),
|
|
2569
|
+
destroy: () => {
|
|
2570
|
+
destroyed = true;
|
|
2571
|
+
lifetime.abort();
|
|
2572
|
+
if (active !== null) {
|
|
2573
|
+
active.alive = false;
|
|
2574
|
+
active.capture.handle.destroy();
|
|
2575
|
+
active = null;
|
|
2576
|
+
}
|
|
2577
|
+
emitter.clear();
|
|
2578
|
+
}
|
|
2579
|
+
};
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
// src/popup/index.ts
|
|
2583
|
+
var SEKYRO_EVENTS = [
|
|
2584
|
+
"session.completed",
|
|
2585
|
+
"session.failed",
|
|
2586
|
+
"session.review_pending",
|
|
2587
|
+
"session.expired",
|
|
2588
|
+
"session.cancelled"
|
|
2589
|
+
];
|
|
2590
|
+
var WINDOW_NAME = "sekyro-verify";
|
|
2591
|
+
var WINDOW_FEATURES = "width=480,height=720,noopener=false";
|
|
2592
|
+
var EVENT_NAMES = new Set(SEKYRO_EVENTS);
|
|
2593
|
+
function isSekyroEvent(data) {
|
|
2594
|
+
if (data === null || typeof data !== "object") return false;
|
|
2595
|
+
const d = data;
|
|
2596
|
+
return d.source === "sekyro" && d.version === 1 && typeof d.sessionId === "string" && typeof d.event === "string" && EVENT_NAMES.has(d.event);
|
|
2597
|
+
}
|
|
2598
|
+
function open(opts) {
|
|
2599
|
+
const origin = new URL(opts.hostedUrl).origin;
|
|
2600
|
+
const popup = window.open(opts.hostedUrl, WINDOW_NAME, WINDOW_FEATURES);
|
|
2601
|
+
if (!popup) return { error: "popup_blocked" };
|
|
2602
|
+
const listener = (ev) => {
|
|
2603
|
+
if (ev.source !== popup) return;
|
|
2604
|
+
if (ev.origin !== origin) return;
|
|
2605
|
+
if (!isSekyroEvent(ev.data)) return;
|
|
2606
|
+
const { source, version, event, sessionId } = ev.data;
|
|
2607
|
+
opts.onEvent({ source, version, event, sessionId });
|
|
2608
|
+
};
|
|
2609
|
+
window.addEventListener("message", listener);
|
|
2610
|
+
return {
|
|
2611
|
+
close() {
|
|
2612
|
+
window.removeEventListener("message", listener);
|
|
2613
|
+
popup.close();
|
|
2614
|
+
}
|
|
2615
|
+
};
|
|
2616
|
+
}
|
|
2617
|
+
function openHosted(hostedUrl, opts) {
|
|
2618
|
+
if (opts.mode === "redirect") {
|
|
2619
|
+
window.location.assign(hostedUrl);
|
|
2620
|
+
return { close() {
|
|
2621
|
+
} };
|
|
2622
|
+
}
|
|
2623
|
+
return open({ hostedUrl, onEvent: opts.onEvent ?? (() => void 0) });
|
|
2624
|
+
}
|
|
2625
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
2626
|
+
0 && (module.exports = {
|
|
2627
|
+
DEFAULT_API_ORIGIN,
|
|
2628
|
+
HostedApiError,
|
|
2629
|
+
KNOWN_CODES,
|
|
2630
|
+
SEKYRO_EVENTS,
|
|
2631
|
+
createLivenessCapture,
|
|
2632
|
+
createVerification,
|
|
2633
|
+
isKnownCode,
|
|
2634
|
+
openHosted
|
|
2635
|
+
});
|
|
2636
|
+
//# sourceMappingURL=index.cjs.map
|