@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
|
@@ -0,0 +1,1094 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DetectorLadderError,
|
|
3
|
+
WorkerDetectorProxy,
|
|
4
|
+
createDetector,
|
|
5
|
+
intrinsicSize,
|
|
6
|
+
workerOffloadSupported
|
|
7
|
+
} from "./chunk-NWF46H7K.js";
|
|
8
|
+
import {
|
|
9
|
+
EXPOSURE_BAND,
|
|
10
|
+
exposure,
|
|
11
|
+
meanLuma,
|
|
12
|
+
sharpness
|
|
13
|
+
} from "./chunk-MWNFSNMP.js";
|
|
14
|
+
|
|
15
|
+
// src/core/capture-constraints.ts
|
|
16
|
+
function buildCaptureConstraints(isMobile, facingMode) {
|
|
17
|
+
void isMobile;
|
|
18
|
+
return {
|
|
19
|
+
video: {
|
|
20
|
+
width: { ideal: 1280, max: 1280 },
|
|
21
|
+
height: { ideal: 720, max: 720 },
|
|
22
|
+
facingMode,
|
|
23
|
+
frameRate: { ideal: 24, max: 30 }
|
|
24
|
+
},
|
|
25
|
+
audio: false
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function detectIsMobile() {
|
|
29
|
+
if (typeof window === "undefined" || typeof navigator === "undefined") return false;
|
|
30
|
+
return window.innerWidth <= 768 || /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
|
31
|
+
String(navigator.userAgent ?? "")
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
var CIRCLE_RADIUS_FACTOR = 0.45;
|
|
35
|
+
var IN_CIRCLE_FACTOR = 0.9;
|
|
36
|
+
var CENTERED_FACTOR = 0.4;
|
|
37
|
+
|
|
38
|
+
// src/core/media.ts
|
|
39
|
+
var MIME_CANDIDATES = [
|
|
40
|
+
"video/mp4;codecs=avc1.42E01E,mp4a.40.2",
|
|
41
|
+
"video/mp4;codecs=avc1.42E01E",
|
|
42
|
+
"video/mp4",
|
|
43
|
+
"video/webm;codecs=vp9,opus",
|
|
44
|
+
"video/webm;codecs=vp9",
|
|
45
|
+
"video/webm;codecs=vp8,opus",
|
|
46
|
+
"video/webm;codecs=vp8",
|
|
47
|
+
"video/webm"
|
|
48
|
+
];
|
|
49
|
+
function pickMimeType() {
|
|
50
|
+
const MR = globalThis.MediaRecorder;
|
|
51
|
+
if (!MR || typeof MR.isTypeSupported !== "function") return "";
|
|
52
|
+
return MIME_CANDIDATES.find((t) => MR.isTypeSupported(t)) ?? "";
|
|
53
|
+
}
|
|
54
|
+
var DEFAULT_CLIP_MIME = "video/webm";
|
|
55
|
+
function mapGetUserMediaError(err) {
|
|
56
|
+
if (err instanceof TypeError) return "insecure";
|
|
57
|
+
const name = err?.name ?? "";
|
|
58
|
+
switch (name) {
|
|
59
|
+
case "NotAllowedError":
|
|
60
|
+
case "PermissionDeniedError":
|
|
61
|
+
return "permission";
|
|
62
|
+
case "NotFoundError":
|
|
63
|
+
case "DevicesNotFoundError":
|
|
64
|
+
return "notfound";
|
|
65
|
+
case "NotReadableError":
|
|
66
|
+
case "TrackStartError":
|
|
67
|
+
return "inuse";
|
|
68
|
+
case "OverconstrainedError":
|
|
69
|
+
case "ConstraintNotSatisfiedError":
|
|
70
|
+
return "overconstrained";
|
|
71
|
+
default:
|
|
72
|
+
return "generic";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function cameraPreflight() {
|
|
76
|
+
const w = globalThis;
|
|
77
|
+
if (typeof w.window !== "undefined" && w.window.isSecureContext === false)
|
|
78
|
+
return "INSECURE_CONTEXT";
|
|
79
|
+
const md = w.navigator?.mediaDevices;
|
|
80
|
+
if (!md || typeof md.getUserMedia !== "function") return "UNSUPPORTED";
|
|
81
|
+
return "OK";
|
|
82
|
+
}
|
|
83
|
+
function isMediaRecorderSupported() {
|
|
84
|
+
const MR = globalThis.MediaRecorder;
|
|
85
|
+
return typeof MR !== "undefined" && typeof MR.isTypeSupported === "function";
|
|
86
|
+
}
|
|
87
|
+
var MAX_CLIP_BYTES = 8 * 1024 * 1024;
|
|
88
|
+
function isClipTooLarge(blob) {
|
|
89
|
+
return blob.size > MAX_CLIP_BYTES;
|
|
90
|
+
}
|
|
91
|
+
function extForMime(mime) {
|
|
92
|
+
const m = (mime || "").toLowerCase();
|
|
93
|
+
if (m.startsWith("image/jpeg")) return "jpg";
|
|
94
|
+
if (m.startsWith("video/mp4")) return "mp4";
|
|
95
|
+
if (m.startsWith("video/webm")) return "webm";
|
|
96
|
+
return "webm";
|
|
97
|
+
}
|
|
98
|
+
function buildLivenessV2FormData(args) {
|
|
99
|
+
const { token, clip, keyframe } = args;
|
|
100
|
+
const ts = args.ts ?? Date.now();
|
|
101
|
+
const clipType = clip.type || DEFAULT_CLIP_MIME;
|
|
102
|
+
const kfType = keyframe.type || "image/jpeg";
|
|
103
|
+
const fd = new FormData();
|
|
104
|
+
fd.append("token", token);
|
|
105
|
+
fd.append("clip", new File([clip], `liveness-${ts}.${extForMime(clipType)}`, { type: clipType }));
|
|
106
|
+
fd.append("keyframe", new File([keyframe], `keyframe-${ts}.${extForMime(kfType)}`, { type: kfType }));
|
|
107
|
+
return fd;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// src/core/clip-recorder.ts
|
|
111
|
+
var CLIP_MS = 2500;
|
|
112
|
+
var TIMESLICE_MS = 1e3;
|
|
113
|
+
var STOP_GRACE_MS = 1500;
|
|
114
|
+
var VIDEO_BPS = 25e5;
|
|
115
|
+
var ClipError = class extends Error {
|
|
116
|
+
constructor(kind, msg) {
|
|
117
|
+
super(msg ?? kind);
|
|
118
|
+
this.kind = kind;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
var ClipRecorder = class {
|
|
122
|
+
constructor() {
|
|
123
|
+
this.recorder = null;
|
|
124
|
+
this.stream = null;
|
|
125
|
+
// own field so teardown can stop tracks (REVIEW FIX 8)
|
|
126
|
+
this.chunks = [];
|
|
127
|
+
this.bytes = 0;
|
|
128
|
+
this.hardStopTimer = null;
|
|
129
|
+
this.graceTimer = null;
|
|
130
|
+
this.settled = false;
|
|
131
|
+
}
|
|
132
|
+
clearTimers() {
|
|
133
|
+
if (this.hardStopTimer) clearTimeout(this.hardStopTimer);
|
|
134
|
+
if (this.graceTimer) clearTimeout(this.graceTimer);
|
|
135
|
+
this.hardStopTimer = null;
|
|
136
|
+
this.graceTimer = null;
|
|
137
|
+
}
|
|
138
|
+
/** Records CLIP_MS of the stream. Resolves with the clip blob + its container mime;
|
|
139
|
+
* rejects with ClipError. Behavior contract is the v1 hook's `start`, verbatim
|
|
140
|
+
* (iOS workarounds included).
|
|
141
|
+
*
|
|
142
|
+
* ONE RECORDING AT A TIME: chunk/byte/timer state is shared per instance, so a
|
|
143
|
+
* second record() while one is in flight rejects ClipError("generic") instead of
|
|
144
|
+
* corrupting the in-flight clip. (v2 guard — v1's hook was gated by its owning
|
|
145
|
+
* component; a class API invites reuse. Zero impact on clip bytes.)
|
|
146
|
+
*
|
|
147
|
+
* `onStopped` (optional) fires ONCE the moment the CLIP_MS recording window
|
|
148
|
+
* elapses (the hard-stop), BEFORE finalize — which on iOS can lag up to
|
|
149
|
+
* STOP_GRACE_MS while onstop flushes the last chunk. It lets the host freeze
|
|
150
|
+
* the display on the already-captured keyframe the instant recording ends,
|
|
151
|
+
* instead of waiting for the blob. PURE NOTIFICATION — it does not touch
|
|
152
|
+
* timers, chunks, or the finalize path, so it is calibration-neutral (no
|
|
153
|
+
* effect on clip bytes). Never fires on a rejected capture (error /
|
|
154
|
+
* cliptoolarge settle before the hard-stop and clear the timer). */
|
|
155
|
+
record(stream, onStopped) {
|
|
156
|
+
if (this.recorder && !this.settled) {
|
|
157
|
+
return Promise.reject(
|
|
158
|
+
new ClipError(
|
|
159
|
+
"generic",
|
|
160
|
+
"record() called while a recording is in flight \u2014 ClipRecorder handles one recording at a time"
|
|
161
|
+
)
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
this.recorder = null;
|
|
165
|
+
this.clearTimers();
|
|
166
|
+
this.chunks = [];
|
|
167
|
+
this.bytes = 0;
|
|
168
|
+
this.settled = false;
|
|
169
|
+
this.stream = stream;
|
|
170
|
+
const picked = pickMimeType();
|
|
171
|
+
return new Promise((resolve, reject) => {
|
|
172
|
+
let recorder;
|
|
173
|
+
try {
|
|
174
|
+
recorder = new MediaRecorder(
|
|
175
|
+
stream,
|
|
176
|
+
picked ? { mimeType: picked, videoBitsPerSecond: VIDEO_BPS } : { videoBitsPerSecond: VIDEO_BPS }
|
|
177
|
+
);
|
|
178
|
+
} catch {
|
|
179
|
+
try {
|
|
180
|
+
recorder = new MediaRecorder(stream);
|
|
181
|
+
} catch {
|
|
182
|
+
reject(new ClipError("unsupported"));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
this.recorder = recorder;
|
|
187
|
+
const settle = (fn) => {
|
|
188
|
+
if (this.settled) return;
|
|
189
|
+
this.settled = true;
|
|
190
|
+
this.clearTimers();
|
|
191
|
+
fn();
|
|
192
|
+
};
|
|
193
|
+
const finalize = () => settle(() => {
|
|
194
|
+
const type = recorder.mimeType || picked || DEFAULT_CLIP_MIME;
|
|
195
|
+
const blob = new Blob(this.chunks, { type });
|
|
196
|
+
if (isClipTooLarge(blob)) {
|
|
197
|
+
reject(new ClipError("cliptoolarge"));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
resolve({ blob, mimeType: type });
|
|
201
|
+
});
|
|
202
|
+
recorder.ondataavailable = (e) => {
|
|
203
|
+
if (e.data && e.data.size) {
|
|
204
|
+
this.chunks.push(e.data);
|
|
205
|
+
this.bytes += e.data.size;
|
|
206
|
+
if (this.bytes > MAX_CLIP_BYTES) {
|
|
207
|
+
settle(() => {
|
|
208
|
+
try {
|
|
209
|
+
if (recorder.state !== "inactive") recorder.stop();
|
|
210
|
+
} catch {
|
|
211
|
+
}
|
|
212
|
+
reject(new ClipError("cliptoolarge"));
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
recorder.onstop = finalize;
|
|
218
|
+
recorder.onerror = () => settle(() => reject(new ClipError("generic")));
|
|
219
|
+
try {
|
|
220
|
+
recorder.start(TIMESLICE_MS);
|
|
221
|
+
} catch {
|
|
222
|
+
settle(() => reject(new ClipError("generic")));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
this.hardStopTimer = setTimeout(() => {
|
|
226
|
+
if (!this.settled) onStopped?.();
|
|
227
|
+
if (recorder.state !== "inactive") {
|
|
228
|
+
try {
|
|
229
|
+
recorder.stop();
|
|
230
|
+
} catch {
|
|
231
|
+
finalize();
|
|
232
|
+
}
|
|
233
|
+
this.graceTimer = setTimeout(finalize, STOP_GRACE_MS);
|
|
234
|
+
} else {
|
|
235
|
+
finalize();
|
|
236
|
+
}
|
|
237
|
+
}, CLIP_MS);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
/** Idempotent teardown: stop recorder + ALL tracks (v1 `stopAndCleanup` + the unmount
|
|
241
|
+
* effect). Optional `stream` override mirrors v1's `stopAndCleanup(stream?)`.
|
|
242
|
+
*
|
|
243
|
+
* MID-RECORDING SEMANTICS (verbatim v1): calling stop() while a record() is in
|
|
244
|
+
* flight may LATER resolve the pending promise with a TRUNCATED clip (recorder.stop()
|
|
245
|
+
* fires onstop → finalize from whatever chunks arrived so far), or — because this
|
|
246
|
+
* method clears the hard-stop/grace timers — never settle it at all if onstop never
|
|
247
|
+
* fires (the iOS grace path has been torn down). Callers MUST NOT treat a resolution
|
|
248
|
+
* arriving after stop() as a full CLIP_MS clip, and must not await a record()
|
|
249
|
+
* promise past stop() without their own timeout. (Task 4's CaptureSession owns
|
|
250
|
+
* sequencing on top of this contract.) */
|
|
251
|
+
stop(stream) {
|
|
252
|
+
this.clearTimers();
|
|
253
|
+
const rec = this.recorder;
|
|
254
|
+
if (rec && rec.state !== "inactive") {
|
|
255
|
+
try {
|
|
256
|
+
rec.stop();
|
|
257
|
+
} catch {
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
this.recorder = null;
|
|
261
|
+
const s = stream ?? this.stream;
|
|
262
|
+
s?.getTracks().forEach((t) => t.stop());
|
|
263
|
+
this.stream = null;
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// src/core/keyframe.ts
|
|
268
|
+
function captureMirroredKeyframe(video, canvas, quality = 0.92) {
|
|
269
|
+
return new Promise((resolve, reject) => {
|
|
270
|
+
const w = video.videoWidth;
|
|
271
|
+
const h = video.videoHeight;
|
|
272
|
+
if (!w || !h) {
|
|
273
|
+
reject(new Error("video-not-ready"));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const ctx = canvas.getContext("2d");
|
|
277
|
+
if (!ctx) {
|
|
278
|
+
reject(new Error("no-2d-context"));
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
canvas.width = w;
|
|
282
|
+
canvas.height = h;
|
|
283
|
+
ctx.scale(-1, 1);
|
|
284
|
+
ctx.drawImage(video, -w, 0);
|
|
285
|
+
canvas.toBlob(
|
|
286
|
+
(blob) => blob ? resolve(blob) : reject(new Error("empty-keyframe")),
|
|
287
|
+
"image/jpeg",
|
|
288
|
+
quality
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// src/core/v3/device-profile.ts
|
|
294
|
+
var DEVICE_PROFILES = Object.freeze({
|
|
295
|
+
LOW: Object.freeze({ cadenceHz: 5, inputWidth: 128, animations: "reduced" }),
|
|
296
|
+
MID: Object.freeze({ cadenceHz: 8, inputWidth: 160, animations: "full" }),
|
|
297
|
+
HIGH: Object.freeze({ cadenceHz: 10, inputWidth: 192, animations: "full" })
|
|
298
|
+
});
|
|
299
|
+
var SIMD_TEST_BYTES = new Uint8Array([
|
|
300
|
+
0,
|
|
301
|
+
97,
|
|
302
|
+
115,
|
|
303
|
+
109,
|
|
304
|
+
1,
|
|
305
|
+
0,
|
|
306
|
+
0,
|
|
307
|
+
0,
|
|
308
|
+
1,
|
|
309
|
+
5,
|
|
310
|
+
1,
|
|
311
|
+
96,
|
|
312
|
+
0,
|
|
313
|
+
1,
|
|
314
|
+
123,
|
|
315
|
+
3,
|
|
316
|
+
2,
|
|
317
|
+
1,
|
|
318
|
+
0,
|
|
319
|
+
10,
|
|
320
|
+
10,
|
|
321
|
+
1,
|
|
322
|
+
8,
|
|
323
|
+
0,
|
|
324
|
+
65,
|
|
325
|
+
0,
|
|
326
|
+
253,
|
|
327
|
+
15,
|
|
328
|
+
253,
|
|
329
|
+
98,
|
|
330
|
+
11
|
|
331
|
+
]);
|
|
332
|
+
function probeDevice() {
|
|
333
|
+
const nav = typeof navigator === "undefined" ? void 0 : navigator;
|
|
334
|
+
let simd = false;
|
|
335
|
+
try {
|
|
336
|
+
simd = typeof WebAssembly !== "undefined" && WebAssembly.validate(SIMD_TEST_BYTES);
|
|
337
|
+
} catch {
|
|
338
|
+
simd = false;
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
cores: nav?.hardwareConcurrency ?? 2,
|
|
342
|
+
memGB: nav?.deviceMemory ?? null,
|
|
343
|
+
simd
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
function classifyDevice(probe) {
|
|
347
|
+
const memGB = probe.memGB ?? 4;
|
|
348
|
+
if (probe.cores < 4 || memGB < 3) return "LOW";
|
|
349
|
+
if (probe.cores >= 8 && memGB >= 6) return "HIGH";
|
|
350
|
+
return "MID";
|
|
351
|
+
}
|
|
352
|
+
var ORDER = ["LOW", "MID", "HIGH"];
|
|
353
|
+
var DeviceProfiler = class {
|
|
354
|
+
constructor(initial) {
|
|
355
|
+
this.samples = [];
|
|
356
|
+
this.adjusted = false;
|
|
357
|
+
this.current = initial;
|
|
358
|
+
}
|
|
359
|
+
get deviceClass() {
|
|
360
|
+
return this.current;
|
|
361
|
+
}
|
|
362
|
+
get profile() {
|
|
363
|
+
return DEVICE_PROFILES[this.current];
|
|
364
|
+
}
|
|
365
|
+
record(detectMs) {
|
|
366
|
+
if (this.adjusted || this.samples.length >= 10) return this.current;
|
|
367
|
+
this.samples.push(detectMs);
|
|
368
|
+
if (this.samples.length < 10) return this.current;
|
|
369
|
+
this.adjusted = true;
|
|
370
|
+
const mean = this.samples.reduce((sum, value) => sum + value, 0) / this.samples.length;
|
|
371
|
+
if (mean > 70) {
|
|
372
|
+
this.current = "LOW";
|
|
373
|
+
} else if (mean < 25) {
|
|
374
|
+
const index = ORDER.indexOf(this.current);
|
|
375
|
+
this.current = ORDER[Math.min(ORDER.length - 1, index + 1)] ?? this.current;
|
|
376
|
+
}
|
|
377
|
+
return this.current;
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// src/core/v3/detect-scheduler.ts
|
|
382
|
+
var STATS_WINDOW = 20;
|
|
383
|
+
var DetectScheduler = class {
|
|
384
|
+
constructor(options) {
|
|
385
|
+
this.timer = null;
|
|
386
|
+
this.frameCallbackId = null;
|
|
387
|
+
this.running = false;
|
|
388
|
+
this.busy = false;
|
|
389
|
+
this.lastRunAt = Number.NEGATIVE_INFINITY;
|
|
390
|
+
this.degradeCount = 0;
|
|
391
|
+
this.durations = [];
|
|
392
|
+
this.options = options;
|
|
393
|
+
this.periodMs = 1e3 / options.cadenceHz;
|
|
394
|
+
}
|
|
395
|
+
start() {
|
|
396
|
+
if (this.running) return;
|
|
397
|
+
this.running = true;
|
|
398
|
+
const video = this.options.video;
|
|
399
|
+
if (typeof video?.requestVideoFrameCallback === "function") {
|
|
400
|
+
const loop = () => {
|
|
401
|
+
if (!this.running) return;
|
|
402
|
+
this.frameCallbackId = video.requestVideoFrameCallback?.(() => {
|
|
403
|
+
this.frameCallbackId = null;
|
|
404
|
+
if (this.now() - this.lastRunAt >= this.periodMs) this.tick();
|
|
405
|
+
loop();
|
|
406
|
+
});
|
|
407
|
+
};
|
|
408
|
+
loop();
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
this.timer = setInterval(() => this.tick(), this.periodMs);
|
|
412
|
+
}
|
|
413
|
+
stop() {
|
|
414
|
+
this.running = false;
|
|
415
|
+
if (this.timer !== null) {
|
|
416
|
+
clearInterval(this.timer);
|
|
417
|
+
this.timer = null;
|
|
418
|
+
}
|
|
419
|
+
if (this.frameCallbackId !== null) {
|
|
420
|
+
this.options.video?.cancelVideoFrameCallback?.(
|
|
421
|
+
this.frameCallbackId
|
|
422
|
+
);
|
|
423
|
+
this.frameCallbackId = null;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
stats() {
|
|
427
|
+
const meanDetectMs = this.durations.length === 0 ? 0 : this.durations.reduce((sum, value) => sum + value, 0) / this.durations.length;
|
|
428
|
+
return { degradeCount: this.degradeCount, meanDetectMs };
|
|
429
|
+
}
|
|
430
|
+
now() {
|
|
431
|
+
return this.options.now?.() ?? performance.now();
|
|
432
|
+
}
|
|
433
|
+
tick() {
|
|
434
|
+
if (!this.running) return;
|
|
435
|
+
if (this.busy) {
|
|
436
|
+
this.degradeCount += 1;
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
this.busy = true;
|
|
440
|
+
this.lastRunAt = this.now();
|
|
441
|
+
const startedAt = this.now();
|
|
442
|
+
this.options.run().catch(() => void 0).finally(() => {
|
|
443
|
+
this.busy = false;
|
|
444
|
+
this.durations.push(this.now() - startedAt);
|
|
445
|
+
if (this.durations.length > STATS_WINDOW) this.durations.shift();
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
// src/core/v3/face-gate-v3.ts
|
|
451
|
+
var V3_GATE_DEFAULTS = Object.freeze({
|
|
452
|
+
Q_GOOD: 0.6,
|
|
453
|
+
RISE_PER_S: 0.9,
|
|
454
|
+
FALL_PER_S: 0.25,
|
|
455
|
+
CENTER_FALLOFF_IOD_MULTIPLE: 1.35,
|
|
456
|
+
BOX_EYE_OFFSET_FACTOR: 0.12,
|
|
457
|
+
IOD_BOX_PROXY_FACTOR: 0.42,
|
|
458
|
+
SIZE_IDEAL_MIN: 0.12,
|
|
459
|
+
SIZE_IDEAL_MAX: 0.3,
|
|
460
|
+
SIZE_HARD_MIN: 0.08,
|
|
461
|
+
SIZE_HARD_MAX: 0.45,
|
|
462
|
+
ROLL_MAX_DEG: 25,
|
|
463
|
+
MOTION_VELOCITY_IOD_PER_S: 0.5,
|
|
464
|
+
MOTION_TAU_S: 0.4,
|
|
465
|
+
MOTION_DEADBAND_IOD_PER_S: 0.1,
|
|
466
|
+
READY_WINDOW_MS: 500,
|
|
467
|
+
// 0.7→0.75 with the trimmed window: trimming forgives exactly ONE flaky
|
|
468
|
+
// tick; the raised mean keeps a SECOND bad tick (a real sustained wobble)
|
|
469
|
+
// from diluting through at 10 Hz cadences.
|
|
470
|
+
READY_MEAN_Q: 0.75,
|
|
471
|
+
GUIDE_SIZE: 0.6,
|
|
472
|
+
GUIDE_CENTERING: 0.6,
|
|
473
|
+
GUIDE_POSE: 0.6,
|
|
474
|
+
GUIDE_EXPOSURE: 0.6,
|
|
475
|
+
GUIDE_SHARPNESS: 0.35,
|
|
476
|
+
GUIDE_MOTION: 0.5
|
|
477
|
+
});
|
|
478
|
+
var clamp01 = (value) => Math.min(1, Math.max(0, value));
|
|
479
|
+
var FaceGateV3 = class {
|
|
480
|
+
constructor(overrides = {}) {
|
|
481
|
+
this.p = 0;
|
|
482
|
+
this.lastTickAt = null;
|
|
483
|
+
this.lastCenter = null;
|
|
484
|
+
this.velocityEma = 0;
|
|
485
|
+
this.qWindow = [];
|
|
486
|
+
/** Which side of the ideal band the last size measurement fell on. */
|
|
487
|
+
this.lastSizeDirection = "ideal";
|
|
488
|
+
this.t = { ...V3_GATE_DEFAULTS, ...overrides };
|
|
489
|
+
}
|
|
490
|
+
reset() {
|
|
491
|
+
this.p = 0;
|
|
492
|
+
this.lastTickAt = null;
|
|
493
|
+
this.lastCenter = null;
|
|
494
|
+
this.velocityEma = 0;
|
|
495
|
+
this.qWindow = [];
|
|
496
|
+
this.lastSizeDirection = "ideal";
|
|
497
|
+
}
|
|
498
|
+
tick(input) {
|
|
499
|
+
const { observation, quality, timestampMs } = input;
|
|
500
|
+
const dtSeconds = this.lastTickAt === null ? 0 : Math.max(0, (timestampMs - this.lastTickAt) / 1e3);
|
|
501
|
+
this.lastTickAt = timestampMs;
|
|
502
|
+
const factors = {
|
|
503
|
+
presence: 0,
|
|
504
|
+
centering: 0,
|
|
505
|
+
size: 0,
|
|
506
|
+
pose: 0,
|
|
507
|
+
sharpness: clamp01(quality.sharpness),
|
|
508
|
+
exposure: clamp01(quality.exposure),
|
|
509
|
+
motion: 1
|
|
510
|
+
};
|
|
511
|
+
if (observation !== null) {
|
|
512
|
+
factors.presence = 1;
|
|
513
|
+
const geometry = this.geometryOf(observation, input);
|
|
514
|
+
factors.centering = geometry.centering;
|
|
515
|
+
factors.size = geometry.size;
|
|
516
|
+
factors.pose = geometry.pose;
|
|
517
|
+
factors.motion = this.motionOf(
|
|
518
|
+
geometry.center,
|
|
519
|
+
geometry.iod,
|
|
520
|
+
timestampMs
|
|
521
|
+
);
|
|
522
|
+
} else {
|
|
523
|
+
this.lastCenter = null;
|
|
524
|
+
this.velocityEma = 0;
|
|
525
|
+
}
|
|
526
|
+
const q = Math.min(
|
|
527
|
+
factors.presence,
|
|
528
|
+
factors.centering,
|
|
529
|
+
factors.size,
|
|
530
|
+
factors.pose,
|
|
531
|
+
factors.sharpness,
|
|
532
|
+
factors.exposure,
|
|
533
|
+
factors.motion
|
|
534
|
+
);
|
|
535
|
+
this.p = clamp01(
|
|
536
|
+
this.p + (q >= this.t.Q_GOOD ? this.t.RISE_PER_S * q : -this.t.FALL_PER_S * (1 - q)) * dtSeconds
|
|
537
|
+
);
|
|
538
|
+
this.qWindow.push({ at: timestampMs, q });
|
|
539
|
+
this.qWindow = this.qWindow.filter(
|
|
540
|
+
(entry) => entry.at >= timestampMs - this.t.READY_WINDOW_MS
|
|
541
|
+
);
|
|
542
|
+
let windowSamples = this.qWindow.map((entry) => entry.q);
|
|
543
|
+
if (windowSamples.length >= 3) {
|
|
544
|
+
const worst = Math.min(...windowSamples);
|
|
545
|
+
const worstIndex = windowSamples.indexOf(worst);
|
|
546
|
+
windowSamples = windowSamples.filter((_, index) => index !== worstIndex);
|
|
547
|
+
}
|
|
548
|
+
const windowMean = windowSamples.reduce((sum, value) => sum + value, 0) / Math.max(1, windowSamples.length);
|
|
549
|
+
const ready = this.p >= 1 && windowMean >= this.t.READY_MEAN_Q;
|
|
550
|
+
return {
|
|
551
|
+
at: timestampMs,
|
|
552
|
+
q,
|
|
553
|
+
p: this.p,
|
|
554
|
+
factors,
|
|
555
|
+
guidanceKey: this.guidanceOf(factors, quality, q),
|
|
556
|
+
ready
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
geometryOf(observation, input) {
|
|
560
|
+
const { box, eyes } = observation;
|
|
561
|
+
const frameCenter = { x: input.frameWidth / 2, y: input.frameHeight / 2 };
|
|
562
|
+
const shortSide = Math.min(input.frameWidth, input.frameHeight);
|
|
563
|
+
const center = eyes !== void 0 ? {
|
|
564
|
+
x: (eyes.left.x + eyes.right.x) / 2,
|
|
565
|
+
y: (eyes.left.y + eyes.right.y) / 2
|
|
566
|
+
} : {
|
|
567
|
+
x: box.x + box.width / 2,
|
|
568
|
+
y: box.y + box.height / 2 - this.t.BOX_EYE_OFFSET_FACTOR * box.height
|
|
569
|
+
};
|
|
570
|
+
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;
|
|
571
|
+
const distance = Math.hypot(
|
|
572
|
+
center.x - frameCenter.x,
|
|
573
|
+
center.y - frameCenter.y
|
|
574
|
+
);
|
|
575
|
+
const centering = iod <= 0 ? 0 : 1 - clamp01(distance / (this.t.CENTER_FALLOFF_IOD_MULTIPLE * iod));
|
|
576
|
+
const ratio = shortSide > 0 ? iod / shortSide : 0;
|
|
577
|
+
this.lastSizeDirection = ratio < this.t.SIZE_IDEAL_MIN ? "far" : ratio > this.t.SIZE_IDEAL_MAX ? "close" : "ideal";
|
|
578
|
+
let size;
|
|
579
|
+
if (ratio < this.t.SIZE_HARD_MIN || ratio > this.t.SIZE_HARD_MAX) {
|
|
580
|
+
size = 0;
|
|
581
|
+
} else if (ratio < this.t.SIZE_IDEAL_MIN) {
|
|
582
|
+
size = (ratio - this.t.SIZE_HARD_MIN) / (this.t.SIZE_IDEAL_MIN - this.t.SIZE_HARD_MIN);
|
|
583
|
+
} else if (ratio > this.t.SIZE_IDEAL_MAX) {
|
|
584
|
+
size = (this.t.SIZE_HARD_MAX - ratio) / (this.t.SIZE_HARD_MAX - this.t.SIZE_IDEAL_MAX);
|
|
585
|
+
} else {
|
|
586
|
+
size = 1;
|
|
587
|
+
}
|
|
588
|
+
let pose = 1;
|
|
589
|
+
if (eyes !== void 0) {
|
|
590
|
+
const rollDeg = Math.abs(
|
|
591
|
+
Math.atan2(eyes.right.y - eyes.left.y, eyes.right.x - eyes.left.x)
|
|
592
|
+
) * 180 / Math.PI;
|
|
593
|
+
pose = 1 - clamp01(rollDeg / this.t.ROLL_MAX_DEG);
|
|
594
|
+
}
|
|
595
|
+
return { center, iod, centering, size, pose };
|
|
596
|
+
}
|
|
597
|
+
motionOf(center, iod, at) {
|
|
598
|
+
const previous = this.lastCenter;
|
|
599
|
+
this.lastCenter = { ...center, at };
|
|
600
|
+
if (previous === null || iod <= 0) return 1;
|
|
601
|
+
const dtSeconds = (at - previous.at) / 1e3;
|
|
602
|
+
if (dtSeconds <= 0) return 1;
|
|
603
|
+
let velocityIodPerS = Math.hypot(center.x - previous.x, center.y - previous.y) / iod / dtSeconds;
|
|
604
|
+
if (velocityIodPerS <= this.t.MOTION_DEADBAND_IOD_PER_S)
|
|
605
|
+
velocityIodPerS = 0;
|
|
606
|
+
const alpha = 1 - Math.exp(-dtSeconds / this.t.MOTION_TAU_S);
|
|
607
|
+
this.velocityEma = alpha * velocityIodPerS + (1 - alpha) * this.velocityEma;
|
|
608
|
+
return 1 - clamp01(this.velocityEma / this.t.MOTION_VELOCITY_IOD_PER_S);
|
|
609
|
+
}
|
|
610
|
+
/** Lowest failing factor speaks — fixed priority (diagnostic, incl. light):
|
|
611
|
+
* presence → size → centering → pose → exposure → sharpness → hold.
|
|
612
|
+
*
|
|
613
|
+
* HONESTY FALLBACK (operator ruling 2026-07-12): when every factor clears
|
|
614
|
+
* its guide threshold but q (= min factor) is still below Q_GOOD — i.e.
|
|
615
|
+
* progress is DRAINING — "hold still" would be a lie ("Perfect" while
|
|
616
|
+
* nothing advances; the field symptom was users discovering by accident
|
|
617
|
+
* that only leaning in fires the gate). In that state the WORST factor
|
|
618
|
+
* speaks even though it passed its guide level, so the user always hears
|
|
619
|
+
* the change that actually unblocks recording. */
|
|
620
|
+
guidanceOf(factors, quality, q) {
|
|
621
|
+
if (factors.presence <= 0) return "fcv3.no_face";
|
|
622
|
+
if (factors.size < this.t.GUIDE_SIZE) return this.sizeGuidance();
|
|
623
|
+
if (factors.centering < this.t.GUIDE_CENTERING) return "fcv3.center_face";
|
|
624
|
+
if (factors.pose < this.t.GUIDE_POSE) return "fcv3.straighten";
|
|
625
|
+
if (factors.exposure < this.t.GUIDE_EXPOSURE)
|
|
626
|
+
return this.exposureGuidance(quality);
|
|
627
|
+
if (factors.sharpness < this.t.GUIDE_SHARPNESS) return "fcv3.steady_camera";
|
|
628
|
+
if (factors.motion < this.t.GUIDE_MOTION) return "fcv3.hold_still";
|
|
629
|
+
if (q < this.t.Q_GOOD) {
|
|
630
|
+
const coachable = [
|
|
631
|
+
["size", this.sizeGuidance()],
|
|
632
|
+
["centering", "fcv3.center_face"],
|
|
633
|
+
["pose", "fcv3.straighten"],
|
|
634
|
+
["exposure", this.exposureGuidance(quality)],
|
|
635
|
+
["sharpness", "fcv3.steady_camera"],
|
|
636
|
+
["motion", "fcv3.hold_still"]
|
|
637
|
+
];
|
|
638
|
+
let worst = coachable[0];
|
|
639
|
+
for (const entry of coachable) {
|
|
640
|
+
if (factors[entry[0]] < factors[worst[0]]) worst = entry;
|
|
641
|
+
}
|
|
642
|
+
return worst[1];
|
|
643
|
+
}
|
|
644
|
+
return "fcv3.hold_still";
|
|
645
|
+
}
|
|
646
|
+
/** Direction from the size band set THIS tick (never inverted at the hard
|
|
647
|
+
* limits: past SIZE_HARD_MAX the direction is 'close' → move_back). */
|
|
648
|
+
sizeGuidance() {
|
|
649
|
+
return this.lastSizeDirection === "far" ? "fcv3.come_closer" : "fcv3.move_back";
|
|
650
|
+
}
|
|
651
|
+
/** Band midpoint — direction only (tracks EXPOSURE_BAND automatically). */
|
|
652
|
+
exposureGuidance(quality) {
|
|
653
|
+
const bandMid = (EXPOSURE_BAND[0] + EXPOSURE_BAND[1]) / 2;
|
|
654
|
+
return quality.meanLuma < bandMid ? "fcv3.more_light" : "fcv3.too_bright";
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
// src/core/v3/capture-session-v3.ts
|
|
659
|
+
var KEYFRAME_CANDIDATES = 4;
|
|
660
|
+
var KEYFRAME_WINDOW_MS = 600;
|
|
661
|
+
var NEUTRAL_QUALITY = { sharpness: 1, exposure: 1, meanLuma: 128 };
|
|
662
|
+
var CaptureSessionV3 = class {
|
|
663
|
+
constructor(options) {
|
|
664
|
+
this.handlers = /* @__PURE__ */ new Map();
|
|
665
|
+
this.stateValue = "idle";
|
|
666
|
+
this.attempt = 0;
|
|
667
|
+
this.stream = null;
|
|
668
|
+
this.detector = null;
|
|
669
|
+
this.scheduler = null;
|
|
670
|
+
this.gate = null;
|
|
671
|
+
this.profiler = null;
|
|
672
|
+
this.lastGuidanceKey = null;
|
|
673
|
+
this.positioningStartedAt = 0;
|
|
674
|
+
this.fired = false;
|
|
675
|
+
this.carriedDegrade = 0;
|
|
676
|
+
this.carriedMeans = [];
|
|
677
|
+
/** Reused downscale canvas — falls back to the raw video where canvas 2D
|
|
678
|
+
* is unavailable (jsdom) so tests and exotic hosts keep detecting. */
|
|
679
|
+
this.snapshotCanvas = null;
|
|
680
|
+
this.options = options;
|
|
681
|
+
this.facing = options.facingMode ?? "user";
|
|
682
|
+
}
|
|
683
|
+
get state() {
|
|
684
|
+
return this.stateValue;
|
|
685
|
+
}
|
|
686
|
+
on(event, handler) {
|
|
687
|
+
let set = this.handlers.get(event);
|
|
688
|
+
if (!set) {
|
|
689
|
+
set = /* @__PURE__ */ new Set();
|
|
690
|
+
this.handlers.set(event, set);
|
|
691
|
+
}
|
|
692
|
+
set.add(handler);
|
|
693
|
+
return () => set?.delete(handler);
|
|
694
|
+
}
|
|
695
|
+
emit(event, payload) {
|
|
696
|
+
for (const handler of [...this.handlers.get(event) ?? []]) {
|
|
697
|
+
handler(payload);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
async start() {
|
|
701
|
+
this.attempt += 1;
|
|
702
|
+
await this.run(this.attempt);
|
|
703
|
+
}
|
|
704
|
+
/** Full reset + camera restart — the documented recovery path (v1 parity). */
|
|
705
|
+
retry() {
|
|
706
|
+
this.teardownAttempt();
|
|
707
|
+
void this.start();
|
|
708
|
+
}
|
|
709
|
+
switchCamera() {
|
|
710
|
+
this.facing = this.facing === "user" ? "environment" : "user";
|
|
711
|
+
this.teardownAttempt();
|
|
712
|
+
void this.start();
|
|
713
|
+
}
|
|
714
|
+
destroy() {
|
|
715
|
+
this.attempt += 1;
|
|
716
|
+
this.teardownAttempt();
|
|
717
|
+
this.detector?.destroy();
|
|
718
|
+
this.detector = null;
|
|
719
|
+
this.options.video.srcObject = null;
|
|
720
|
+
}
|
|
721
|
+
// ── attempt lifecycle ──────────────────────────────────────────────────────
|
|
722
|
+
async run(attempt) {
|
|
723
|
+
const { options } = this;
|
|
724
|
+
this.fired = false;
|
|
725
|
+
this.lastGuidanceKey = null;
|
|
726
|
+
this.carriedDegrade = 0;
|
|
727
|
+
this.carriedMeans = [];
|
|
728
|
+
this.setState("preflight");
|
|
729
|
+
const preflight = cameraPreflight();
|
|
730
|
+
if (preflight !== "OK") {
|
|
731
|
+
this.fail(preflight === "INSECURE_CONTEXT" ? "insecure" : "unsupported");
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
this.setState("acquiring");
|
|
735
|
+
const getUserMedia = options.getUserMedia ?? ((constraints) => navigator.mediaDevices.getUserMedia(constraints));
|
|
736
|
+
let stream;
|
|
737
|
+
try {
|
|
738
|
+
stream = await getUserMedia(buildCaptureConstraints(detectIsMobile(), this.facing));
|
|
739
|
+
} catch (err) {
|
|
740
|
+
if (attempt !== this.attempt) return;
|
|
741
|
+
this.fail(mapGetUserMediaError(err));
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (attempt !== this.attempt) {
|
|
745
|
+
for (const track of stream.getTracks()) track.stop();
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
this.stream = stream;
|
|
749
|
+
options.video.srcObject = stream;
|
|
750
|
+
await options.video.play().catch(() => void 0);
|
|
751
|
+
if (attempt !== this.attempt) return;
|
|
752
|
+
if (this.detector === null) {
|
|
753
|
+
const ladderOptions = {
|
|
754
|
+
...options.v3AssetBaseUrl !== void 0 ? { v3AssetBaseUrl: options.v3AssetBaseUrl } : {}
|
|
755
|
+
};
|
|
756
|
+
const create = options.createDetector ?? (options.createWorker !== void 0 && workerOffloadSupported() ? async () => {
|
|
757
|
+
try {
|
|
758
|
+
return await WorkerDetectorProxy.create(
|
|
759
|
+
options.createWorker(),
|
|
760
|
+
ladderOptions
|
|
761
|
+
);
|
|
762
|
+
} catch {
|
|
763
|
+
return createDetector(ladderOptions);
|
|
764
|
+
}
|
|
765
|
+
} : () => createDetector(ladderOptions));
|
|
766
|
+
let created;
|
|
767
|
+
try {
|
|
768
|
+
created = await create();
|
|
769
|
+
} catch (err) {
|
|
770
|
+
if (attempt === this.attempt) {
|
|
771
|
+
this.fail(
|
|
772
|
+
"models",
|
|
773
|
+
err instanceof DetectorLadderError ? err.fallbackReason : describeError(err)
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
if (attempt !== this.attempt) {
|
|
779
|
+
created.destroy();
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
this.detector = created;
|
|
783
|
+
this.options.telemetry?.({
|
|
784
|
+
type: "detector",
|
|
785
|
+
tier: created.tier,
|
|
786
|
+
...created.fallbackReason !== void 0 ? { fallbackReason: created.fallbackReason } : {}
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
const probe = probeDevice();
|
|
790
|
+
const staticClass = options.deviceClass ?? classifyDevice(probe);
|
|
791
|
+
this.profiler = new DeviceProfiler(staticClass);
|
|
792
|
+
this.options.telemetry?.({
|
|
793
|
+
type: "profile",
|
|
794
|
+
staticClass,
|
|
795
|
+
activeClass: this.profiler.deviceClass,
|
|
796
|
+
simd: probe.simd
|
|
797
|
+
});
|
|
798
|
+
this.gate = new FaceGateV3(options.thresholds ?? {});
|
|
799
|
+
this.setState("positioning");
|
|
800
|
+
this.positioningStartedAt = this.now();
|
|
801
|
+
this.startScheduler(attempt);
|
|
802
|
+
}
|
|
803
|
+
/** Chrome hint (Task 11): 'reduced' on LOW even without the media query. */
|
|
804
|
+
get animations() {
|
|
805
|
+
return this.profiler?.profile.animations ?? "full";
|
|
806
|
+
}
|
|
807
|
+
startScheduler(attempt) {
|
|
808
|
+
const profiler = this.profiler;
|
|
809
|
+
if (profiler === null) return;
|
|
810
|
+
if (this.scheduler !== null) {
|
|
811
|
+
const stats = this.scheduler.stats();
|
|
812
|
+
this.carriedDegrade += stats.degradeCount;
|
|
813
|
+
if (stats.meanDetectMs > 0) this.carriedMeans.push(stats.meanDetectMs);
|
|
814
|
+
this.scheduler.stop();
|
|
815
|
+
}
|
|
816
|
+
const scheduler = new DetectScheduler({
|
|
817
|
+
cadenceHz: profiler.profile.cadenceHz,
|
|
818
|
+
video: this.options.video,
|
|
819
|
+
...this.options.now !== void 0 ? { now: this.options.now } : {},
|
|
820
|
+
run: () => this.detectTick(attempt)
|
|
821
|
+
});
|
|
822
|
+
this.scheduler = scheduler;
|
|
823
|
+
scheduler.start();
|
|
824
|
+
}
|
|
825
|
+
async detectTick(attempt) {
|
|
826
|
+
const { detector, gate, profiler } = this;
|
|
827
|
+
const video = this.options.video;
|
|
828
|
+
if (attempt !== this.attempt || detector === null || gate === null || profiler === null)
|
|
829
|
+
return;
|
|
830
|
+
if (this.fired || video.videoWidth === 0) return;
|
|
831
|
+
const before = profiler.deviceClass;
|
|
832
|
+
const t0 = this.now();
|
|
833
|
+
const source = this.options.makeSnapshot?.(video, profiler.profile.inputWidth) ?? this.defaultSnapshot(video, profiler.profile.inputWidth);
|
|
834
|
+
let observation = null;
|
|
835
|
+
try {
|
|
836
|
+
observation = await detector.detect(source, video.videoWidth, video.videoHeight);
|
|
837
|
+
} catch {
|
|
838
|
+
observation = null;
|
|
839
|
+
}
|
|
840
|
+
if (attempt !== this.attempt || this.fired) return;
|
|
841
|
+
profiler.record(this.now() - t0);
|
|
842
|
+
if (profiler.deviceClass !== before) this.startScheduler(attempt);
|
|
843
|
+
const quality = observation === null ? NEUTRAL_QUALITY : this.options.measureQuality?.(video, observation.box) ?? this.defaultQuality(video, observation.box);
|
|
844
|
+
const tick = gate.tick({
|
|
845
|
+
timestampMs: this.now(),
|
|
846
|
+
observation,
|
|
847
|
+
quality,
|
|
848
|
+
frameWidth: video.videoWidth,
|
|
849
|
+
frameHeight: video.videoHeight
|
|
850
|
+
});
|
|
851
|
+
this.emit("checks", {
|
|
852
|
+
engine: "v3",
|
|
853
|
+
progress: tick.p,
|
|
854
|
+
q: tick.q,
|
|
855
|
+
factors: tick.factors,
|
|
856
|
+
attempt
|
|
857
|
+
});
|
|
858
|
+
if (tick.guidanceKey !== this.lastGuidanceKey) {
|
|
859
|
+
this.lastGuidanceKey = tick.guidanceKey;
|
|
860
|
+
this.emit("guidance", { key: tick.guidanceKey });
|
|
861
|
+
}
|
|
862
|
+
if (tick.ready && this.stateValue === "positioning") {
|
|
863
|
+
this.fired = true;
|
|
864
|
+
void this.fire(attempt);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
async fire(attempt) {
|
|
868
|
+
const { options } = this;
|
|
869
|
+
const scheduler = this.scheduler;
|
|
870
|
+
scheduler?.stop();
|
|
871
|
+
const stats = scheduler?.stats() ?? { degradeCount: 0, meanDetectMs: 0 };
|
|
872
|
+
const means = [...this.carriedMeans, ...stats.meanDetectMs > 0 ? [stats.meanDetectMs] : []];
|
|
873
|
+
options.telemetry?.({
|
|
874
|
+
type: "fired",
|
|
875
|
+
timeToGateMs: this.now() - this.positioningStartedAt,
|
|
876
|
+
degradeCount: this.carriedDegrade + stats.degradeCount,
|
|
877
|
+
meanDetectMs: means.length === 0 ? 0 : means.reduce((sum, value) => sum + value, 0) / means.length
|
|
878
|
+
});
|
|
879
|
+
const mode = options.mode ?? "clip";
|
|
880
|
+
const bestOf = this.collectKeyframes(attempt);
|
|
881
|
+
if (mode === "photo") {
|
|
882
|
+
const best2 = await bestOf;
|
|
883
|
+
if (attempt !== this.attempt) return;
|
|
884
|
+
if (best2 === null) {
|
|
885
|
+
this.fail("generic");
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
this.finishCapture({ keyframe: best2.blob, previewDataUrl: best2.previewDataUrl });
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
this.setState("recording");
|
|
892
|
+
const recorder = options.createRecorder?.() ?? new ClipRecorder();
|
|
893
|
+
const stream = this.stream;
|
|
894
|
+
if (stream === null) {
|
|
895
|
+
this.fail("generic");
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
let frozenBest = null;
|
|
899
|
+
void bestOf.then(
|
|
900
|
+
(candidate) => {
|
|
901
|
+
frozenBest = candidate;
|
|
902
|
+
},
|
|
903
|
+
() => {
|
|
904
|
+
}
|
|
905
|
+
);
|
|
906
|
+
const onRecordingStopped = () => {
|
|
907
|
+
if (attempt !== this.attempt) return;
|
|
908
|
+
if (frozenBest !== null) {
|
|
909
|
+
this.emit("frozen", { previewDataUrl: frozenBest.previewDataUrl });
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
let clip;
|
|
913
|
+
let best;
|
|
914
|
+
try {
|
|
915
|
+
[clip, best] = await Promise.all([recorder.record(stream, onRecordingStopped), bestOf]);
|
|
916
|
+
} catch (err) {
|
|
917
|
+
if (attempt !== this.attempt) return;
|
|
918
|
+
const kind = err.kind;
|
|
919
|
+
this.fail(kind === "cliptoolarge" ? "cliptoolarge" : "recorder");
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
if (attempt !== this.attempt) return;
|
|
923
|
+
if (isClipTooLarge(clip.blob)) {
|
|
924
|
+
this.fail("cliptoolarge");
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
if (best === null) {
|
|
928
|
+
this.fail("generic");
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
this.finishCapture({
|
|
932
|
+
clip: clip.blob,
|
|
933
|
+
clipMimeType: clip.mimeType,
|
|
934
|
+
keyframe: best.blob,
|
|
935
|
+
previewDataUrl: best.previewDataUrl
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
/** Up to KEYFRAME_CANDIDATES mirrored frames over KEYFRAME_WINDOW_MS —
|
|
939
|
+
* sharpest wins (reuses frame-quality.sharpness via the capture seam). */
|
|
940
|
+
async collectKeyframes(attempt) {
|
|
941
|
+
const capture = this.options.captureKeyframe ?? ((video) => this.defaultKeyframe(video));
|
|
942
|
+
const stepMs = KEYFRAME_WINDOW_MS / (KEYFRAME_CANDIDATES - 1);
|
|
943
|
+
let best = null;
|
|
944
|
+
for (let i = 0; i < KEYFRAME_CANDIDATES; i += 1) {
|
|
945
|
+
if (attempt !== this.attempt) return best;
|
|
946
|
+
try {
|
|
947
|
+
const candidate = await capture(this.options.video);
|
|
948
|
+
if (best === null || candidate.sharpness > best.sharpness) best = candidate;
|
|
949
|
+
} catch {
|
|
950
|
+
}
|
|
951
|
+
if (i < KEYFRAME_CANDIDATES - 1) await delay(stepMs);
|
|
952
|
+
}
|
|
953
|
+
return best;
|
|
954
|
+
}
|
|
955
|
+
finishCapture(artifacts) {
|
|
956
|
+
this.stopTracks();
|
|
957
|
+
this.setState("captured");
|
|
958
|
+
this.emit("artifacts", artifacts);
|
|
959
|
+
}
|
|
960
|
+
// ── defaults for the canvas-bound seams ────────────────────────────────────
|
|
961
|
+
async defaultKeyframe(video) {
|
|
962
|
+
const canvas = document.createElement("canvas");
|
|
963
|
+
const blob = await captureMirroredKeyframe(video, canvas);
|
|
964
|
+
const previewDataUrl = canvas.toDataURL("image/jpeg", 0.92);
|
|
965
|
+
return { blob, previewDataUrl, sharpness: this.centerSharpness(video) };
|
|
966
|
+
}
|
|
967
|
+
defaultSnapshot(video, targetWidth) {
|
|
968
|
+
try {
|
|
969
|
+
const canvas = this.snapshotCanvas ?? document.createElement("canvas");
|
|
970
|
+
const scale = targetWidth / Math.max(1, video.videoWidth);
|
|
971
|
+
const width = Math.max(1, Math.round(video.videoWidth * scale));
|
|
972
|
+
const height = Math.max(1, Math.round(video.videoHeight * scale));
|
|
973
|
+
const ctx = canvas.getContext("2d");
|
|
974
|
+
if (ctx === null) return video;
|
|
975
|
+
if (canvas.width !== width) canvas.width = width;
|
|
976
|
+
if (canvas.height !== height) canvas.height = height;
|
|
977
|
+
ctx.drawImage(video, 0, 0, width, height);
|
|
978
|
+
this.snapshotCanvas = canvas;
|
|
979
|
+
return canvas;
|
|
980
|
+
} catch {
|
|
981
|
+
return video;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
defaultQuality(video, box) {
|
|
985
|
+
const crop = cropImageData(video, box, 96);
|
|
986
|
+
if (crop === null) return NEUTRAL_QUALITY;
|
|
987
|
+
return { sharpness: sharpness(crop), exposure: exposure(crop), meanLuma: meanLuma(crop) };
|
|
988
|
+
}
|
|
989
|
+
centerSharpness(video) {
|
|
990
|
+
const size = intrinsicSize(video);
|
|
991
|
+
const crop = cropImageData(
|
|
992
|
+
video,
|
|
993
|
+
{ x: size.width * 0.25, y: size.height * 0.25, width: size.width * 0.5, height: size.height * 0.5 },
|
|
994
|
+
96
|
|
995
|
+
);
|
|
996
|
+
return crop === null ? 0 : sharpness(crop);
|
|
997
|
+
}
|
|
998
|
+
// ── teardown ──────────────────────────────────────────────────────────────
|
|
999
|
+
teardownAttempt() {
|
|
1000
|
+
this.scheduler?.stop();
|
|
1001
|
+
this.scheduler = null;
|
|
1002
|
+
this.gate = null;
|
|
1003
|
+
this.stopTracks();
|
|
1004
|
+
}
|
|
1005
|
+
stopTracks() {
|
|
1006
|
+
if (this.stream === null) return;
|
|
1007
|
+
for (const track of this.stream.getTracks()) track.stop();
|
|
1008
|
+
this.stream = null;
|
|
1009
|
+
}
|
|
1010
|
+
fail(kind, fallbackReason) {
|
|
1011
|
+
this.scheduler?.stop();
|
|
1012
|
+
this.stopTracks();
|
|
1013
|
+
this.setState("error");
|
|
1014
|
+
this.options.telemetry?.({
|
|
1015
|
+
type: "error",
|
|
1016
|
+
kind,
|
|
1017
|
+
...fallbackReason !== void 0 ? { fallbackReason } : {}
|
|
1018
|
+
});
|
|
1019
|
+
this.emit("error", { kind, retryable: kind !== "insecure" && kind !== "unsupported" });
|
|
1020
|
+
}
|
|
1021
|
+
setState(state) {
|
|
1022
|
+
this.stateValue = state;
|
|
1023
|
+
this.emit("statechange", { state });
|
|
1024
|
+
}
|
|
1025
|
+
now() {
|
|
1026
|
+
return this.options.now?.() ?? performance.now();
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
function delay(ms) {
|
|
1030
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1031
|
+
}
|
|
1032
|
+
function describeError(err) {
|
|
1033
|
+
return err instanceof Error ? err.message : String(err);
|
|
1034
|
+
}
|
|
1035
|
+
function cropImageData(video, box, targetWidth) {
|
|
1036
|
+
try {
|
|
1037
|
+
const canvas = document.createElement("canvas");
|
|
1038
|
+
const scale = targetWidth / Math.max(1, box.width);
|
|
1039
|
+
canvas.width = Math.max(1, Math.round(box.width * scale));
|
|
1040
|
+
canvas.height = Math.max(1, Math.round(box.height * scale));
|
|
1041
|
+
const ctx = canvas.getContext("2d");
|
|
1042
|
+
if (ctx === null) return null;
|
|
1043
|
+
ctx.drawImage(
|
|
1044
|
+
video,
|
|
1045
|
+
box.x,
|
|
1046
|
+
box.y,
|
|
1047
|
+
box.width,
|
|
1048
|
+
box.height,
|
|
1049
|
+
0,
|
|
1050
|
+
0,
|
|
1051
|
+
canvas.width,
|
|
1052
|
+
canvas.height
|
|
1053
|
+
);
|
|
1054
|
+
return ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
1055
|
+
} catch {
|
|
1056
|
+
return null;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
export {
|
|
1061
|
+
buildCaptureConstraints,
|
|
1062
|
+
detectIsMobile,
|
|
1063
|
+
CIRCLE_RADIUS_FACTOR,
|
|
1064
|
+
IN_CIRCLE_FACTOR,
|
|
1065
|
+
CENTERED_FACTOR,
|
|
1066
|
+
MIME_CANDIDATES,
|
|
1067
|
+
pickMimeType,
|
|
1068
|
+
DEFAULT_CLIP_MIME,
|
|
1069
|
+
mapGetUserMediaError,
|
|
1070
|
+
cameraPreflight,
|
|
1071
|
+
isMediaRecorderSupported,
|
|
1072
|
+
MAX_CLIP_BYTES,
|
|
1073
|
+
isClipTooLarge,
|
|
1074
|
+
extForMime,
|
|
1075
|
+
buildLivenessV2FormData,
|
|
1076
|
+
CLIP_MS,
|
|
1077
|
+
TIMESLICE_MS,
|
|
1078
|
+
STOP_GRACE_MS,
|
|
1079
|
+
VIDEO_BPS,
|
|
1080
|
+
ClipError,
|
|
1081
|
+
ClipRecorder,
|
|
1082
|
+
captureMirroredKeyframe,
|
|
1083
|
+
DEVICE_PROFILES,
|
|
1084
|
+
probeDevice,
|
|
1085
|
+
classifyDevice,
|
|
1086
|
+
DeviceProfiler,
|
|
1087
|
+
DetectScheduler,
|
|
1088
|
+
V3_GATE_DEFAULTS,
|
|
1089
|
+
FaceGateV3,
|
|
1090
|
+
KEYFRAME_CANDIDATES,
|
|
1091
|
+
KEYFRAME_WINDOW_MS,
|
|
1092
|
+
CaptureSessionV3
|
|
1093
|
+
};
|
|
1094
|
+
//# sourceMappingURL=chunk-LOTAJDRE.js.map
|