@mostajs/face 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,23 @@
1
1
  # @mostajs/face
2
2
 
3
- > Reusable face recognition module — detection, descriptor extraction, 1:N matching.
3
+ > Reusable face recognition module — detection, descriptor extraction, 1:N matching, API route factories.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@mostajs/face.svg)](https://www.npmjs.com/package/@mostajs/face)
6
6
  [![license](https://img.shields.io/npm/l/@mostajs/face.svg)](LICENSE)
7
7
 
8
- Part of the [@mosta suite](https://mostajs.dev).
8
+ Part of the [@mosta suite](https://mostajs.dev). **100% standalone** — zero dependency on `@mostajs/orm` or any other `@mostajs/*` package.
9
+
10
+ ---
11
+
12
+ ## Table des matieres
13
+
14
+ 1. [Installation](#installation)
15
+ 2. [Quick Start](#quick-start)
16
+ 3. [API Route Factories](#api-route-factories)
17
+ 4. [React Hooks](#react-hooks)
18
+ 5. [Integration complete dans une nouvelle app](#integration-complete)
19
+ 6. [API Reference](#api-reference)
20
+ 7. [Architecture](#architecture)
9
21
 
10
22
  ---
11
23
 
@@ -15,59 +27,411 @@ Part of the [@mosta suite](https://mostajs.dev).
15
27
  npm install @mostajs/face
16
28
  ```
17
29
 
30
+ Copier les modeles face-api.js dans votre dossier `public/` :
31
+
32
+ ```bash
33
+ mkdir -p public/models/face-api
34
+ # Copier les fichiers .bin et .json depuis node_modules/@vladmandic/face-api/model/
35
+ cp node_modules/@vladmandic/face-api/model/tiny_face_detector_model-* public/models/face-api/
36
+ cp node_modules/@vladmandic/face-api/model/face_landmark_68_model-* public/models/face-api/
37
+ cp node_modules/@vladmandic/face-api/model/face_recognition_model-* public/models/face-api/
38
+ ```
39
+
40
+ ---
41
+
18
42
  ## Quick Start
19
43
 
20
- ### 1. Load models and detect faces
44
+ ### 1. Charger les modeles et detecter des visages
21
45
 
22
46
  ```typescript
23
47
  import { loadModels, detectFace, extractDescriptor } from '@mostajs/face'
24
48
 
25
- await loadModels('/models') // path to face-api.js model files
49
+ await loadModels('/models/face-api')
26
50
 
27
51
  const detection = await detectFace(imageElement)
28
- const descriptor = await extractDescriptor(imageElement)
52
+ const descriptor = await extractDescriptor(imageElement) // Float32Array(128)
29
53
  ```
30
54
 
31
- ### 2. Compare faces
55
+ ### 2. Comparer des visages
32
56
 
33
57
  ```typescript
34
58
  import { compareFaces, findMatch } from '@mostajs/face'
35
59
 
60
+ // Distance euclidienne entre deux descripteurs
36
61
  const distance = compareFaces(descriptor1, descriptor2)
62
+ console.log(distance < 0.6 ? 'Meme personne' : 'Personnes differentes')
37
63
 
38
- const match = findMatch(unknownDescriptor, knownFaces, 0.6)
64
+ // Recherche 1:N parmi des candidats
65
+ const candidates = [
66
+ { id: '1', faceDescriptor: [...], name: 'Alice' },
67
+ { id: '2', faceDescriptor: [...], name: 'Bob' },
68
+ ]
69
+ const match = findMatch(unknownDescriptor, candidates, 0.6)
39
70
  if (match) {
40
- console.log('Matched:', match.label, 'distance:', match.distance)
71
+ console.log('Match:', match.match.name, 'distance:', match.distance)
72
+ }
73
+ ```
74
+
75
+ ---
76
+
77
+ ## API Route Factories
78
+
79
+ Depuis la v1.1.0, le package fournit des factories pour creer des routes API (Next.js App Router ou tout framework supportant `Request`/`Response`).
80
+
81
+ ### POST /api/face/recognize
82
+
83
+ Factory qui recoit un descripteur facial et cherche le meilleur match parmi vos candidats.
84
+
85
+ ```typescript
86
+ // src/app/api/face/recognize/route.ts
87
+ import { createRecognizeHandler } from '@mostajs/face/api/recognize.route'
88
+ import { db } from '@/lib/db'
89
+
90
+ export const { POST } = createRecognizeHandler({
91
+ // Fournir les candidats depuis votre base de donnees
92
+ getCandidates: async () => {
93
+ return db.query('SELECT id, name, photo, "faceDescriptor" FROM users WHERE "faceDescriptor" IS NOT NULL')
94
+ },
95
+
96
+ // Seuil de matching (defaut: 0.6)
97
+ getThreshold: async () => 0.55,
98
+
99
+ // Champs retournes dans la reponse (defaut: tous sauf faceDescriptor)
100
+ publicFields: ['name', 'photo', 'email'],
101
+
102
+ // Optionnel: verifier l'authentification
103
+ checkAuth: async (req) => {
104
+ const token = req.headers.get('Authorization')
105
+ if (!token) return Response.json({ error: 'Unauthorized' }, { status: 401 })
106
+ return null // OK
107
+ },
108
+
109
+ // Optionnel: desactiver la feature
110
+ isEnabled: async () => {
111
+ const settings = await getAppSettings()
112
+ return settings.faceRecognitionEnabled
113
+ },
114
+ })
115
+ ```
116
+
117
+ **Requete :**
118
+ ```json
119
+ POST /api/face/recognize
120
+ { "faceDescriptor": [0.023, -0.114, 0.087, ...] }
121
+ ```
122
+
123
+ **Reponse (match) :**
124
+ ```json
125
+ {
126
+ "data": {
127
+ "match": true,
128
+ "distance": 0.312,
129
+ "candidate": { "id": "abc123", "name": "Alice", "photo": "/photos/alice.jpg" }
130
+ }
131
+ }
132
+ ```
133
+
134
+ **Reponse (pas de match) :**
135
+ ```json
136
+ {
137
+ "data": { "match": false, "distance": 0.782 }
41
138
  }
42
139
  ```
43
140
 
44
- ### 3. React hooks
141
+ ### POST /api/face/detect (placeholder)
142
+
143
+ ```typescript
144
+ // src/app/api/face/detect/route.ts
145
+ import { createDetectHandler } from '@mostajs/face/api/detect.route'
146
+
147
+ export const { POST } = createDetectHandler({
148
+ checkAuth: async (req) => { /* ... */ return null },
149
+ isEnabled: async () => true,
150
+ })
151
+ ```
152
+
153
+ > La detection se fait cote client via face-api.js. Cette route sert de point d'entree pour le controle d'acces et un futur traitement serveur.
154
+
155
+ ---
156
+
157
+ ## React Hooks
158
+
159
+ ### useCamera
45
160
 
46
161
  ```tsx
47
162
  import { useCamera } from '@mostajs/face/hooks/useCamera'
163
+
164
+ function CameraView() {
165
+ const { videoRef, streaming, start, stop, capture } = useCamera()
166
+
167
+ return (
168
+ <div>
169
+ <video ref={videoRef} autoPlay playsInline muted />
170
+ {!streaming
171
+ ? <button onClick={start}>Demarrer</button>
172
+ : <button onClick={() => capture(canvas)}>Capturer</button>
173
+ }
174
+ </div>
175
+ )
176
+ }
177
+ ```
178
+
179
+ ### useFaceDetection
180
+
181
+ ```tsx
48
182
  import { useFaceDetection } from '@mostajs/face/hooks/useFaceDetection'
49
183
 
50
- function FaceCapture() {
51
- const { videoRef, start, stop } = useCamera()
52
- const { detection, descriptor } = useFaceDetection(videoRef)
53
- // ...
184
+ function LiveDetection({ videoRef }) {
185
+ const { faceDetected, detection, descriptor } = useFaceDetection(videoRef, {
186
+ scoreThreshold: 0.5,
187
+ inputSize: 320,
188
+ })
189
+
190
+ return <span>{faceDetected ? 'Visage detecte' : 'Aucun visage'}</span>
191
+ }
192
+ ```
193
+
194
+ ---
195
+
196
+ ## Integration complete
197
+
198
+ Exemple complet d'integration dans une nouvelle app Next.js.
199
+
200
+ ### Etape 1 — Installer
201
+
202
+ ```bash
203
+ npm install @mostajs/face
204
+ ```
205
+
206
+ ### Etape 2 — Copier les modeles
207
+
208
+ ```bash
209
+ mkdir -p public/models/face-api
210
+ cp node_modules/@vladmandic/face-api/model/tiny_face_detector_model-* public/models/face-api/
211
+ cp node_modules/@vladmandic/face-api/model/face_landmark_68_model-* public/models/face-api/
212
+ cp node_modules/@vladmandic/face-api/model/face_recognition_model-* public/models/face-api/
213
+ ```
214
+
215
+ ### Etape 3 — Route API recognize
216
+
217
+ ```typescript
218
+ // src/app/api/face/recognize/route.ts
219
+ import { createRecognizeHandler } from '@mostajs/face/api/recognize.route'
220
+ import prisma from '@/lib/prisma' // ou tout ORM
221
+
222
+ export const { POST } = createRecognizeHandler({
223
+ getCandidates: async () => {
224
+ const users = await prisma.user.findMany({
225
+ where: { faceDescriptor: { not: null } },
226
+ select: { id: true, name: true, photo: true, faceDescriptor: true },
227
+ })
228
+ return users.map(u => ({
229
+ ...u,
230
+ faceDescriptor: u.faceDescriptor as number[],
231
+ }))
232
+ },
233
+ getThreshold: async () => 0.6,
234
+ publicFields: ['name', 'photo'],
235
+ })
236
+ ```
237
+
238
+ ### Etape 4 — Composant React
239
+
240
+ ```tsx
241
+ 'use client'
242
+ import { useState, useRef, useEffect, useCallback } from 'react'
243
+ import { useCamera } from '@mostajs/face/hooks/useCamera'
244
+
245
+ export default function FaceSearch() {
246
+ const { videoRef, streaming, start, stop } = useCamera()
247
+ const [result, setResult] = useState(null)
248
+ const faceApiRef = useRef(null)
249
+
250
+ useEffect(() => {
251
+ import('@mostajs/face').then(mod => {
252
+ mod.loadModels('/models/face-api')
253
+ faceApiRef.current = mod
254
+ })
255
+ }, [])
256
+
257
+ const recognize = useCallback(async () => {
258
+ if (!faceApiRef.current || !videoRef.current) return
259
+ const descriptor = await faceApiRef.current.extractDescriptor(videoRef.current)
260
+ if (!descriptor) { alert('Aucun visage'); return }
261
+
262
+ const res = await fetch('/api/face/recognize', {
263
+ method: 'POST',
264
+ headers: { 'Content-Type': 'application/json' },
265
+ body: JSON.stringify({ faceDescriptor: Array.from(descriptor) }),
266
+ })
267
+ const json = await res.json()
268
+ setResult(json.data)
269
+ }, [videoRef])
270
+
271
+ return (
272
+ <div>
273
+ <video ref={videoRef} autoPlay playsInline muted />
274
+ {!streaming
275
+ ? <button onClick={start}>Camera</button>
276
+ : <button onClick={recognize}>Rechercher</button>
277
+ }
278
+ {result?.match && <p>Trouve: {result.candidate.name}</p>}
279
+ </div>
280
+ )
54
281
  }
55
282
  ```
56
283
 
284
+ ### Etape 5 — Verification locale
285
+
286
+ ```bash
287
+ # Demarrer l'app
288
+ npm run dev
289
+
290
+ # Tester l'API
291
+ curl -X POST http://localhost:3000/api/face/recognize \
292
+ -H 'Content-Type: application/json' \
293
+ -d '{"faceDescriptor": [0.1, 0.2, ...]}'
294
+ ```
295
+
296
+ ---
297
+
57
298
  ## API Reference
58
299
 
300
+ ### Core (client-side)
301
+
302
+ | Export | Description |
303
+ |--------|-------------|
304
+ | `loadModels(path)` | Charger les modeles face-api.js |
305
+ | `isLoaded()` | Verifier si les modeles sont charges |
306
+ | `detectFace(input)` | Detecter un visage avec landmarks |
307
+ | `detectAllFaces(input)` | Detecter tous les visages |
308
+ | `extractDescriptor(input)` | Extraire un descripteur 128-dim |
309
+
310
+ ### Matching (client ou serveur)
311
+
312
+ | Export | Description |
313
+ |--------|-------------|
314
+ | `compareFaces(d1, d2)` | Distance euclidienne entre 2 descripteurs |
315
+ | `findMatch(descriptor, candidates, threshold)` | Meilleur match sous le seuil |
316
+ | `findAllMatches(descriptor, candidates, threshold)` | Tous les matchs sous le seuil |
317
+
318
+ ### Utils
319
+
59
320
  | Export | Description |
60
321
  |--------|-------------|
61
- | `loadModels(path)` | Load face-api.js models |
62
- | `detectFace(input)` | Detect single face with landmarks |
63
- | `detectAllFaces(input)` | Detect all faces |
64
- | `extractDescriptor(input)` | Extract 128-dim face descriptor |
65
- | `compareFaces(d1, d2)` | Euclidean distance between descriptors |
66
- | `findMatch(descriptor, known, threshold)` | Find best match |
67
- | `findAllMatches(descriptor, known, threshold)` | Find all matches |
68
- | `useCamera()` | Camera management hook |
69
- | `useFaceDetection()` | Continuous detection hook |
322
+ | `descriptorToArray(Float32Array)` | Convertir en `number[]` |
323
+ | `arrayToDescriptor(number[])` | Convertir en `Float32Array` |
324
+ | `isValidDescriptor(value)` | Verifier (128 nombres) |
325
+ | `drawDetection(canvas, detection, w, h)` | Dessiner le cadre de detection |
326
+
327
+ ### Route Factories (serveur)
328
+
329
+ | Export | Description |
330
+ |--------|-------------|
331
+ | `createRecognizeHandler(config)` | Factory POST recognize avec matching 1:N |
332
+ | `createDetectHandler(config?)` | Factory POST detect (placeholder) |
333
+
334
+ ### React Hooks
335
+
336
+ | Export | Description |
337
+ |--------|-------------|
338
+ | `useCamera()` | Gestion camera (start/stop/capture) |
339
+ | `useFaceDetection(videoRef, config?)` | Detection continue en temps reel |
340
+
341
+ ### Types
342
+
343
+ | Type | Description |
344
+ |------|-------------|
345
+ | `MostaFaceConfig` | Configuration (modelsPath, thresholds, inputSize) |
346
+ | `FaceDetectionResult` | Resultat detection (score, box) |
347
+ | `FaceMatchResult<T>` | Resultat matching (match, distance) |
348
+ | `FaceDescriptor` | `Float32Array \| number[]` |
349
+ | `FaceCandidate` | Interface candidat (id, faceDescriptor, ...) |
350
+ | `RecognizeHandlerConfig` | Config de `createRecognizeHandler` |
351
+ | `DetectHandlerConfig` | Config de `createDetectHandler` |
352
+
353
+ ---
354
+
355
+ ## Architecture
356
+
357
+ ```
358
+ @mostajs/face (100% standalone)
359
+ ├── lib/
360
+ │ ├── face-api.ts # Chargement modeles, detection, extraction
361
+ │ ├── face-matcher.ts # Comparaison, findMatch, findAllMatches
362
+ │ └── face-utils.ts # Dessin, conversion descripteurs
363
+ ├── api/
364
+ │ ├── recognize.route.ts # Factory POST /api/face/recognize
365
+ │ └── detect.route.ts # Factory POST /api/face/detect
366
+ ├── hooks/
367
+ │ ├── useCamera.ts # Hook gestion camera
368
+ │ └── useFaceDetection.ts # Hook detection temps reel
369
+ ├── types/
370
+ │ └── index.ts # Types exports
371
+ └── index.ts # Barrel exports
372
+
373
+ Dependances:
374
+ @vladmandic/face-api (seule dep runtime)
375
+ react >= 18 (peer dependency)
376
+
377
+ Zero dependance sur: @mostajs/orm, @mostajs/auth, @mostajs/settings
378
+ ```
379
+
380
+ ### Pattern Factory
381
+
382
+ Le meme pattern que `@mostajs/setup` : le package exporte des factories, l'app injecte ses dependances :
383
+
384
+ ```
385
+ ┌─────────────────────┐ inject callbacks ┌──────────────────────┐
386
+ │ @mostajs/face │ ◄──────────────────────── │ Votre app │
387
+ │ │ │ │
388
+ │ createRecognizeHandler({ │ getCandidates: () => │
389
+ │ getCandidates, │ │ db.query(...) │
390
+ │ checkAuth, │ │ checkAuth: () => │
391
+ │ getThreshold, │ │ verifyToken(...) │
392
+ │ }) │ │ │
393
+ └─────────────────────┘ └──────────────────────┘
394
+ ```
395
+
396
+
397
+
398
+ Résultat
399
+
400
+ Ce qui a été migré dans @mostajs/face v1.1.0
401
+
402
+ ┌────────────────────────┬─────────────────────────────────────────────────────┐
403
+ │ Fichier │ Description │
404
+ ├────────────────────────┼─────────────────────────────────────────────────────┤
405
+ │ api/recognize.route.ts │ Factory createRecognizeHandler() — matching 1:N │
406
+ │ │ avec callbacks injectés │
407
+ ├────────────────────────┼─────────────────────────────────────────────────────┤
408
+ │ api/detect.route.ts │ Factory createDetectHandler() — placeholder avec │
409
+ │ │ auth/enable │
410
+ ├────────────────────────┼─────────────────────────────────────────────────────┤
411
+ │ index.ts │ +4 exports (2 factories + 2 types) │
412
+ ├────────────────────────┼─────────────────────────────────────────────────────┤
413
+ │ package.json │ v1.1.0, subpath exports ./api/*, ./lib/*, ./types │
414
+ ├────────────────────────┼─────────────────────────────────────────────────────┤
415
+ │ README.md │ Tutoriel complet avec intégration nouvelle app │
416
+ └────────────────────────┴─────────────────────────────────────────────────────┘
417
+
418
+ Routes app simplifiées
419
+
420
+ - src/app/api/face/recognize/route.ts : 101 lignes → 30 lignes (injection de
421
+ getCandidates, checkAuth, getThreshold, publicFields)
422
+ - src/app/api/face/detect/route.ts : 30 lignes → 15 lignes
423
+
424
+ Ce qui reste dans l'app (non migré)
425
+
426
+ - src/app/api/scan/route.ts — Scanning de tickets (QR, quotas, réentrée). Pas lié
427
+ à face, relève d'un futur @mostajs/ticketing
428
+ - src/components/clients/FaceDetector.tsx — Composant UI trop couplé (shadcn,
429
+ sonner, settings). Les hooks useCamera/useFaceDetection du package couvrent la
430
+ partie réutilisable
431
+
432
+
433
+ ---
70
434
 
71
435
  ## License
72
436
 
73
- MIT — © 2025 Dr Hamid MADANI <drmdh@msn.com>
437
+ MIT — Dr Hamid MADANI <drmdh@msn.com>
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Configuration for the detect handler.
3
+ */
4
+ export interface DetectHandlerConfig {
5
+ /** Check auth/permission — return null if OK, or a Response to deny */
6
+ checkAuth?: (req: Request) => Promise<Response | null>;
7
+ /** Check if face recognition is enabled (default: always true) */
8
+ isEnabled?: () => Promise<boolean>;
9
+ }
10
+ /**
11
+ * Factory for POST /api/face/detect
12
+ *
13
+ * Placeholder endpoint — face detection runs client-side via face-api.js.
14
+ * This route exists for permission gating and future server-side detection.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * import { createDetectHandler } from '@mostajs/face/api/detect.route'
19
+ * export const { POST } = createDetectHandler()
20
+ * ```
21
+ */
22
+ export declare function createDetectHandler(config?: DetectHandlerConfig): {
23
+ POST: (req: Request) => Promise<Response>;
24
+ };
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ // @mosta/face — Detect API route factory (placeholder)
3
+ // Author: Dr Hamid MADANI drmdh@msn.com
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.createDetectHandler = createDetectHandler;
6
+ /**
7
+ * Factory for POST /api/face/detect
8
+ *
9
+ * Placeholder endpoint — face detection runs client-side via face-api.js.
10
+ * This route exists for permission gating and future server-side detection.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { createDetectHandler } from '@mostajs/face/api/detect.route'
15
+ * export const { POST } = createDetectHandler()
16
+ * ```
17
+ */
18
+ function createDetectHandler(config = {}) {
19
+ async function POST(req) {
20
+ if (config.isEnabled) {
21
+ const enabled = await config.isEnabled();
22
+ if (!enabled) {
23
+ return Response.json({ error: { code: 'FEATURE_DISABLED', message: 'Face recognition is disabled' } }, { status: 403 });
24
+ }
25
+ }
26
+ if (config.checkAuth) {
27
+ const denied = await config.checkAuth(req);
28
+ if (denied)
29
+ return denied;
30
+ }
31
+ return Response.json({
32
+ data: {
33
+ message: 'Face detection runs client-side. Use the FaceDetector component or useFaceDetection hook.',
34
+ hint: 'For recognition, send the descriptor to POST /api/face/recognize',
35
+ },
36
+ });
37
+ }
38
+ return { POST };
39
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Candidate shape: must have an id, faceDescriptor, and any extra fields.
3
+ */
4
+ export interface FaceCandidate {
5
+ id: string;
6
+ faceDescriptor: number[];
7
+ [key: string]: unknown;
8
+ }
9
+ /**
10
+ * Configuration for the recognize handler.
11
+ */
12
+ export interface RecognizeHandlerConfig {
13
+ /** Fetch all active candidates with a faceDescriptor from the database */
14
+ getCandidates: () => Promise<FaceCandidate[]>;
15
+ /** Check auth/permission — return null if OK, or a Response to deny */
16
+ checkAuth?: (req: Request) => Promise<Response | null>;
17
+ /** Get the matching distance threshold (default: 0.6) */
18
+ getThreshold?: () => Promise<number>;
19
+ /** Check if face recognition is enabled (default: always true) */
20
+ isEnabled?: () => Promise<boolean>;
21
+ /** Fields to return from the matched candidate (default: all except faceDescriptor) */
22
+ publicFields?: string[];
23
+ }
24
+ /**
25
+ * Factory for POST /api/face/recognize
26
+ *
27
+ * Receives a 128-float face descriptor, searches candidates, returns best match.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * // app route: src/app/api/face/recognize/route.ts
32
+ * import { createRecognizeHandler } from '@mostajs/face/api/recognize.route'
33
+ * import { clientRepo } from '@/dal/service'
34
+ *
35
+ * export const { POST } = createRecognizeHandler({
36
+ * getCandidates: async () => {
37
+ * const repo = await clientRepo()
38
+ * return repo.findAll(
39
+ * { faceDescriptor: { $exists: true, $not: { $size: 0 } }, status: 'active' },
40
+ * { select: ['firstName', 'lastName', 'clientNumber', 'photo', 'faceDescriptor'] },
41
+ * )
42
+ * },
43
+ * getThreshold: async () => 0.6,
44
+ * })
45
+ * ```
46
+ */
47
+ export declare function createRecognizeHandler(config: RecognizeHandlerConfig): {
48
+ POST: (req: Request) => Promise<Response>;
49
+ };
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createRecognizeHandler = createRecognizeHandler;
4
+ // @mosta/face — Recognize API route factory
5
+ // Author: Dr Hamid MADANI drmdh@msn.com
6
+ const face_matcher_1 = require("../lib/face-matcher");
7
+ /**
8
+ * Factory for POST /api/face/recognize
9
+ *
10
+ * Receives a 128-float face descriptor, searches candidates, returns best match.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * // app route: src/app/api/face/recognize/route.ts
15
+ * import { createRecognizeHandler } from '@mostajs/face/api/recognize.route'
16
+ * import { clientRepo } from '@/dal/service'
17
+ *
18
+ * export const { POST } = createRecognizeHandler({
19
+ * getCandidates: async () => {
20
+ * const repo = await clientRepo()
21
+ * return repo.findAll(
22
+ * { faceDescriptor: { $exists: true, $not: { $size: 0 } }, status: 'active' },
23
+ * { select: ['firstName', 'lastName', 'clientNumber', 'photo', 'faceDescriptor'] },
24
+ * )
25
+ * },
26
+ * getThreshold: async () => 0.6,
27
+ * })
28
+ * ```
29
+ */
30
+ function createRecognizeHandler(config) {
31
+ async function POST(req) {
32
+ // Check if enabled
33
+ if (config.isEnabled) {
34
+ const enabled = await config.isEnabled();
35
+ if (!enabled) {
36
+ return Response.json({ error: { code: 'FEATURE_DISABLED', message: 'Face recognition is disabled' } }, { status: 403 });
37
+ }
38
+ }
39
+ // Check auth
40
+ if (config.checkAuth) {
41
+ const denied = await config.checkAuth(req);
42
+ if (denied)
43
+ return denied;
44
+ }
45
+ // Parse body
46
+ let body;
47
+ try {
48
+ body = await req.json();
49
+ }
50
+ catch {
51
+ return Response.json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid JSON' } }, { status: 400 });
52
+ }
53
+ // Validate descriptor
54
+ const desc = body.faceDescriptor;
55
+ if (!Array.isArray(desc) ||
56
+ desc.length !== 128 ||
57
+ !desc.every((v) => typeof v === 'number')) {
58
+ return Response.json({ error: { code: 'VALIDATION_ERROR', message: 'faceDescriptor must be an array of 128 numbers' } }, { status: 400 });
59
+ }
60
+ // Get candidates
61
+ const candidates = await config.getCandidates();
62
+ if (candidates.length === 0) {
63
+ return Response.json({
64
+ data: { match: false, message: 'No candidates with face data' },
65
+ });
66
+ }
67
+ // Find match
68
+ const threshold = config.getThreshold ? await config.getThreshold() : 0.6;
69
+ const result = (0, face_matcher_1.findMatch)(desc, candidates, threshold);
70
+ if (result) {
71
+ // Strip faceDescriptor from response
72
+ const { faceDescriptor: _fd, ...publicData } = result.match;
73
+ const filtered = config.publicFields
74
+ ? Object.fromEntries(Object.entries(publicData).filter(([k]) => config.publicFields.includes(k) || k === 'id'))
75
+ : publicData;
76
+ return Response.json({
77
+ data: {
78
+ match: true,
79
+ distance: Math.round(result.distance * 1000) / 1000,
80
+ candidate: filtered,
81
+ },
82
+ });
83
+ }
84
+ // No match — still report best distance for debugging
85
+ let bestDistance = null;
86
+ for (const c of candidates) {
87
+ if (!c.faceDescriptor || c.faceDescriptor.length !== 128)
88
+ continue;
89
+ let sum = 0;
90
+ for (let i = 0; i < 128; i++) {
91
+ const diff = desc[i] - c.faceDescriptor[i];
92
+ sum += diff * diff;
93
+ }
94
+ const d = Math.sqrt(sum);
95
+ if (bestDistance === null || d < bestDistance)
96
+ bestDistance = d;
97
+ }
98
+ return Response.json({
99
+ data: {
100
+ match: false,
101
+ distance: bestDistance !== null ? Math.round(bestDistance * 1000) / 1000 : null,
102
+ },
103
+ });
104
+ }
105
+ return { POST };
106
+ }
@@ -0,0 +1,27 @@
1
+ export interface FaceDetectorProps {
2
+ /** Existing photo (base64) */
3
+ photo: string;
4
+ /** Callback when a photo is captured */
5
+ onCapture: (data: {
6
+ photo: string;
7
+ faceDescriptor: number[] | null;
8
+ }) => void;
9
+ /** Callback when photo is cleared */
10
+ onClear: () => void;
11
+ /** Verification mode: compare against existing descriptor */
12
+ verifyDescriptor?: number[];
13
+ /** Callback with verification result */
14
+ onVerifyResult?: (result: {
15
+ match: boolean;
16
+ distance: number;
17
+ } | null) => void;
18
+ /** Enable face detection (default: true) */
19
+ enabled?: boolean;
20
+ /** Match threshold (default: 0.6) */
21
+ threshold?: number;
22
+ /** Require face detected before capture (default: true) */
23
+ requireForCapture?: boolean;
24
+ /** Error callback instead of console.error */
25
+ onError?: (message: string) => void;
26
+ }
27
+ export default function FaceDetector({ photo, onCapture, onClear, verifyDescriptor, onVerifyResult, enabled, threshold, requireForCapture, onError, }: FaceDetectorProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,177 @@
1
+ "use strict";
2
+ // @mostajs/face — FaceDetector component
3
+ // Author: Dr Hamid MADANI drmdh@msn.com
4
+ 'use client';
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.default = FaceDetector;
7
+ const jsx_runtime_1 = require("react/jsx-runtime");
8
+ const react_1 = require("react");
9
+ const face_api_1 = require("../lib/face-api");
10
+ const face_matcher_1 = require("../lib/face-matcher");
11
+ const face_utils_1 = require("../lib/face-utils");
12
+ function FaceDetector({ photo, onCapture, onClear, verifyDescriptor, onVerifyResult, enabled = true, threshold = 0.6, requireForCapture = true, onError, }) {
13
+ const videoRef = (0, react_1.useRef)(null);
14
+ const canvasRef = (0, react_1.useRef)(null);
15
+ const overlayRef = (0, react_1.useRef)(null);
16
+ const animFrameRef = (0, react_1.useRef)(0);
17
+ const [streaming, setStreaming] = (0, react_1.useState)(false);
18
+ const [modelsReady, setModelsReady] = (0, react_1.useState)(false);
19
+ const [loadingModels, setLoadingModels] = (0, react_1.useState)(false);
20
+ const [faceDetected, setFaceDetected] = (0, react_1.useState)(false);
21
+ const [verifyResult, setVerifyResult] = (0, react_1.useState)(null);
22
+ const reportError = (0, react_1.useCallback)((msg) => {
23
+ if (onError)
24
+ onError(msg);
25
+ else
26
+ console.error('[FaceDetector]', msg);
27
+ }, [onError]);
28
+ // Load face-api models on mount
29
+ (0, react_1.useEffect)(() => {
30
+ if (!enabled)
31
+ return;
32
+ let cancelled = false;
33
+ async function init() {
34
+ setLoadingModels(true);
35
+ try {
36
+ await (0, face_api_1.loadModels)();
37
+ if (!cancelled)
38
+ setModelsReady(true);
39
+ }
40
+ catch (err) {
41
+ console.error('Face-api model loading error:', err);
42
+ if (!cancelled)
43
+ reportError('Impossible de charger les modeles de detection faciale');
44
+ }
45
+ finally {
46
+ if (!cancelled)
47
+ setLoadingModels(false);
48
+ }
49
+ }
50
+ init();
51
+ return () => { cancelled = true; };
52
+ }, [enabled, reportError]);
53
+ // Real-time detection loop
54
+ const detectLoop = (0, react_1.useCallback)(async () => {
55
+ if (!videoRef.current || !overlayRef.current)
56
+ return;
57
+ if (videoRef.current.paused || videoRef.current.ended)
58
+ return;
59
+ const video = videoRef.current;
60
+ const overlay = overlayRef.current;
61
+ const detection = await (0, face_api_1.detectFace)(video);
62
+ setFaceDetected(!!detection);
63
+ if (!videoRef.current || !overlayRef.current)
64
+ return;
65
+ (0, face_utils_1.drawDetection)(overlay, detection, video.videoWidth, video.videoHeight);
66
+ animFrameRef.current = requestAnimationFrame(detectLoop);
67
+ }, []);
68
+ const startCamera = (0, react_1.useCallback)(async () => {
69
+ setVerifyResult(null);
70
+ try {
71
+ const stream = await navigator.mediaDevices.getUserMedia({
72
+ video: { width: 320, height: 240, facingMode: 'user' },
73
+ });
74
+ if (videoRef.current) {
75
+ videoRef.current.srcObject = stream;
76
+ videoRef.current.play();
77
+ setStreaming(true);
78
+ if (enabled && modelsReady) {
79
+ videoRef.current.onloadeddata = () => {
80
+ animFrameRef.current = requestAnimationFrame(detectLoop);
81
+ };
82
+ }
83
+ }
84
+ }
85
+ catch {
86
+ reportError("Impossible d'acceder a la camera");
87
+ }
88
+ }, [enabled, modelsReady, detectLoop, reportError]);
89
+ const stopCamera = (0, react_1.useCallback)(() => {
90
+ cancelAnimationFrame(animFrameRef.current);
91
+ if (videoRef.current?.srcObject) {
92
+ const tracks = videoRef.current.srcObject.getTracks();
93
+ tracks.forEach((track) => track.stop());
94
+ videoRef.current.srcObject = null;
95
+ }
96
+ setStreaming(false);
97
+ setFaceDetected(false);
98
+ }, []);
99
+ // Cleanup on unmount
100
+ (0, react_1.useEffect)(() => {
101
+ return () => {
102
+ cancelAnimationFrame(animFrameRef.current);
103
+ if (videoRef.current?.srcObject) {
104
+ const tracks = videoRef.current.srcObject.getTracks();
105
+ tracks.forEach((track) => track.stop());
106
+ }
107
+ };
108
+ }, []);
109
+ const capturePhoto = (0, react_1.useCallback)(async () => {
110
+ if (!videoRef.current || !canvasRef.current)
111
+ return;
112
+ const canvas = canvasRef.current;
113
+ const video = videoRef.current;
114
+ canvas.width = video.videoWidth;
115
+ canvas.height = video.videoHeight;
116
+ const ctx = canvas.getContext('2d');
117
+ if (!ctx)
118
+ return;
119
+ ctx.drawImage(video, 0, 0);
120
+ const dataUrl = canvas.toDataURL('image/jpeg', 0.8);
121
+ let descriptor = null;
122
+ if (enabled && modelsReady) {
123
+ try {
124
+ const raw = await (0, face_api_1.extractDescriptor)(canvas);
125
+ if (raw)
126
+ descriptor = Array.from(raw);
127
+ }
128
+ catch (err) {
129
+ console.error('Descriptor extraction error:', err);
130
+ }
131
+ }
132
+ stopCamera();
133
+ onCapture({ photo: dataUrl, faceDescriptor: descriptor });
134
+ }, [enabled, modelsReady, stopCamera, onCapture]);
135
+ const verifyFace = (0, react_1.useCallback)(async () => {
136
+ if (!videoRef.current || !verifyDescriptor)
137
+ return;
138
+ try {
139
+ const raw = await (0, face_api_1.extractDescriptor)(videoRef.current);
140
+ if (!raw) {
141
+ setVerifyResult(null);
142
+ onVerifyResult?.(null);
143
+ reportError('Aucun visage detecte');
144
+ return;
145
+ }
146
+ const distance = (0, face_matcher_1.compareFaces)(raw, verifyDescriptor);
147
+ const match = distance < threshold;
148
+ const result = { match, distance };
149
+ setVerifyResult(result);
150
+ onVerifyResult?.(result);
151
+ }
152
+ catch (err) {
153
+ console.error('Face verification error:', err);
154
+ reportError('Erreur lors de la verification');
155
+ }
156
+ }, [verifyDescriptor, onVerifyResult, threshold, reportError]);
157
+ const captureDisabled = enabled && requireForCapture && !faceDetected;
158
+ // Basic webcam mode (face detection disabled)
159
+ if (!enabled) {
160
+ return ((0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', flexDirection: 'column', gap: '1rem' }, children: [photo ? ((0, jsx_runtime_1.jsxs)("div", { style: { position: 'relative' }, children: [(0, jsx_runtime_1.jsx)("img", { src: photo, alt: "Photo", style: { width: '100%', borderRadius: '0.5rem' } }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: onClear, style: { position: 'absolute', top: 8, right: 8, background: '#ef4444', color: 'white', border: 'none', borderRadius: '50%', width: 24, height: 24, cursor: 'pointer', fontSize: 14, lineHeight: 1 }, children: "x" })] })) : ((0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', flexDirection: 'column', gap: '0.5rem' }, children: [(0, jsx_runtime_1.jsx)("video", { ref: videoRef, style: { width: '100%', borderRadius: '0.5rem', display: streaming ? 'block' : 'none' }, autoPlay: true, playsInline: true, muted: true }), streaming ? ((0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', gap: '0.5rem' }, children: [(0, jsx_runtime_1.jsx)("button", { type: "button", onClick: capturePhoto, style: { flex: 1, padding: '0.5rem', cursor: 'pointer' }, children: "Capturer" }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: stopCamera, style: { padding: '0.5rem', cursor: 'pointer' }, children: "x" })] })) : ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: startCamera, style: { width: '100%', padding: '0.5rem', cursor: 'pointer' }, children: "Prendre photo" }))] })), (0, jsx_runtime_1.jsx)("canvas", { ref: canvasRef, style: { display: 'none' } })] }));
161
+ }
162
+ // Full face detection mode
163
+ return ((0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', flexDirection: 'column', gap: '1rem' }, children: [photo ? ((0, jsx_runtime_1.jsxs)("div", { style: { position: 'relative' }, children: [(0, jsx_runtime_1.jsx)("img", { src: photo, alt: "Photo", style: { width: '100%', borderRadius: '0.5rem' } }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: onClear, style: { position: 'absolute', top: 8, right: 8, background: '#ef4444', color: 'white', border: 'none', borderRadius: '50%', width: 24, height: 24, cursor: 'pointer', fontSize: 14, lineHeight: 1 }, children: "x" })] })) : ((0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', flexDirection: 'column', gap: '0.5rem' }, children: [(0, jsx_runtime_1.jsxs)("div", { style: { position: 'relative', display: streaming ? 'block' : 'none' }, children: [(0, jsx_runtime_1.jsx)("video", { ref: videoRef, style: { width: '100%', borderRadius: '0.5rem' }, autoPlay: true, playsInline: true, muted: true }), (0, jsx_runtime_1.jsx)("canvas", { ref: overlayRef, style: { position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none' } }), (0, jsx_runtime_1.jsx)("div", { style: {
164
+ position: 'absolute', bottom: 8, left: 8,
165
+ padding: '2px 8px', borderRadius: 4, fontSize: 12, fontWeight: 500,
166
+ background: faceDetected ? 'rgba(34,197,94,0.9)' : 'rgba(239,68,68,0.9)',
167
+ color: 'white',
168
+ }, children: faceDetected ? 'Visage detecte' : 'Aucun visage' })] }), streaming ? ((0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', gap: '0.5rem' }, children: [verifyDescriptor ? ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: verifyFace, disabled: !faceDetected, style: { flex: 1, padding: '0.5rem', cursor: faceDetected ? 'pointer' : 'not-allowed', opacity: faceDetected ? 1 : 0.5 }, children: "Verifier visage" })) : ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: capturePhoto, disabled: captureDisabled, style: { flex: 1, padding: '0.5rem', cursor: captureDisabled ? 'not-allowed' : 'pointer', opacity: captureDisabled ? 0.5 : 1 }, children: captureDisabled ? 'Cadrez votre visage...' : 'Capturer' })), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: stopCamera, style: { padding: '0.5rem', cursor: 'pointer' }, children: "x" })] })) : ((0, jsx_runtime_1.jsx)("div", { children: loadingModels ? ((0, jsx_runtime_1.jsx)("button", { type: "button", disabled: true, style: { width: '100%', padding: '0.5rem', opacity: 0.5 }, children: "Chargement detection faciale..." })) : ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: startCamera, style: { width: '100%', padding: '0.5rem', cursor: 'pointer' }, children: verifyDescriptor ? 'Verifier visage' : 'Prendre photo' })) }))] })), verifyResult && ((0, jsx_runtime_1.jsx)("div", { style: {
169
+ display: 'flex', alignItems: 'center', gap: '0.5rem',
170
+ padding: '0.75rem', borderRadius: '0.5rem', fontSize: 14, fontWeight: 500,
171
+ background: verifyResult.match ? '#f0fdf4' : '#fef2f2',
172
+ color: verifyResult.match ? '#15803d' : '#b91c1c',
173
+ border: `1px solid ${verifyResult.match ? '#bbf7d0' : '#fecaca'}`,
174
+ }, children: verifyResult.match
175
+ ? `Visage verifie (confiance: ${Math.round((1 - verifyResult.distance) * 100)}%)`
176
+ : `Visage non reconnu (distance: ${verifyResult.distance.toFixed(2)})` })), (0, jsx_runtime_1.jsx)("canvas", { ref: canvasRef, style: { display: 'none' } })] }));
177
+ }
package/dist/index.d.ts CHANGED
@@ -3,4 +3,10 @@ export { compareFaces, findMatch, findAllMatches } from './lib/face-matcher';
3
3
  export { descriptorToArray, arrayToDescriptor, isValidDescriptor, drawDetection } from './lib/face-utils';
4
4
  export { useCamera } from './hooks/useCamera';
5
5
  export { useFaceDetection } from './hooks/useFaceDetection';
6
+ export { createRecognizeHandler } from './api/recognize.route';
7
+ export { createDetectHandler } from './api/detect.route';
8
+ export { default as FaceDetector } from './components/FaceDetector';
9
+ export type { FaceDetectorProps } from './components/FaceDetector';
6
10
  export type { MostaFaceConfig, FaceDetectionResult, FaceMatchResult, FaceDescriptor } from './types';
11
+ export type { FaceCandidate, RecognizeHandlerConfig } from './api/recognize.route';
12
+ export type { DetectHandlerConfig } from './api/detect.route';
package/dist/index.js CHANGED
@@ -1,8 +1,11 @@
1
1
  "use strict";
2
2
  // @mosta/face — Barrel exports
3
3
  // Author: Dr Hamid MADANI drmdh@msn.com
4
+ var __importDefault = (this && this.__importDefault) || function (mod) {
5
+ return (mod && mod.__esModule) ? mod : { "default": mod };
6
+ };
4
7
  Object.defineProperty(exports, "__esModule", { value: true });
5
- exports.useFaceDetection = exports.useCamera = exports.drawDetection = exports.isValidDescriptor = exports.arrayToDescriptor = exports.descriptorToArray = exports.findAllMatches = exports.findMatch = exports.compareFaces = exports.extractDescriptor = exports.detectAllFaces = exports.detectFace = exports.isLoaded = exports.loadModels = void 0;
8
+ exports.FaceDetector = exports.createDetectHandler = exports.createRecognizeHandler = exports.useFaceDetection = exports.useCamera = exports.drawDetection = exports.isValidDescriptor = exports.arrayToDescriptor = exports.descriptorToArray = exports.findAllMatches = exports.findMatch = exports.compareFaces = exports.extractDescriptor = exports.detectAllFaces = exports.detectFace = exports.isLoaded = exports.loadModels = void 0;
6
9
  // Core face-api service
7
10
  var face_api_1 = require("./lib/face-api");
8
11
  Object.defineProperty(exports, "loadModels", { enumerable: true, get: function () { return face_api_1.loadModels; } });
@@ -26,3 +29,11 @@ var useCamera_1 = require("./hooks/useCamera");
26
29
  Object.defineProperty(exports, "useCamera", { enumerable: true, get: function () { return useCamera_1.useCamera; } });
27
30
  var useFaceDetection_1 = require("./hooks/useFaceDetection");
28
31
  Object.defineProperty(exports, "useFaceDetection", { enumerable: true, get: function () { return useFaceDetection_1.useFaceDetection; } });
32
+ // API route factories
33
+ var recognize_route_1 = require("./api/recognize.route");
34
+ Object.defineProperty(exports, "createRecognizeHandler", { enumerable: true, get: function () { return recognize_route_1.createRecognizeHandler; } });
35
+ var detect_route_1 = require("./api/detect.route");
36
+ Object.defineProperty(exports, "createDetectHandler", { enumerable: true, get: function () { return detect_route_1.createDetectHandler; } });
37
+ // Components
38
+ var FaceDetector_1 = require("./components/FaceDetector");
39
+ Object.defineProperty(exports, "FaceDetector", { enumerable: true, get: function () { return __importDefault(FaceDetector_1).default; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mostajs/face",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Reusable face recognition module — detection, descriptor extraction, 1:N matching",
5
5
  "author": "Dr Hamid MADANI <drmdh@msn.com>",
6
6
  "license": "MIT",
@@ -24,6 +24,41 @@
24
24
  "import": "./dist/hooks/useFaceDetection.js",
25
25
  "require": "./dist/hooks/useFaceDetection.js",
26
26
  "default": "./dist/hooks/useFaceDetection.js"
27
+ },
28
+ "./components/FaceDetector": {
29
+ "types": "./dist/components/FaceDetector.d.ts",
30
+ "import": "./dist/components/FaceDetector.js",
31
+ "default": "./dist/components/FaceDetector.js"
32
+ },
33
+ "./lib/face-api": {
34
+ "types": "./dist/lib/face-api.d.ts",
35
+ "import": "./dist/lib/face-api.js",
36
+ "default": "./dist/lib/face-api.js"
37
+ },
38
+ "./lib/face-matcher": {
39
+ "types": "./dist/lib/face-matcher.d.ts",
40
+ "import": "./dist/lib/face-matcher.js",
41
+ "default": "./dist/lib/face-matcher.js"
42
+ },
43
+ "./lib/face-utils": {
44
+ "types": "./dist/lib/face-utils.d.ts",
45
+ "import": "./dist/lib/face-utils.js",
46
+ "default": "./dist/lib/face-utils.js"
47
+ },
48
+ "./api/recognize.route": {
49
+ "types": "./dist/api/recognize.route.d.ts",
50
+ "import": "./dist/api/recognize.route.js",
51
+ "default": "./dist/api/recognize.route.js"
52
+ },
53
+ "./api/detect.route": {
54
+ "types": "./dist/api/detect.route.d.ts",
55
+ "import": "./dist/api/detect.route.js",
56
+ "default": "./dist/api/detect.route.js"
57
+ },
58
+ "./types": {
59
+ "types": "./dist/types/index.d.ts",
60
+ "import": "./dist/types/index.js",
61
+ "default": "./dist/types/index.js"
27
62
  }
28
63
  },
29
64
  "files": ["dist", "LICENSE", "README.md"],