@beltec/face-gate 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,653 @@
1
+ "use client";
2
+
3
+ // src/react/FaceGate.tsx
4
+ import { useEffect, useRef, useState } from "react";
5
+
6
+ // src/core/types.ts
7
+ var FaceGateError = class extends Error {
8
+ constructor(code, message, status, resetsAt) {
9
+ super(message);
10
+ this.code = code;
11
+ this.status = status;
12
+ this.resetsAt = resetsAt;
13
+ this.name = "FaceGateError";
14
+ }
15
+ };
16
+
17
+ // src/core/stream.ts
18
+ var CLOSE = {
19
+ 4400: ["bad_request", "A API recusou a mensagem da c\xE2mera."],
20
+ 4401: ["unauthorized", "Sess\xE3o da c\xE2mera expirada. Tente de novo."],
21
+ 4402: ["token_month_limit", "Limite mensal de tokens atingido."],
22
+ 4403: ["forbidden", "Acesso negado para esta valida\xE7\xE3o."],
23
+ 4408: ["bad_request", "A c\xE2mera ficou ligada tempo demais."],
24
+ 4429: ["token_window_limit", "Limite de uso atingido. Aguarde e tente de novo."],
25
+ 1011: ["server", "Erro interno na API."]
26
+ };
27
+ var DEFAULT_FPS = 6;
28
+ var MAX_BUFFERED = 32 * 1024;
29
+ function openFaceGateStream(url, ticket, options, handlers, WS = WebSocket) {
30
+ const ws = new WS(url);
31
+ ws.binaryType = "arraybuffer";
32
+ let closedByUs = false;
33
+ let finished = false;
34
+ let seq = 0;
35
+ ws.onopen = () => ws.send(JSON.stringify({ type: "start", token: ticket, ...options }));
36
+ ws.onmessage = (e) => {
37
+ if (typeof e.data !== "string") return;
38
+ const m = JSON.parse(e.data);
39
+ if (m.type === "ready") handlers.onReady?.(Number(m.max_fps) || DEFAULT_FPS);
40
+ else if (m.type === "frame") handlers.onFrame?.(m.seq, m.result, m.stable ?? { ms: 0, needed: 0 });
41
+ else if (m.type === "captured") handlers.onCaptured?.(m.seq);
42
+ else if (m.type === "final") {
43
+ finished = true;
44
+ handlers.onFinal?.(m.seq, m.result);
45
+ }
46
+ };
47
+ ws.onclose = (e) => {
48
+ if (closedByUs || finished) return;
49
+ const [code, message] = CLOSE[e.code] ?? ["network", e.reason || "Conex\xE3o com a API perdida."];
50
+ handlers.onError?.(new FaceGateError(code, e.reason || message, e.code));
51
+ };
52
+ const open = () => ws.readyState === 1;
53
+ return {
54
+ sendFrame: (frame) => {
55
+ if (!open() || ws.bufferedAmount > MAX_BUFFERED) return null;
56
+ ws.send(frame);
57
+ return ++seq;
58
+ },
59
+ configure: (o) => {
60
+ if (open()) ws.send(JSON.stringify({ type: "config", ...o }));
61
+ },
62
+ reset: () => {
63
+ finished = false;
64
+ if (open()) ws.send(JSON.stringify({ type: "reset" }));
65
+ },
66
+ close: () => {
67
+ closedByUs = true;
68
+ if (ws.readyState <= 1) ws.close(1e3);
69
+ }
70
+ };
71
+ }
72
+
73
+ // src/react/Emblem.tsx
74
+ import { useId } from "react";
75
+ import { jsx, jsxs } from "react/jsx-runtime";
76
+ function Emblem({ animated = false, className }) {
77
+ const id = useId().replace(/:/g, "");
78
+ const shield = "M4 4.5Q4 4 4.5 4H43.5Q44 4 44 4.5V30C44 42 35 52 24 52C13 52 4 42 4 30Z";
79
+ return /* @__PURE__ */ jsxs(
80
+ "svg",
81
+ {
82
+ viewBox: "0 0 48 56",
83
+ fill: "none",
84
+ "aria-hidden": "true",
85
+ className: [className, animated ? "fg-emblem-animated" : ""].filter(Boolean).join(" "),
86
+ children: [
87
+ /* @__PURE__ */ jsxs("defs", { children: [
88
+ /* @__PURE__ */ jsxs("linearGradient", { id: `${id}g`, x1: "0", y1: "0", x2: "1", y2: "1", children: [
89
+ /* @__PURE__ */ jsx("stop", { offset: "0", stopColor: "#e3c35c" }),
90
+ /* @__PURE__ */ jsx("stop", { offset: "0.55", stopColor: "#c9a43e" }),
91
+ /* @__PURE__ */ jsx("stop", { offset: "1", stopColor: "#a8852a" })
92
+ ] }),
93
+ /* @__PURE__ */ jsx("clipPath", { id: `${id}c`, children: /* @__PURE__ */ jsx("path", { d: shield }) })
94
+ ] }),
95
+ /* @__PURE__ */ jsx("path", { className: "fg-emblem-shield", d: shield, fill: `url(#${id}g)` }),
96
+ /* @__PURE__ */ jsx("g", { clipPath: `url(#${id}c)`, children: /* @__PURE__ */ jsx("path", { className: "fg-emblem-band", d: "M-2 41.2L50 10.8", stroke: "#24282d", strokeWidth: "9.5", pathLength: 1 }) }),
97
+ /* @__PURE__ */ jsx("path", { className: "fg-emblem-line", d: "M10.5 33.9L37.5 18.1", stroke: "#c9a43e", strokeWidth: "0.9", pathLength: 1 }),
98
+ /* @__PURE__ */ jsx("circle", { className: "fg-emblem-dot", cx: "10.5", cy: "33.9", r: "1.6", fill: "#c9a43e" }),
99
+ /* @__PURE__ */ jsx("circle", { className: "fg-emblem-dot fg-emblem-dot-2", cx: "37.5", cy: "18.1", r: "1.6", fill: "#c9a43e" }),
100
+ /* @__PURE__ */ jsxs("g", { className: "fg-emblem-lens", children: [
101
+ /* @__PURE__ */ jsx("circle", { cx: "24", cy: "26", r: "7.4", fill: "#24282d" }),
102
+ /* @__PURE__ */ jsx("circle", { cx: "24", cy: "26", r: "5.3", stroke: "#c9a43e", strokeWidth: "1.6" }),
103
+ /* @__PURE__ */ jsx("circle", { cx: "24", cy: "26", r: "2.6", fill: "#15181b", stroke: "#c9a43e", strokeWidth: "0.8" }),
104
+ /* @__PURE__ */ jsx("circle", { cx: "23", cy: "25", r: "0.8", fill: "#f1dc93" })
105
+ ] })
106
+ ]
107
+ }
108
+ );
109
+ }
110
+
111
+ // src/react/framing.ts
112
+ var FULL = { x: 0, y: 0, s: 1 };
113
+ var WHOLE = { x: 0, y: 0, w: 1, h: 1 };
114
+ var FACE_H_TARGET = 0.4;
115
+ var MIN_CROP = 0.5;
116
+ var RETARGET = 0.03;
117
+ var CENTER_TOLERANCE = 0.08;
118
+ var clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
119
+ function baseCrop(a) {
120
+ const s = Math.min(a.w, a.h);
121
+ return { s, x: a.x + (a.w - s) / 2, y: a.y + (a.h - s) / 2 };
122
+ }
123
+ function fitCrop(c, a) {
124
+ const s = Math.min(c.s, a.w, a.h);
125
+ return { s, x: clamp(c.x, a.x, a.x + a.w - s), y: clamp(c.y, a.y, a.y + a.h - s) };
126
+ }
127
+ function aimCrop(box, used, a) {
128
+ const cx = used.x + (box.xmin + box.xmax) / 2 * used.s;
129
+ const cy = used.y + (box.ymin + box.ymax) / 2 * used.s;
130
+ const maxS = Math.min(a.w, a.h);
131
+ const s = clamp((box.ymax - box.ymin) * used.s / FACE_H_TARGET, Math.min(MIN_CROP, maxS), maxS);
132
+ return fitCrop({ s, x: cx - s / 2, y: cy - s / 2 }, a);
133
+ }
134
+ var moved = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y) + Math.abs(a.s - b.s) > RETARGET;
135
+ var ease = (c, t, k = 0.12) => ({
136
+ x: c.x + (t.x - c.x) * k,
137
+ y: c.y + (t.y - c.y) * k,
138
+ s: c.s + (t.s - c.s) * k
139
+ });
140
+ function cropTransform(c, mirror = true) {
141
+ const k = 1 / c.s;
142
+ const move = `translate(${-c.x * 100}%, ${-c.y * 100}%)`;
143
+ return mirror ? `translateX(100%) scale(${-k}, ${k}) ${move}` : `scale(${k}) ${move}`;
144
+ }
145
+ function overlayTransform(used, now, mirror = true) {
146
+ const k = used.s / now.s;
147
+ const dx = (used.x - now.x) / now.s;
148
+ const tx = mirror ? 1 - k - dx : dx;
149
+ const ty = (used.y - now.y) / now.s;
150
+ return `translate(${tx * 100}%, ${ty * 100}%) scale(${k})`;
151
+ }
152
+ function boxStyle(b, mirror) {
153
+ const left = mirror ? 1 - b.xmax : b.xmin;
154
+ return {
155
+ left: `${left * 100}%`,
156
+ top: `${b.ymin * 100}%`,
157
+ width: `${(b.xmax - b.xmin) * 100}%`,
158
+ height: `${(b.ymax - b.ymin) * 100}%`
159
+ };
160
+ }
161
+ function areaFromPixels(px, w, h) {
162
+ const lit = (x, y) => {
163
+ const i = (y * w + x) * 4;
164
+ return Math.max(px[i] ?? 0, px[i + 1] ?? 0, px[i + 2] ?? 0) > 24;
165
+ };
166
+ const colLit = (x) => Array.from({ length: h }, (_, y) => lit(x, y)).some(Boolean);
167
+ const rowLit = (y) => Array.from({ length: w }, (_, x) => lit(x, y)).some(Boolean);
168
+ let x0 = 0;
169
+ let x1 = w - 1;
170
+ let y0 = 0;
171
+ let y1 = h - 1;
172
+ while (x0 < x1 && !colLit(x0)) x0++;
173
+ while (x1 > x0 && !colLit(x1)) x1--;
174
+ while (y0 < y1 && !rowLit(y0)) y0++;
175
+ while (y1 > y0 && !rowLit(y1)) y1--;
176
+ if (x0 === 0 && x1 === w - 1 && y0 === 0 && y1 === h - 1) return WHOLE;
177
+ const area = { x: (x0 + 1) / w, y: (y0 + 1) / h, w: Math.max((x1 - x0 - 1) / w, 0), h: Math.max((y1 - y0 - 1) / h, 0) };
178
+ return area.w < 0.4 || area.h < 0.4 ? WHOLE : area;
179
+ }
180
+ function isDark(px) {
181
+ let sum = 0;
182
+ let n = 0;
183
+ for (let i = 0; i < px.length; i += 4 * 97) {
184
+ sum += (px[i] ?? 0) + (px[i + 1] ?? 0) + (px[i + 2] ?? 0);
185
+ n += 3;
186
+ }
187
+ return n > 0 && sum / n < 8;
188
+ }
189
+ function framingThresholds(th) {
190
+ const t = th ?? {};
191
+ return {
192
+ ...t,
193
+ CENTER_TOL_X: Math.min(t.CENTER_TOL_X ?? CENTER_TOLERANCE, CENTER_TOLERANCE),
194
+ CENTER_TOL_Y: Math.min(t.CENTER_TOL_Y ?? CENTER_TOLERANCE, CENTER_TOLERANCE)
195
+ };
196
+ }
197
+
198
+ // src/react/labels.ts
199
+ var DEFAULT_LABELS = {
200
+ start: "Ligar c\xE2mera",
201
+ stop: "Desligar c\xE2mera",
202
+ retry: "Nova captura",
203
+ connecting: "Conectando\u2026",
204
+ searching: "Procurando rosto\u2026",
205
+ hold: "Fique parado\u2026",
206
+ allGood: "Tudo certo, segure a posi\xE7\xE3o\u2026",
207
+ fix: "Corrigir",
208
+ analyzing: "Analisando o rosto\u2026",
209
+ analyzingClean: "Analisando o rosto e removendo o fundo\u2026",
210
+ analyzingEnhance: "Melhorando a imagem e analisando o rosto\u2026",
211
+ enhanced: "Imagem melhorada",
212
+ enhancedAi: "Rosto restaurado com IA: confira se a foto continua fiel \xE0 pessoa.",
213
+ approved: "Foto aprovada",
214
+ rejected: "Foto reprovada",
215
+ cameraOff: "C\xE2mera desligada",
216
+ cameraHint: "A foto \xE9 capturada sozinha quando todos os crit\xE9rios passam.",
217
+ insecure: "A c\xE2mera exige HTTPS (ou localhost).",
218
+ noCamera: "Este navegador n\xE3o permite acesso \xE0 c\xE2mera.",
219
+ denied: "Permiss\xE3o de c\xE2mera negada. Libere o acesso na barra do navegador.",
220
+ notFound: "Nenhuma c\xE2mera encontrada.",
221
+ busy: "A c\xE2mera est\xE1 em uso por outro aplicativo. Feche-o e tente de novo."
222
+ };
223
+ var FAIL_LABELS = {
224
+ no_face: "enquadre o rosto",
225
+ multiple_faces: "s\xF3 uma pessoa na imagem",
226
+ face_too_small: "aproxime-se da c\xE2mera",
227
+ face_off_center: "centralize o rosto",
228
+ eyes_closed: "abra os olhos",
229
+ not_looking_at_camera: "olhe para a c\xE2mera",
230
+ head_too_tilted: "endireite a cabe\xE7a",
231
+ image_blurry: "fique parado (imagem tremida)",
232
+ poor_lighting: "melhore a luz no rosto",
233
+ overexposed: "luz forte demais no rosto",
234
+ glasses_sunglasses: "retire os \xF3culos escuros",
235
+ helmet_detected: "retire o capacete",
236
+ covered_face: "descubra o rosto",
237
+ smile_detected: "express\xE3o neutra"
238
+ };
239
+ function describeFails(codes = []) {
240
+ return codes.map((c) => FAIL_LABELS[c] ?? c).join(", ");
241
+ }
242
+
243
+ // src/react/FaceGate.tsx
244
+ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
245
+ var NO_STABLE = { ms: 0, needed: 0 };
246
+ var CENTER = { xmin: 0.3, ymin: 0.2, xmax: 0.7, ymax: 0.8 };
247
+ var INTRO_MS = 3400;
248
+ var KEEP_FRAMES = 15;
249
+ var LOST_MS = 1500;
250
+ var SEND_MAX = 800;
251
+ var MEASURE_EVERY = 30;
252
+ var CAMERA_HD = { width: { ideal: 1280 }, height: { ideal: 960 }, facingMode: "user" };
253
+ var CAMERA_SD = { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: "user" };
254
+ var DARK_FRAMES = 3;
255
+ function FaceGate({
256
+ getTicket,
257
+ onCapture,
258
+ onLive,
259
+ onError,
260
+ autoStart = false,
261
+ intro = true,
262
+ watermark = true,
263
+ labels,
264
+ className,
265
+ style,
266
+ clean = false,
267
+ model,
268
+ device,
269
+ thresholds,
270
+ enhance = null,
271
+ autoFrame = true,
272
+ mirror = true
273
+ }) {
274
+ const t = { ...DEFAULT_LABELS, ...labels };
275
+ const videoRef = useRef(null);
276
+ const mediaRef = useRef(null);
277
+ const streamRef = useRef(null);
278
+ const canvasRef = useRef(null);
279
+ const framesRef = useRef(/* @__PURE__ */ new Map());
280
+ const viewRef = useRef(null);
281
+ const overlayRef = useRef(null);
282
+ const overlayCropRef = useRef(FULL);
283
+ const fpsRef = useRef(6);
284
+ const sendRef = useRef(() => {
285
+ });
286
+ const lastSeqRef = useRef(0);
287
+ const cropRef = useRef(FULL);
288
+ const targetRef = useRef(FULL);
289
+ const areaRef = useRef(WHOLE);
290
+ const measuredRef = useRef(0);
291
+ const areaCanvasRef = useRef(null);
292
+ const lostSinceRef = useRef(0);
293
+ const darkRef = useRef({ count: 0, checked: 0, fellBack: false });
294
+ const capturedRef = useRef(null);
295
+ const gateRef = useRef({ camera: false, socket: false, intro: false });
296
+ const hadFaceRef = useRef(false);
297
+ const introTimer = useRef(null);
298
+ const [phase, setPhase] = useState("off");
299
+ const [showIntro, setShowIntro] = useState(false);
300
+ const [live, setLive] = useState(null);
301
+ const [stable, setStable] = useState(NO_STABLE);
302
+ const [detectKey, setDetectKey] = useState(0);
303
+ const [frozen, setFrozen] = useState(null);
304
+ const [final, setFinal] = useState(null);
305
+ const [error, setError] = useState(null);
306
+ const [ratio, setRatio] = useState(4 / 3);
307
+ const th = autoFrame ? framingThresholds(thresholds) : thresholds;
308
+ const cfg = useRef({ getTicket, onCapture, onLive, onError, options: { clean, model, device, thresholds: th, enhance } });
309
+ useEffect(() => {
310
+ cfg.current = { getTicket, onCapture, onLive, onError, options: { clean, model, device, thresholds: th, enhance } };
311
+ });
312
+ useEffect(() => {
313
+ streamRef.current?.configure({ clean, model, device, thresholds: th, enhance });
314
+ }, [clean, model, device, thresholds, autoFrame, enhance]);
315
+ useEffect(() => {
316
+ if (phase !== "live" || !autoFrame) return;
317
+ let id = 0;
318
+ const tick = () => {
319
+ cropRef.current = ease(cropRef.current, targetRef.current);
320
+ if (viewRef.current) viewRef.current.style.transform = cropTransform(cropRef.current, mirror);
321
+ if (overlayRef.current) overlayRef.current.style.transform = overlayTransform(overlayCropRef.current, cropRef.current, mirror);
322
+ id = requestAnimationFrame(tick);
323
+ };
324
+ id = requestAnimationFrame(tick);
325
+ return () => cancelAnimationFrame(id);
326
+ }, [phase, autoFrame, mirror]);
327
+ function release() {
328
+ if (introTimer.current) clearTimeout(introTimer.current);
329
+ streamRef.current?.close();
330
+ streamRef.current = null;
331
+ mediaRef.current?.getTracks().forEach((tr) => tr.stop());
332
+ mediaRef.current = null;
333
+ }
334
+ useEffect(() => release, []);
335
+ function fail(err) {
336
+ release();
337
+ setShowIntro(false);
338
+ setPhase("off");
339
+ setError(err.message);
340
+ cfg.current.onError?.(err);
341
+ }
342
+ function measureArea(v) {
343
+ if (measuredRef.current++ % MEASURE_EVERY !== 0) return;
344
+ const W = 64;
345
+ const H = Math.max(8, Math.round(W * v.videoHeight / v.videoWidth));
346
+ const c = areaCanvasRef.current ?? (areaCanvasRef.current = document.createElement("canvas"));
347
+ c.width = W;
348
+ c.height = H;
349
+ const ctx = c.getContext("2d", { willReadFrequently: true });
350
+ if (!ctx) return;
351
+ ctx.drawImage(v, 0, 0, W, H);
352
+ const next = areaFromPixels(ctx.getImageData(0, 0, W, H).data, W, H);
353
+ const prev = areaRef.current;
354
+ if (Math.abs(next.x - prev.x) + Math.abs(next.y - prev.y) + Math.abs(next.w - prev.w) + Math.abs(next.h - prev.h) < 0.02) return;
355
+ areaRef.current = next;
356
+ const into = (cr) => fitCrop(cr.s >= 0.999 ? baseCrop(next) : cr, next);
357
+ cropRef.current = into(cropRef.current);
358
+ targetRef.current = into(targetRef.current);
359
+ }
360
+ function checkDark(ctx, w, h) {
361
+ const d = darkRef.current;
362
+ if (d.fellBack || d.checked >= DARK_FRAMES * 2) return false;
363
+ d.checked += 1;
364
+ d.count = isDark(ctx.getImageData(0, 0, w, h).data) ? d.count + 1 : 0;
365
+ if (d.count < DARK_FRAMES) return false;
366
+ d.fellBack = true;
367
+ void reopenCamera();
368
+ return true;
369
+ }
370
+ async function reopenCamera() {
371
+ try {
372
+ const media = await navigator.mediaDevices.getUserMedia({ video: CAMERA_SD });
373
+ mediaRef.current?.getTracks().forEach((tr) => tr.stop());
374
+ mediaRef.current = media;
375
+ if (videoRef.current) {
376
+ videoRef.current.srcObject = media;
377
+ await videoRef.current.play().catch(() => {
378
+ });
379
+ }
380
+ } catch {
381
+ }
382
+ requestAnimationFrame(sendFrame);
383
+ }
384
+ function aim(seq, box2) {
385
+ if (!autoFrame) return;
386
+ const now = performance.now();
387
+ if (!box2) {
388
+ lostSinceRef.current || (lostSinceRef.current = now);
389
+ if (now - lostSinceRef.current > LOST_MS) targetRef.current = baseCrop(areaRef.current);
390
+ return;
391
+ }
392
+ lostSinceRef.current = 0;
393
+ const used = framesRef.current.get(seq)?.crop ?? cropRef.current;
394
+ const next = aimCrop(box2, used, areaRef.current);
395
+ if (moved(next, targetRef.current)) targetRef.current = next;
396
+ }
397
+ function sendFrame() {
398
+ const v = videoRef.current;
399
+ const s = streamRef.current;
400
+ if (!v || !v.videoWidth || !s) return;
401
+ if (autoFrame) measureArea(v);
402
+ const crop = autoFrame ? cropRef.current : FULL;
403
+ const sw = crop.s * v.videoWidth;
404
+ const sh = crop.s * v.videoHeight;
405
+ const k = Math.min(1, SEND_MAX / Math.max(sw, sh));
406
+ const c = canvasRef.current ?? (canvasRef.current = document.createElement("canvas"));
407
+ c.width = Math.round(sw * k);
408
+ c.height = Math.round(sh * k);
409
+ const ctx = c.getContext("2d", { willReadFrequently: true });
410
+ ctx?.drawImage(v, crop.x * v.videoWidth, crop.y * v.videoHeight, sw, sh, 0, 0, c.width, c.height);
411
+ if (autoFrame && ctx && checkDark(ctx, c.width, c.height)) return;
412
+ c.toBlob(
413
+ (blob) => {
414
+ const cur = streamRef.current;
415
+ if (!blob || !cur) return;
416
+ const seq = cur.sendFrame(blob);
417
+ if (seq == null) return;
418
+ const frames = framesRef.current;
419
+ frames.set(seq, { blob, crop });
420
+ for (const k2 of frames.keys()) if (k2 <= seq - KEEP_FRAMES) frames.delete(k2);
421
+ },
422
+ "image/jpeg",
423
+ 0.85
424
+ );
425
+ }
426
+ function goLiveIfReady() {
427
+ const g = gateRef.current;
428
+ if (!g.camera || !g.socket || !g.intro || !streamRef.current) return;
429
+ setShowIntro(false);
430
+ setPhase("live");
431
+ sendFrame();
432
+ }
433
+ async function start() {
434
+ setError(null);
435
+ if (typeof window === "undefined") return;
436
+ if (!window.isSecureContext) return fail(new FaceGateError("camera_unavailable", t.insecure));
437
+ if (!navigator.mediaDevices?.getUserMedia) return fail(new FaceGateError("camera_unavailable", t.noCamera));
438
+ release();
439
+ const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
440
+ const playIntro = intro && !reduceMotion;
441
+ gateRef.current = { camera: false, socket: false, intro: !playIntro };
442
+ framesRef.current.clear();
443
+ hadFaceRef.current = false;
444
+ lastSeqRef.current = 0;
445
+ cropRef.current = targetRef.current = overlayCropRef.current = FULL;
446
+ if (overlayRef.current) overlayRef.current.style.transform = "";
447
+ areaRef.current = WHOLE;
448
+ measuredRef.current = 0;
449
+ lostSinceRef.current = 0;
450
+ darkRef.current = { count: 0, checked: 0, fellBack: false };
451
+ if (viewRef.current) viewRef.current.style.transform = cropTransform(FULL, mirror);
452
+ setLive(null);
453
+ setStable(NO_STABLE);
454
+ setFrozen(null);
455
+ setFinal(null);
456
+ setShowIntro(playIntro);
457
+ setPhase("starting");
458
+ if (playIntro) {
459
+ introTimer.current = setTimeout(() => {
460
+ gateRef.current.intro = true;
461
+ goLiveIfReady();
462
+ }, INTRO_MS);
463
+ }
464
+ const [cam, tk] = await Promise.allSettled([
465
+ navigator.mediaDevices.getUserMedia({ video: autoFrame ? CAMERA_HD : CAMERA_SD }),
466
+ cfg.current.getTicket()
467
+ ]);
468
+ if (cam.status === "rejected") {
469
+ const name = cam.reason?.name;
470
+ const msg = name === "NotAllowedError" ? t.denied : name === "NotFoundError" ? t.notFound : name === "NotReadableError" ? t.busy : String(cam.reason?.message ?? cam.reason);
471
+ return fail(new FaceGateError(name === "NotAllowedError" ? "camera_denied" : "camera_unavailable", msg));
472
+ }
473
+ if (tk.status === "rejected") {
474
+ cam.value.getTracks().forEach((tr) => tr.stop());
475
+ const e = tk.reason;
476
+ return fail(e instanceof FaceGateError ? e : new FaceGateError("network", e?.message ?? "Falha ao obter o ticket"));
477
+ }
478
+ mediaRef.current = cam.value;
479
+ if (videoRef.current) {
480
+ videoRef.current.srcObject = cam.value;
481
+ void videoRef.current.play().catch(() => {
482
+ });
483
+ }
484
+ streamRef.current = openFaceGateStream(tk.value.url, tk.value.ticket, cfg.current.options, {
485
+ onReady: (maxFps) => {
486
+ fpsRef.current = Math.min(Math.max(maxFps, 1), 10);
487
+ gateRef.current.socket = true;
488
+ goLiveIfReady();
489
+ },
490
+ onFrame: (seq, r, st) => {
491
+ if (seq <= lastSeqRef.current) return;
492
+ lastSeqRef.current = seq;
493
+ const f = framesRef.current.get(seq);
494
+ if (f) f.result = r;
495
+ if (autoFrame) overlayCropRef.current = f?.crop ?? cropRef.current;
496
+ aim(seq, r.dominant_face_bbox);
497
+ const hasFace = Boolean(r.dominant_face_bbox);
498
+ if (hasFace && !hadFaceRef.current) setDetectKey((k) => k + 1);
499
+ hadFaceRef.current = hasFace;
500
+ setLive(r);
501
+ setStable(st);
502
+ cfg.current.onLive?.(r);
503
+ },
504
+ onCaptured: (seq) => {
505
+ const frames = framesRef.current;
506
+ const pick = frames.get(seq) ?? frames.get(Math.max(...frames.keys()));
507
+ mediaRef.current?.getTracks().forEach((tr) => tr.stop());
508
+ mediaRef.current = null;
509
+ if (pick) {
510
+ const url = URL.createObjectURL(pick.blob);
511
+ capturedRef.current = { blob: pick.blob, url };
512
+ setFrozen({ url, box: pick.result?.dominant_face_bbox ?? CENTER });
513
+ }
514
+ setPhase("processing");
515
+ },
516
+ onFinal: (_seq, r) => {
517
+ streamRef.current?.close();
518
+ streamRef.current = null;
519
+ const cap = capturedRef.current;
520
+ const image = r.cleaned_image ?? r.enhancement?.enhanced_image ?? cap?.url ?? "";
521
+ setFinal({ url: image, result: r });
522
+ setPhase("done");
523
+ if (cap) cfg.current.onCapture?.({ image, frame: cap.blob, result: r });
524
+ },
525
+ onError: fail
526
+ });
527
+ }
528
+ function stop() {
529
+ release();
530
+ setShowIntro(false);
531
+ setPhase("off");
532
+ }
533
+ useEffect(() => {
534
+ if (autoStart) void start();
535
+ }, []);
536
+ useEffect(() => {
537
+ sendRef.current = sendFrame;
538
+ });
539
+ useEffect(() => {
540
+ if (phase !== "live") return;
541
+ const id = setInterval(() => sendRef.current(), 1e3 / fpsRef.current);
542
+ return () => clearInterval(id);
543
+ }, [phase]);
544
+ const cameraOn = phase === "starting" || phase === "live";
545
+ const box = phase === "live" ? live?.dominant_face_bbox ?? null : null;
546
+ const ok = Boolean(live?.accepted);
547
+ const holding = phase === "live" && ok && stable.ms > 0 && stable.needed > 0;
548
+ const progress = holding ? Math.min(stable.ms / stable.needed, 1) : 0;
549
+ const status = phase === "starting" ? t.connecting : !live ? t.searching : live.accepted ? holding ? t.hold : t.allGood : !live.dominant_face_bbox ? t.searching : `${t.fix}: ${describeFails(live.hard_fails)}`;
550
+ return /* @__PURE__ */ jsxs2("div", { className: ["fg-root", className].filter(Boolean).join(" "), style, children: [
551
+ /* @__PURE__ */ jsxs2("div", { className: "fg-stage", style: { aspectRatio: String(ratio), width: `min(100%, calc(70dvh * ${ratio}))`, marginInline: "auto" }, children: [
552
+ /* @__PURE__ */ jsx2(
553
+ "div",
554
+ {
555
+ ref: viewRef,
556
+ hidden: !cameraOn,
557
+ style: { position: "absolute", inset: 0, transformOrigin: "0 0", transform: cropTransform(FULL, mirror) },
558
+ children: /* @__PURE__ */ jsx2(
559
+ "video",
560
+ {
561
+ ref: videoRef,
562
+ autoPlay: true,
563
+ playsInline: true,
564
+ muted: true,
565
+ className: "fg-video",
566
+ onLoadedData: (e) => {
567
+ const v = e.currentTarget;
568
+ if (v.videoWidth && v.videoHeight) setRatio(v.videoWidth / v.videoHeight);
569
+ gateRef.current.camera = true;
570
+ goLiveIfReady();
571
+ }
572
+ }
573
+ )
574
+ }
575
+ ),
576
+ phase === "live" && !box && /* @__PURE__ */ jsx2(
577
+ "div",
578
+ {
579
+ className: "fg-search",
580
+ "aria-hidden": "true",
581
+ style: { top: "15%", left: "50%", width: "auto", height: "68%", aspectRatio: "3 / 4", maxWidth: "80%", translate: "-50% 0" },
582
+ children: /* @__PURE__ */ jsx2("span", { className: "fg-search-sweep" })
583
+ }
584
+ ),
585
+ /* @__PURE__ */ jsx2(
586
+ "div",
587
+ {
588
+ ref: overlayRef,
589
+ "aria-hidden": "true",
590
+ style: { position: "absolute", inset: 0, transformOrigin: "0 0", pointerEvents: "none" },
591
+ children: box && /* @__PURE__ */ jsxs2("div", { className: "fg-track", "data-state": ok ? "ok" : "warn", style: boxStyle(box, mirror), children: [
592
+ /* @__PURE__ */ jsx2("span", { className: "fg-track-line" }),
593
+ holding && /* @__PURE__ */ jsx2("span", { className: "fg-track-hold", style: { transform: `scaleX(${progress})` } })
594
+ ] }, detectKey)
595
+ }
596
+ ),
597
+ phase === "processing" && frozen && /* @__PURE__ */ jsxs2(Fragment, { children: [
598
+ /* @__PURE__ */ jsx2("img", { src: frozen.url, alt: "", className: "fg-video", style: mirror ? { transform: "scaleX(-1)" } : void 0 }),
599
+ /* @__PURE__ */ jsx2("div", { className: "fg-scan", style: boxStyle(frozen.box, mirror), "aria-hidden": "true", children: /* @__PURE__ */ jsx2("span", { className: "fg-scan-line" }) })
600
+ ] }),
601
+ phase === "done" && final && /* @__PURE__ */ jsxs2("div", { className: "fg-result", children: [
602
+ /* @__PURE__ */ jsx2("img", { src: final.url, alt: final.result.accepted ? t.approved : t.rejected, className: "fg-result-img" }),
603
+ final.result.enhancement && /* @__PURE__ */ jsxs2(
604
+ "span",
605
+ {
606
+ className: "fg-badge",
607
+ "data-ai": final.result.warnings?.includes("face_restored_ai") ? "true" : void 0,
608
+ title: final.result.warnings?.includes("face_restored_ai") ? t.enhancedAi : void 0,
609
+ children: [
610
+ t.enhanced,
611
+ final.result.warnings?.includes("face_restored_ai") && /* @__PURE__ */ jsx2("small", { children: t.enhancedAi })
612
+ ]
613
+ }
614
+ )
615
+ ] }),
616
+ watermark && phase !== "off" && /* @__PURE__ */ jsx2(Emblem, { className: "fg-watermark" }),
617
+ showIntro && /* @__PURE__ */ jsx2("div", { className: "fg-intro", "aria-hidden": "true", children: /* @__PURE__ */ jsx2(Emblem, { animated: true, className: "fg-intro-emblem" }) }),
618
+ phase === "off" && /* @__PURE__ */ jsxs2("div", { className: "fg-idle", children: [
619
+ /* @__PURE__ */ jsx2(Emblem, { className: "fg-idle-emblem" }),
620
+ /* @__PURE__ */ jsx2("strong", { children: t.cameraOff }),
621
+ /* @__PURE__ */ jsx2("span", { children: error ?? t.cameraHint })
622
+ ] }),
623
+ (cameraOn || phase === "processing") && /* @__PURE__ */ jsxs2("div", { className: "fg-status", role: "status", children: [
624
+ /* @__PURE__ */ jsx2("span", { className: `fg-dot ${phase === "processing" ? "fg-dot-busy" : ok ? "fg-dot-ok" : live ? "fg-dot-warn" : ""}` }),
625
+ /* @__PURE__ */ jsx2("span", { children: phase === "processing" ? clean ? t.analyzingClean : enhance ? t.analyzingEnhance : t.analyzing : status })
626
+ ] }),
627
+ phase === "done" && final && /* @__PURE__ */ jsxs2("div", { className: "fg-status", role: "status", children: [
628
+ /* @__PURE__ */ jsx2("span", { className: `fg-dot ${final.result.accepted ? "fg-dot-ok" : "fg-dot-warn"}` }),
629
+ /* @__PURE__ */ jsx2("span", { children: final.result.accepted ? t.approved : `${t.rejected}: ${describeFails(final.result.hard_fails ?? final.result.reasons)}` })
630
+ ] })
631
+ ] }),
632
+ /* @__PURE__ */ jsx2("div", { className: "fg-actions", children: phase === "done" || phase === "off" && final ? /* @__PURE__ */ jsx2("button", { type: "button", className: "fg-button", onClick: start, children: t.retry }) : /* @__PURE__ */ jsx2(
633
+ "button",
634
+ {
635
+ type: "button",
636
+ className: `fg-button ${cameraOn ? "fg-button-outline" : ""}`,
637
+ disabled: phase === "processing",
638
+ onClick: cameraOn ? stop : start,
639
+ children: cameraOn ? t.stop : t.start
640
+ }
641
+ ) })
642
+ ] });
643
+ }
644
+ export {
645
+ DEFAULT_LABELS,
646
+ Emblem,
647
+ FAIL_LABELS,
648
+ FaceGate,
649
+ FaceGateError,
650
+ describeFails,
651
+ openFaceGateStream
652
+ };
653
+ //# sourceMappingURL=index.js.map