@beltec/face-gate 0.1.0 → 0.1.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/README.md CHANGED
@@ -1,218 +1,218 @@
1
- # @beltec/face-gate
2
-
3
- **Face Gate, uma solução BelTec** — validação de fotos de rosto para o cadastro de visitantes e moradores no
4
- controle de acesso. Este pacote traz:
5
-
6
- - **`<FaceGate>`** (React): webcam com validação em **tempo real** — mira que segue o rosto, orientação do que
7
- corrigir, captura automática só quando a pessoa fica parada com tudo aprovado, scanner sobre a foto e a foto
8
- final (com o fundo removido, se contratado).
9
- - **`@beltec/face-gate/server`** (Node/Next): guarda a sua chave, emite o ticket do navegador e valida arquivos.
10
-
11
- O `client_secret` **nunca** vai ao navegador: o seu servidor pede um ticket de uso único (60 s) e só ele chega à
12
- página.
13
-
14
- ```
15
- navegador ──POST /api/face-gate/ticket──▶ seu servidor ──chave──▶ Face Gate API
16
- navegador ◀──────── ticket (60 s) ────────┘
17
- navegador ══ WebSocket (ticket) ══▶ Face Gate API (quadros ao vivo → captura → resultado)
18
- ```
19
-
20
- ## Instalação
21
-
22
- ```bash
23
- npm install @beltec/face-gate
24
- ```
25
-
26
- React 18+ no navegador; Node 18.17+ no servidor. Crie a chave no portal do Face Gate
27
- (**Chaves de acesso**): você recebe o `client_id` e o `client_secret` (este aparece uma vez só).
28
-
29
- ```bash
30
- # .env do seu servidor
31
- FACE_GATE_CLIENT_ID=...
32
- FACE_GATE_CLIENT_SECRET=...
33
- ```
34
-
35
- ## Next.js (App Router)
36
-
37
- **1. Rota que emite o ticket** — `app/api/face-gate/ticket/route.ts`
38
-
39
- ```ts
40
- import { createFaceGate } from "@beltec/face-gate/server";
41
-
42
- const faceGate = createFaceGate({
43
- clientId: process.env.FACE_GATE_CLIENT_ID!,
44
- clientSecret: process.env.FACE_GATE_CLIENT_SECRET!,
45
- });
46
-
47
- // Só usuários logados no SEU sistema abrem a câmera (cada captura consome tokens do seu plano).
48
- export const POST = faceGate.ticketHandler({
49
- authorize: async (req) => Boolean(await getSessionFromYourApp(req)),
50
- });
51
- ```
52
-
53
- **2. A tela** — um Client Component
54
-
55
- ```tsx
56
- "use client";
57
- import { FaceGate } from "@beltec/face-gate";
58
- import "@beltec/face-gate/styles.css";
59
-
60
- export function CadastroFoto() {
61
- return (
62
- <FaceGate
63
- clean // devolve a foto sem fundo (serviço "validar e remover fundo" contratado)
64
- getTicket={async () => {
65
- const r = await fetch("/api/face-gate/ticket", { method: "POST" });
66
- if (!r.ok) throw new Error("Não foi possível abrir a câmera");
67
- return r.json();
68
- }}
69
- onCapture={({ image, result }) => {
70
- if (result.accepted) salvarFoto(image); // data URL (PNG sem fundo ou JPEG do quadro)
71
- }}
72
- onError={(e) => console.warn(e.code, e.message)}
73
- />
74
- );
75
- }
76
- ```
77
-
78
- ## Validar um arquivo (sem webcam)
79
-
80
- ```ts
81
- import { createFaceGate } from "@beltec/face-gate/server";
82
-
83
- const faceGate = createFaceGate({ clientId, clientSecret });
84
- const result = await faceGate.validate(fileBuffer, { clean: true, model: "birefnet-portrait" });
85
-
86
- result.accepted; // true | false
87
- result.hard_fails; // ex.: ["eyes_closed", "glasses_sunglasses"]
88
- result.cleaned_image; // PNG sem fundo (data URL), quando clean
89
- result.usage?.tokens; // tokens gastos nesta análise
90
- ```
91
-
92
- Com melhoria de imagem: `faceGate.validate(fileBuffer, { enhance: "classic" })` (veja abaixo).
93
-
94
- Express/Fastify funcionam igual: use `createStreamTicket()` na sua rota e devolva `{ ticket, url }` ao navegador.
95
-
96
- ## `<FaceGate>` — propriedades
97
-
98
- | Prop | Tipo | Padrão | |
99
- |---|---|---|---|
100
- | `getTicket` | `() => Promise<{ ticket, url }>` | — | chama a sua rota de ticket |
101
- | `onCapture` | `({ image, frame, result }) => void` | — | foto capturada e validada |
102
- | `onLive` | `(result) => void` | — | cada quadro analisado ao vivo |
103
- | `onError` | `(error: FaceGateError) => void` | — | câmera negada, limite de tokens, rede… |
104
- | `clean` | `boolean` | `false` | remover o fundo da foto capturada |
105
- | `model` | `"u2net" \| "u2netp" \| "birefnet-portrait"` | servidor | modelo da remoção de fundo |
106
- | `device` | `"cpu" \| "gpu"` | servidor | onde rodar a remoção de fundo |
107
- | `enhance` | `"classic" \| "gfpgan" \| null` | `null` | melhorar a foto capturada antes de validar (serviço "validar com melhoria") |
108
- | `thresholds` | `Record<string, number>` | servidor | limiares (nomes de `GET /v1/thresholds`) |
109
- | `autoFrame` | `boolean` | `true` | enquadramento automático: a imagem aproxima e acompanha o rosto, mantendo-o no centro; a foto capturada é esse recorte e só sai com o rosto centralizado (tolerância de 8%, ou a sua em `thresholds` se for mais rígida). Ignora as tarjas pretas que algumas câmeras acrescentam |
110
- | `mirror` | `boolean` | `true` | prévia espelhada, como um espelho (a foto enviada e a final não são espelhadas) |
111
- | `autoStart` | `boolean` | `false` | ligar a câmera ao montar |
112
- | `intro` | `boolean` | `true` | abertura animada do escudo |
113
- | `watermark` | `boolean` | `true` | escudo no canto do vídeo |
114
- | `labels` | `Partial<FaceGateLabels>` | pt-BR | troque qualquer texto |
115
-
116
- ### Aparência
117
-
118
- Os estilos usam o prefixo `.fg-` e variáveis CSS:
119
-
120
- ```css
121
- .minha-tela .fg-root {
122
- --fg-accent: #c9a43e; /* mira, botão, barra de "fique parado" */
123
- --fg-accent-text: #1d2125; /* texto sobre o botão */
124
- --fg-surface: #1d2125; /* fundo do vídeo */
125
- --fg-radius: 12px;
126
- }
127
- ```
128
-
129
- Respeita `prefers-reduced-motion` (sem animações).
130
-
131
- ## Melhoria de imagem
132
-
133
- Serviço contratável **"Validar com melhoria"** (scope `validate_enhance`). A API melhora a foto, valida a
134
- versão melhorada e devolve `enhancement` com o resultado **antes** (original) e **depois** (melhorada) e a
135
- imagem melhorada (`enhancement.enhanced_image`, JPEG). No `<FaceGate enhance="classic">`, só a foto
136
- capturada passa pela melhoria (os quadros ao vivo não), o `onCapture` entrega a melhorada em `image` e a
137
- tela mostra o selo "Imagem melhorada".
138
-
139
- | Método | O que faz | Custo |
140
- |---|---|---|
141
- | `classic` | balanço de branco, exposição, contraste local, redução de ruído e nitidez leve. Determinística, não altera o rosto | baixo |
142
- | `gfpgan` | a clássica + restauração do rosto com IA (GFPGAN). Recria detalhes do rosto: a resposta traz o aviso `face_restored_ai` e o componente mostra o alerta | alto |
143
-
144
- **Regra:** a melhoria só corrige luz (`poor_lighting`, `overexposed`) e rosto não encontrado por falta de
145
- luz (`no_face`). As demais falhas da foto original — desfoque, olhos fechados, óculos escuros, pose… —
146
- continuam reprovando (`enhancement.carried_over`). Com `clean`, o fundo é removido da foto melhorada (exige
147
- também o serviço "validar e remover fundo").
148
-
149
- **Tokens:** a validação roda duas vezes (antes e depois) e soma a etapa `usage.breakdown.enhance`
150
- (a clássica é barata; o GFPGAN custa bem mais).
151
-
152
- ## Avatar
153
-
154
- Serviço **Avatar** contratado (`POST /v1/photos/avatar`): valida a foto e, se aprovada, devolve o avatar só com a
155
- pessoa do rosto em foco — o fundo e outros elementos ou pessoas são removidos —, recortado centrado no rosto.
156
-
157
- ```ts
158
- import { createFaceGate } from "@beltec/face-gate/server";
159
-
160
- const faceGate = createFaceGate({ clientId, clientSecret });
161
- const r = await faceGate.avatar(fileBuffer, {
162
- shape: "round", // "round" (padrão; fora do círculo transparente) | "square"
163
- size: 512, // 64 a 1024
164
- backgroundColor: "#1d252a", // ou background: <imagem> (tem prioridade); sem nenhum = PNG transparente
165
- margin: 25, // só com fundo: % do quadro deixado para o fundo (0 a 60)
166
- layout: "bottom", // só com fundo: "center" | "bottom" | "left" | "right"
167
- });
168
-
169
- if (r.accepted) r.avatar_image; // data:image/png;base64,...
170
- else r.hard_fails; // sem avatar: os mesmos motivos da validação
171
- ```
172
-
173
- | Opção | Tipo | Padrão | |
174
- |---|---|---|---|
175
- | `shape` | `"round" \| "square"` | `"round"` | formato do recorte |
176
- | `size` | `number` | `512` | lado em pixels (64 a 1024) |
177
- | `backgroundColor` | `string` | — | cor sólida `#RRGGBB` |
178
- | `background` | `Blob \| ArrayBuffer \| Uint8Array` | — | imagem de fundo (preenche o quadro) |
179
- | `template` | `number` | — | id de um **template de avatar** salvo no portal (Avatares): formato, tamanho, fundo e enquadramento prontos, sem reenviar a imagem; as outras opções têm prioridade |
180
- | `backgroundPosition` | `{ x?, y?, zoom? }` | centro, `zoom` 100 | **só com `background`**: qual parte da imagem aparece — `x`/`y` de 0 a 100 (50 = centro, como o `object-position` do CSS) e `zoom` de 100 a 300 % |
181
- | `margin` | `number` | `0` | **só com fundo**: % do quadro deixado para o fundo em volta da pessoa (0 a 60) |
182
- | `layout` | `"center" \| "bottom" \| "left" \| "right"` | `"center"` | **só com fundo**: onde a pessoa fica (centro, apoiada na base, base à esquerda/direita) |
183
- | `model` / `device` | | servidor (BiRefNet) | modelo de remoção de fundo que separa a pessoa |
184
- | `thresholds` | `Record<string, number>` | servidor | limiares da validação |
185
-
186
- O resultado traz também `avatar` (`shape`, `size`, `model`, `background`, `margin`, `layout`). **Tokens:** soma a etapa `avatar`
187
- em `usage.breakdown` (a separação da pessoa roda na imagem analisada inteira).
188
-
189
- ## Tokens e erros
190
-
191
- Cada foto capturada (e cada `validate`) consome **tokens** do seu plano — megapixels processados × peso de cada
192
- etapa; foto maior ou remoção de fundo com BiRefNet gastam mais. Os quadros ao vivo da webcam não consomem.
193
- O plano limita os tokens por **janela** (ex.: 5 h a partir do 1º uso) e por **mês** (renova no aniversário do
194
- contrato).
195
-
196
- `FaceGateError.code`:
197
-
198
- | code | quando |
199
- |---|---|
200
- | `token_window_limit` | janela de tokens esgotada (`resetsAt` = quando renova) |
201
- | `token_month_limit` | teto mensal atingido (`resetsAt` = data da renovação) |
202
- | `forbidden` | serviço não contratado, chave bloqueada/expirada, fora do contrato ou IP não liberado |
203
- | `unauthorized` | chave/ticket inválido ou expirado |
204
- | `camera_denied` / `camera_unavailable` | permissão negada, sem câmera ou página sem HTTPS |
205
- | `network` / `server` / `bad_request` | falhas de conexão ou da API |
206
-
207
- ## Segurança e LGPD
208
-
209
- - A whitelist de IPs da chave vale para o **seu servidor** (onde o ticket é emitido); o navegador usa só o
210
- ticket de uso único.
211
- - A API não guarda as imagens: só o resultado e metadados da validação.
212
- - A câmera exige HTTPS (ou `localhost`).
213
- - As chamadas do servidor saem com o User-Agent `face-gate-sdk/<versão> (node <versão>; <sistema>)`
214
- (`SDK_USER_AGENT`). Assim, uma tentativa de um IP não liberado aparece no portal como "SDK Face Gate <versão>".
215
-
216
- ---
217
-
218
- © BelTec, do grupo Belfort. Uso sujeito ao contrato do Face Gate.
1
+ # @beltec/face-gate
2
+
3
+ **Face Gate, uma solução BelTec** — validação de fotos de rosto para o cadastro de visitantes e moradores no
4
+ controle de acesso. Este pacote traz:
5
+
6
+ - **`<FaceGate>`** (React): webcam com validação em **tempo real** — mira que segue o rosto, orientação do que
7
+ corrigir, captura automática só quando a pessoa fica parada com tudo aprovado, scanner sobre a foto e a foto
8
+ final (com o fundo removido, se contratado).
9
+ - **`@beltec/face-gate/server`** (Node/Next): guarda a sua chave, emite o ticket do navegador e valida arquivos.
10
+
11
+ O `client_secret` **nunca** vai ao navegador: o seu servidor pede um ticket de uso único (60 s) e só ele chega à
12
+ página.
13
+
14
+ ```
15
+ navegador ──POST /api/face-gate/ticket──▶ seu servidor ──chave──▶ Face Gate API
16
+ navegador ◀──────── ticket (60 s) ────────┘
17
+ navegador ══ WebSocket (ticket) ══▶ Face Gate API (quadros ao vivo → captura → resultado)
18
+ ```
19
+
20
+ ## Instalação
21
+
22
+ ```bash
23
+ npm install @beltec/face-gate
24
+ ```
25
+
26
+ React 18+ no navegador; Node 18.17+ no servidor. Crie a chave no portal do Face Gate
27
+ (**Chaves de acesso**): você recebe o `client_id` e o `client_secret` (este aparece uma vez só).
28
+
29
+ ```bash
30
+ # .env do seu servidor
31
+ FACE_GATE_CLIENT_ID=...
32
+ FACE_GATE_CLIENT_SECRET=...
33
+ ```
34
+
35
+ ## Next.js (App Router)
36
+
37
+ **1. Rota que emite o ticket** — `app/api/face-gate/ticket/route.ts`
38
+
39
+ ```ts
40
+ import { createFaceGate } from "@beltec/face-gate/server";
41
+
42
+ const faceGate = createFaceGate({
43
+ clientId: process.env.FACE_GATE_CLIENT_ID!,
44
+ clientSecret: process.env.FACE_GATE_CLIENT_SECRET!,
45
+ });
46
+
47
+ // Só usuários logados no SEU sistema abrem a câmera (cada captura consome tokens do seu plano).
48
+ export const POST = faceGate.ticketHandler({
49
+ authorize: async (req) => Boolean(await getSessionFromYourApp(req)),
50
+ });
51
+ ```
52
+
53
+ **2. A tela** — um Client Component
54
+
55
+ ```tsx
56
+ "use client";
57
+ import { FaceGate } from "@beltec/face-gate";
58
+ import "@beltec/face-gate/styles.css";
59
+
60
+ export function CadastroFoto() {
61
+ return (
62
+ <FaceGate
63
+ clean // devolve a foto sem fundo (serviço "validar e remover fundo" contratado)
64
+ getTicket={async () => {
65
+ const r = await fetch("/api/face-gate/ticket", { method: "POST" });
66
+ if (!r.ok) throw new Error("Não foi possível abrir a câmera");
67
+ return r.json();
68
+ }}
69
+ onCapture={({ image, result }) => {
70
+ if (result.accepted) salvarFoto(image); // data URL (PNG sem fundo ou JPEG do quadro)
71
+ }}
72
+ onError={(e) => console.warn(e.code, e.message)}
73
+ />
74
+ );
75
+ }
76
+ ```
77
+
78
+ ## Validar um arquivo (sem webcam)
79
+
80
+ ```ts
81
+ import { createFaceGate } from "@beltec/face-gate/server";
82
+
83
+ const faceGate = createFaceGate({ clientId, clientSecret });
84
+ const result = await faceGate.validate(fileBuffer, { clean: true, model: "birefnet-portrait" });
85
+
86
+ result.accepted; // true | false
87
+ result.hard_fails; // ex.: ["eyes_closed", "glasses_sunglasses"]
88
+ result.cleaned_image; // PNG sem fundo (data URL), quando clean
89
+ result.usage?.tokens; // tokens gastos nesta análise
90
+ ```
91
+
92
+ Com melhoria de imagem: `faceGate.validate(fileBuffer, { enhance: "classic" })` (veja abaixo).
93
+
94
+ Express/Fastify funcionam igual: use `createStreamTicket()` na sua rota e devolva `{ ticket, url }` ao navegador.
95
+
96
+ ## `<FaceGate>` — propriedades
97
+
98
+ | Prop | Tipo | Padrão | |
99
+ |---|---|---|---|
100
+ | `getTicket` | `() => Promise<{ ticket, url }>` | — | chama a sua rota de ticket |
101
+ | `onCapture` | `({ image, frame, result }) => void` | — | foto capturada e validada |
102
+ | `onLive` | `(result) => void` | — | cada quadro analisado ao vivo |
103
+ | `onError` | `(error: FaceGateError) => void` | — | câmera negada, limite de tokens, rede… |
104
+ | `clean` | `boolean` | `false` | remover o fundo da foto capturada |
105
+ | `model` | `"u2net" \| "u2netp" \| "birefnet-portrait"` | servidor | modelo da remoção de fundo |
106
+ | `device` | `"cpu" \| "gpu"` | servidor | onde rodar a remoção de fundo |
107
+ | `enhance` | `"classic" \| "gfpgan" \| null` | `null` | melhorar a foto capturada antes de validar (serviço "validar com melhoria") |
108
+ | `thresholds` | `Record<string, number>` | servidor | limiares (nomes de `GET /v1/thresholds`) |
109
+ | `autoFrame` | `boolean` | `true` | enquadramento automático: a imagem aproxima e acompanha o rosto, mantendo-o no centro; a foto capturada é esse recorte e só sai com o rosto centralizado (tolerância de 8%, ou a sua em `thresholds` se for mais rígida). Ignora as tarjas pretas que algumas câmeras acrescentam |
110
+ | `mirror` | `boolean` | `true` | prévia espelhada, como um espelho (a foto enviada e a final não são espelhadas) |
111
+ | `autoStart` | `boolean` | `false` | ligar a câmera ao montar |
112
+ | `intro` | `boolean` | `true` | abertura animada do escudo |
113
+ | `watermark` | `boolean` | `true` | escudo no canto do vídeo |
114
+ | `labels` | `Partial<FaceGateLabels>` | pt-BR | troque qualquer texto |
115
+
116
+ ### Aparência
117
+
118
+ Os estilos usam o prefixo `.fg-` e variáveis CSS:
119
+
120
+ ```css
121
+ .minha-tela .fg-root {
122
+ --fg-accent: #c9a43e; /* mira, botão, barra de "fique parado" */
123
+ --fg-accent-text: #1d2125; /* texto sobre o botão */
124
+ --fg-surface: #1d2125; /* fundo do vídeo */
125
+ --fg-radius: 12px;
126
+ }
127
+ ```
128
+
129
+ Respeita `prefers-reduced-motion` (sem animações).
130
+
131
+ ## Melhoria de imagem
132
+
133
+ Serviço contratável **"Validar com melhoria"** (scope `validate_enhance`). A API melhora a foto, valida a
134
+ versão melhorada e devolve `enhancement` com o resultado **antes** (original) e **depois** (melhorada) e a
135
+ imagem melhorada (`enhancement.enhanced_image`, JPEG). No `<FaceGate enhance="classic">`, só a foto
136
+ capturada passa pela melhoria (os quadros ao vivo não), o `onCapture` entrega a melhorada em `image` e a
137
+ tela mostra o selo "Imagem melhorada".
138
+
139
+ | Método | O que faz | Custo |
140
+ |---|---|---|
141
+ | `classic` | balanço de branco, exposição, contraste local, redução de ruído e nitidez leve. Determinística, não altera o rosto | baixo |
142
+ | `gfpgan` | a clássica + restauração do rosto com IA (GFPGAN). Recria detalhes do rosto: a resposta traz o aviso `face_restored_ai` e o componente mostra o alerta | alto |
143
+
144
+ **Regra:** a melhoria só corrige luz (`poor_lighting`, `overexposed`) e rosto não encontrado por falta de
145
+ luz (`no_face`). As demais falhas da foto original — desfoque, olhos fechados, óculos escuros, pose… —
146
+ continuam reprovando (`enhancement.carried_over`). Com `clean`, o fundo é removido da foto melhorada (exige
147
+ também o serviço "validar e remover fundo").
148
+
149
+ **Tokens:** a validação roda duas vezes (antes e depois) e soma a etapa `usage.breakdown.enhance`
150
+ (a clássica é barata; o GFPGAN custa bem mais).
151
+
152
+ ## Avatar
153
+
154
+ Serviço **Avatar** contratado (`POST /v1/photos/avatar`): valida a foto e, se aprovada, devolve o avatar só com a
155
+ pessoa do rosto em foco — o fundo e outros elementos ou pessoas são removidos —, recortado centrado no rosto.
156
+
157
+ ```ts
158
+ import { createFaceGate } from "@beltec/face-gate/server";
159
+
160
+ const faceGate = createFaceGate({ clientId, clientSecret });
161
+ const r = await faceGate.avatar(fileBuffer, {
162
+ shape: "round", // "round" (padrão; fora do círculo transparente) | "square"
163
+ size: 512, // 64 a 1024
164
+ backgroundColor: "#1d252a", // ou background: <imagem> (tem prioridade); sem nenhum = PNG transparente
165
+ margin: 25, // só com fundo: % do quadro deixado para o fundo (0 a 60)
166
+ layout: "bottom", // só com fundo: "center" | "bottom" | "left" | "right"
167
+ });
168
+
169
+ if (r.accepted) r.avatar_image; // data:image/png;base64,...
170
+ else r.hard_fails; // sem avatar: os mesmos motivos da validação
171
+ ```
172
+
173
+ | Opção | Tipo | Padrão | |
174
+ |---|---|---|---|
175
+ | `shape` | `"round" \| "square"` | `"round"` | formato do recorte |
176
+ | `size` | `number` | `512` | lado em pixels (64 a 1024) |
177
+ | `backgroundColor` | `string` | — | cor sólida `#RRGGBB` |
178
+ | `background` | `Blob \| ArrayBuffer \| Uint8Array` | — | imagem de fundo (preenche o quadro) |
179
+ | `template` | `number` | — | id de um **template de avatar** salvo no portal (Avatares): formato, tamanho, fundo e enquadramento prontos, sem reenviar a imagem; as outras opções têm prioridade |
180
+ | `backgroundPosition` | `{ x?, y?, zoom? }` | centro, `zoom` 100 | **só com `background`**: qual parte da imagem aparece — `x`/`y` de 0 a 100 (50 = centro, como o `object-position` do CSS) e `zoom` de 100 a 300 % |
181
+ | `margin` | `number` | `0` | **só com fundo**: % do quadro deixado para o fundo em volta da pessoa (0 a 60) |
182
+ | `layout` | `"center" \| "bottom" \| "left" \| "right"` | `"center"` | **só com fundo**: onde a pessoa fica (centro, apoiada na base, base à esquerda/direita) |
183
+ | `model` / `device` | | servidor (BiRefNet) | modelo de remoção de fundo que separa a pessoa |
184
+ | `thresholds` | `Record<string, number>` | servidor | limiares da validação |
185
+
186
+ O resultado traz também `avatar` (`shape`, `size`, `model`, `background`, `margin`, `layout`). **Tokens:** soma a etapa `avatar`
187
+ em `usage.breakdown` (a separação da pessoa roda na imagem analisada inteira).
188
+
189
+ ## Tokens e erros
190
+
191
+ Cada foto capturada (e cada `validate`) consome **tokens** do seu plano — megapixels processados × peso de cada
192
+ etapa; foto maior ou remoção de fundo com BiRefNet gastam mais. Os quadros ao vivo da webcam não consomem.
193
+ O plano limita os tokens por **janela** (ex.: 5 h a partir do 1º uso) e por **mês** (renova no aniversário do
194
+ contrato).
195
+
196
+ `FaceGateError.code`:
197
+
198
+ | code | quando |
199
+ |---|---|
200
+ | `token_window_limit` | janela de tokens esgotada (`resetsAt` = quando renova) |
201
+ | `token_month_limit` | teto mensal atingido (`resetsAt` = data da renovação) |
202
+ | `forbidden` | serviço não contratado, chave bloqueada/expirada, fora do contrato ou IP não liberado |
203
+ | `unauthorized` | chave/ticket inválido ou expirado |
204
+ | `camera_denied` / `camera_unavailable` | permissão negada, sem câmera ou página sem HTTPS |
205
+ | `network` / `server` / `bad_request` | falhas de conexão ou da API |
206
+
207
+ ## Segurança e LGPD
208
+
209
+ - A whitelist de IPs da chave vale para o **seu servidor** (onde o ticket é emitido); o navegador usa só o
210
+ ticket de uso único.
211
+ - A API não guarda as imagens: só o resultado e metadados da validação.
212
+ - A câmera exige HTTPS (ou `localhost`).
213
+ - As chamadas do servidor saem com o User-Agent `face-gate-sdk/<versão> (node <versão>; <sistema>)`
214
+ (`SDK_USER_AGENT`). Assim, uma tentativa de um IP não liberado aparece no portal como "SDK Face Gate <versão>".
215
+
216
+ ---
217
+
218
+ © BelTec, do grupo Belfort. Uso sujeito ao contrato do Face Gate.
package/dist/index.cjs CHANGED
@@ -55,6 +55,8 @@ var CLOSE = {
55
55
  4429: ["token_window_limit", "Limite de uso atingido. Aguarde e tente de novo."],
56
56
  1011: ["server", "Erro interno na API."]
57
57
  };
58
+ var DEFAULT_FPS = 6;
59
+ var MAX_BUFFERED = 32 * 1024;
58
60
  function openFaceGateStream(url, ticket, options, handlers, WS = WebSocket) {
59
61
  const ws = new WS(url);
60
62
  ws.binaryType = "arraybuffer";
@@ -65,7 +67,7 @@ function openFaceGateStream(url, ticket, options, handlers, WS = WebSocket) {
65
67
  ws.onmessage = (e) => {
66
68
  if (typeof e.data !== "string") return;
67
69
  const m = JSON.parse(e.data);
68
- if (m.type === "ready") handlers.onReady?.();
70
+ if (m.type === "ready") handlers.onReady?.(Number(m.max_fps) || DEFAULT_FPS);
69
71
  else if (m.type === "frame") handlers.onFrame?.(m.seq, m.result, m.stable ?? { ms: 0, needed: 0 });
70
72
  else if (m.type === "captured") handlers.onCaptured?.(m.seq);
71
73
  else if (m.type === "final") {
@@ -81,7 +83,7 @@ function openFaceGateStream(url, ticket, options, handlers, WS = WebSocket) {
81
83
  const open = () => ws.readyState === 1;
82
84
  return {
83
85
  sendFrame: (frame) => {
84
- if (!open()) return null;
86
+ if (!open() || ws.bufferedAmount > MAX_BUFFERED) return null;
85
87
  ws.send(frame);
86
88
  return ++seq;
87
89
  },
@@ -171,6 +173,13 @@ function cropTransform(c, mirror = true) {
171
173
  const move = `translate(${-c.x * 100}%, ${-c.y * 100}%)`;
172
174
  return mirror ? `translateX(100%) scale(${-k}, ${k}) ${move}` : `scale(${k}) ${move}`;
173
175
  }
176
+ function overlayTransform(used, now, mirror = true) {
177
+ const k = used.s / now.s;
178
+ const dx = (used.x - now.x) / now.s;
179
+ const tx = mirror ? 1 - k - dx : dx;
180
+ const ty = (used.y - now.y) / now.s;
181
+ return `translate(${tx * 100}%, ${ty * 100}%) scale(${k})`;
182
+ }
174
183
  function boxStyle(b, mirror) {
175
184
  const left = mirror ? 1 - b.xmax : b.xmin;
176
185
  return {
@@ -300,6 +309,12 @@ function FaceGate({
300
309
  const canvasRef = (0, import_react2.useRef)(null);
301
310
  const framesRef = (0, import_react2.useRef)(/* @__PURE__ */ new Map());
302
311
  const viewRef = (0, import_react2.useRef)(null);
312
+ const overlayRef = (0, import_react2.useRef)(null);
313
+ const overlayCropRef = (0, import_react2.useRef)(FULL);
314
+ const fpsRef = (0, import_react2.useRef)(6);
315
+ const sendRef = (0, import_react2.useRef)(() => {
316
+ });
317
+ const lastSeqRef = (0, import_react2.useRef)(0);
303
318
  const cropRef = (0, import_react2.useRef)(FULL);
304
319
  const targetRef = (0, import_react2.useRef)(FULL);
305
320
  const areaRef = (0, import_react2.useRef)(WHOLE);
@@ -334,6 +349,7 @@ function FaceGate({
334
349
  const tick = () => {
335
350
  cropRef.current = ease(cropRef.current, targetRef.current);
336
351
  if (viewRef.current) viewRef.current.style.transform = cropTransform(cropRef.current, mirror);
352
+ if (overlayRef.current) overlayRef.current.style.transform = overlayTransform(overlayCropRef.current, cropRef.current, mirror);
337
353
  id = requestAnimationFrame(tick);
338
354
  };
339
355
  id = requestAnimationFrame(tick);
@@ -456,7 +472,9 @@ function FaceGate({
456
472
  gateRef.current = { camera: false, socket: false, intro: !playIntro };
457
473
  framesRef.current.clear();
458
474
  hadFaceRef.current = false;
459
- cropRef.current = targetRef.current = FULL;
475
+ lastSeqRef.current = 0;
476
+ cropRef.current = targetRef.current = overlayCropRef.current = FULL;
477
+ if (overlayRef.current) overlayRef.current.style.transform = "";
460
478
  areaRef.current = WHOLE;
461
479
  measuredRef.current = 0;
462
480
  lostSinceRef.current = 0;
@@ -495,13 +513,17 @@ function FaceGate({
495
513
  });
496
514
  }
497
515
  streamRef.current = openFaceGateStream(tk.value.url, tk.value.ticket, cfg.current.options, {
498
- onReady: () => {
516
+ onReady: (maxFps) => {
517
+ fpsRef.current = Math.min(Math.max(maxFps, 1), 10);
499
518
  gateRef.current.socket = true;
500
519
  goLiveIfReady();
501
520
  },
502
521
  onFrame: (seq, r, st) => {
522
+ if (seq <= lastSeqRef.current) return;
523
+ lastSeqRef.current = seq;
503
524
  const f = framesRef.current.get(seq);
504
525
  if (f) f.result = r;
526
+ if (autoFrame) overlayCropRef.current = f?.crop ?? cropRef.current;
505
527
  aim(seq, r.dominant_face_bbox);
506
528
  const hasFace = Boolean(r.dominant_face_bbox);
507
529
  if (hasFace && !hadFaceRef.current) setDetectKey((k) => k + 1);
@@ -509,7 +531,6 @@ function FaceGate({
509
531
  setLive(r);
510
532
  setStable(st);
511
533
  cfg.current.onLive?.(r);
512
- requestAnimationFrame(sendFrame);
513
534
  },
514
535
  onCaptured: (seq) => {
515
536
  const frames = framesRef.current;
@@ -543,6 +564,14 @@ function FaceGate({
543
564
  (0, import_react2.useEffect)(() => {
544
565
  if (autoStart) void start();
545
566
  }, []);
567
+ (0, import_react2.useEffect)(() => {
568
+ sendRef.current = sendFrame;
569
+ });
570
+ (0, import_react2.useEffect)(() => {
571
+ if (phase !== "live") return;
572
+ const id = setInterval(() => sendRef.current(), 1e3 / fpsRef.current);
573
+ return () => clearInterval(id);
574
+ }, [phase]);
546
575
  const cameraOn = phase === "starting" || phase === "live";
547
576
  const box = phase === "live" ? live?.dominant_face_bbox ?? null : null;
548
577
  const ok = Boolean(live?.accepted);
@@ -584,10 +613,18 @@ function FaceGate({
584
613
  children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fg-search-sweep" })
585
614
  }
586
615
  ),
587
- box && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fg-track", "data-state": ok ? "ok" : "warn", style: boxStyle(box, mirror), "aria-hidden": "true", children: [
588
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fg-track-line" }),
589
- holding && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fg-track-hold", style: { transform: `scaleX(${progress})` } })
590
- ] }, detectKey),
616
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
617
+ "div",
618
+ {
619
+ ref: overlayRef,
620
+ "aria-hidden": "true",
621
+ style: { position: "absolute", inset: 0, transformOrigin: "0 0", pointerEvents: "none" },
622
+ children: box && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fg-track", "data-state": ok ? "ok" : "warn", style: boxStyle(box, mirror), children: [
623
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fg-track-line" }),
624
+ holding && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fg-track-hold", style: { transform: `scaleX(${progress})` } })
625
+ ] }, detectKey)
626
+ }
627
+ ),
591
628
  phase === "processing" && frozen && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
592
629
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("img", { src: frozen.url, alt: "", className: "fg-video", style: mirror ? { transform: "scaleX(-1)" } : void 0 }),
593
630
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "fg-scan", style: boxStyle(frozen.box, mirror), "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fg-scan-line" }) })