alphavalid-sdk 0.0.5 → 0.0.6
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/alphavalid.es.js +18017 -9
- package/dist/alphavalid.umd.js +3804 -1
- package/dist/core/camera.d.ts +8 -0
- package/dist/core/camera.d.ts.map +1 -0
- package/dist/core/feedback.d.ts +7 -0
- package/dist/core/feedback.d.ts.map +1 -0
- package/dist/index.d.ts +13 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/types/sdk.d.ts +29 -0
- package/dist/types/sdk.d.ts.map +1 -0
- package/dist/ui/overlay.d.ts +16 -0
- package/dist/ui/overlay.d.ts.map +1 -0
- package/dist/utils/dom.d.ts +3 -0
- package/dist/utils/dom.d.ts.map +1 -0
- package/dist/vision/faceDetector.d.ts +7 -0
- package/dist/vision/faceDetector.d.ts.map +1 -0
- package/package.json +6 -2
- package/src/core/camera.ts +69 -0
- package/src/core/feedback.ts +37 -0
- package/src/index.ts +110 -19
- package/src/types/sdk.ts +40 -0
- package/src/ui/overlay.ts +93 -0
- package/src/utils/dom.ts +14 -0
- package/src/vision/faceDetector.ts +49 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface CameraHandle {
|
|
2
|
+
video: HTMLVideoElement;
|
|
3
|
+
stream: MediaStream;
|
|
4
|
+
stop: () => void;
|
|
5
|
+
}
|
|
6
|
+
export declare function startUserCamera(container: HTMLElement): Promise<CameraHandle>;
|
|
7
|
+
export declare function captureVideoFrameToJpegBlob(video: HTMLVideoElement, quality?: number): Promise<Blob>;
|
|
8
|
+
//# sourceMappingURL=camera.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"camera.d.ts","sourceRoot":"","sources":["../../src/core/camera.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,gBAAgB,CAAC;IACxB,MAAM,EAAE,WAAW,CAAC;IACpB,IAAI,EAAE,MAAM,IAAI,CAAC;CAClB;AAED,wBAAsB,eAAe,CAAC,SAAS,EAAE,WAAW,GAAG,OAAO,CAAC,YAAY,CAAC,CA0CnF;AAED,wBAAsB,2BAA2B,CAAC,KAAK,EAAE,gBAAgB,EAAE,OAAO,SAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAkBvG"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AlphaValidFeedbackMessage, FaceDetectionStatus } from '../types/sdk';
|
|
2
|
+
export interface FeedbackResult {
|
|
3
|
+
message: AlphaValidFeedbackMessage;
|
|
4
|
+
valid: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function computeFeedback(status: FaceDetectionStatus): FeedbackResult;
|
|
7
|
+
//# sourceMappingURL=feedback.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"feedback.d.ts","sourceRoot":"","sources":["../../src/core/feedback.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,yBAAyB,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAEnF,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,yBAAyB,CAAC;IACnC,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,mBAAmB,GAAG,cAAc,CA6B3E"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
|
+
import type { AlphaValidStartOptions } from './types/sdk';
|
|
1
2
|
export declare class AlphaValid {
|
|
2
|
-
private
|
|
3
|
-
|
|
3
|
+
private _camera;
|
|
4
|
+
private _overlay;
|
|
5
|
+
private _container;
|
|
6
|
+
private _faceDetector;
|
|
7
|
+
private _loopTimer;
|
|
8
|
+
private _lastStatusValid;
|
|
9
|
+
private _options;
|
|
10
|
+
start(options: AlphaValidStartOptions): Promise<void>;
|
|
11
|
+
/** Stop camera + overlay + detection loop */
|
|
12
|
+
stop(): Promise<void>;
|
|
4
13
|
capture(): Promise<Blob>;
|
|
14
|
+
private startDetectionLoop;
|
|
5
15
|
}
|
|
16
|
+
export type { AlphaValidStartOptions } from './types/sdk';
|
|
6
17
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,qBAAa,UAAU;IACrB,OAAO,CAAC,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AAO1D,qBAAa,UAAU;IACrB,OAAO,CAAC,OAAO,CAA6B;IAC5C,OAAO,CAAC,QAAQ,CAA8B;IAC9C,OAAO,CAAC,UAAU,CAA4B;IAC9C,OAAO,CAAC,aAAa,CAAsC;IAC3D,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,gBAAgB,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAuC;IAEjD,KAAK,CAAC,OAAO,EAAE,sBAAsB;IAwC3C,6CAA6C;IACvC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAsBrB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAW9B,OAAO,CAAC,kBAAkB;CA2B3B;AAED,YAAY,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type AlphaValidFeedbackMessage = 'Inicializando câmera...' | 'Centralize seu rosto' | 'Aproxime o rosto' | 'Afaste o rosto' | 'Ajuste a iluminação' | 'Apenas 1 rosto por vez' | 'Rosto detectado' | 'Pronto para capturar';
|
|
2
|
+
export interface AlphaValidStartOptions {
|
|
3
|
+
container: HTMLElement;
|
|
4
|
+
/** Called when the camera stream is attached and video can play. */
|
|
5
|
+
onReady?: () => void;
|
|
6
|
+
/** Called frequently as face detection runs, to guide the user. */
|
|
7
|
+
onFeedback?: (message: AlphaValidFeedbackMessage) => void;
|
|
8
|
+
/** Called on errors (permission denied, no camera, etc). */
|
|
9
|
+
onError?: (error: unknown) => void;
|
|
10
|
+
/** Optional: enable/disable overlay guide (default true). */
|
|
11
|
+
overlay?: boolean;
|
|
12
|
+
/** Optional: circle size ratio (0-1). Default 0.72 of the smallest side. */
|
|
13
|
+
guideCircleRatio?: number;
|
|
14
|
+
/** How often to run face detection (ms). Default 200. */
|
|
15
|
+
detectionIntervalMs?: number;
|
|
16
|
+
/** Provide face-api models base URL (must contain /tiny_face_detector_model-weights_manifest.json etc). */
|
|
17
|
+
modelsPath?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface FaceDetectionStatus {
|
|
20
|
+
faces: number;
|
|
21
|
+
/** Face box normalized [0..1] relative to video square viewport. */
|
|
22
|
+
box?: {
|
|
23
|
+
x: number;
|
|
24
|
+
y: number;
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=sdk.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sdk.d.ts","sourceRoot":"","sources":["../../src/types/sdk.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,yBAAyB,GACjC,yBAAyB,GACzB,sBAAsB,GACtB,kBAAkB,GAClB,gBAAgB,GAChB,qBAAqB,GACrB,wBAAwB,GACxB,iBAAiB,GACjB,sBAAsB,CAAC;AAE3B,MAAM,WAAW,sBAAsB;IACrC,SAAS,EAAE,WAAW,CAAC;IAEvB,oEAAoE;IACpE,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IAErB,mEAAmE;IACnE,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,yBAAyB,KAAK,IAAI,CAAC;IAE1D,4DAA4D;IAC5D,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IAEnC,6DAA6D;IAC7D,OAAO,CAAC,EAAE,OAAO,CAAC;IAElB,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAE1B,yDAAyD;IACzD,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAE7B,2GAA2G;IAC3G,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,oEAAoE;IACpE,GAAG,CAAC,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;CAC/D"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface OverlayHandle {
|
|
2
|
+
canvas: HTMLCanvasElement;
|
|
3
|
+
dispose: () => void;
|
|
4
|
+
render: (status: {
|
|
5
|
+
message?: string;
|
|
6
|
+
box?: {
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
};
|
|
12
|
+
valid?: boolean;
|
|
13
|
+
}) => void;
|
|
14
|
+
}
|
|
15
|
+
export declare function createOverlay(container: HTMLElement, guideCircleRatio?: number): OverlayHandle;
|
|
16
|
+
//# sourceMappingURL=overlay.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"overlay.d.ts","sourceRoot":"","sources":["../../src/ui/overlay.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,iBAAiB,CAAC;IAC1B,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,MAAM,EAAE,CAAC,MAAM,EAAE;QACf,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,GAAG,CAAC,EAAE;YAAE,CAAC,EAAE,MAAM,CAAC;YAAC,CAAC,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,MAAM,CAAC;YAAC,MAAM,EAAE,MAAM,CAAA;SAAE,CAAC;QAC9D,KAAK,CAAC,EAAE,OAAO,CAAC;KACjB,KAAK,IAAI,CAAC;CACZ;AAED,wBAAgB,aAAa,CAAC,SAAS,EAAE,WAAW,EAAE,gBAAgB,SAAO,GAAG,aAAa,CAkF5F"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dom.d.ts","sourceRoot":"","sources":["../../src/utils/dom.ts"],"names":[],"mappings":"AAAA,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,EAAE,IAAI,WAAW,CAItF;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,WAAW,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,CAOvE"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { FaceDetectionStatus } from '../types/sdk';
|
|
2
|
+
export interface FaceDetector {
|
|
3
|
+
load: (modelsPath: string) => Promise<void>;
|
|
4
|
+
detect: (video: HTMLVideoElement) => Promise<FaceDetectionStatus>;
|
|
5
|
+
}
|
|
6
|
+
export declare function createFaceDetector(): FaceDetector;
|
|
7
|
+
//# sourceMappingURL=faceDetector.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"faceDetector.d.ts","sourceRoot":"","sources":["../../src/vision/faceDetector.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAExD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,MAAM,EAAE,CAAC,KAAK,EAAE,gBAAgB,KAAK,OAAO,CAAC,mBAAmB,CAAC,CAAC;CACnE;AAED,wBAAgB,kBAAkB,IAAI,YAAY,CAwCjD"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "alphavalid-sdk",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"description": "SDK de validação facial e liveness",
|
|
5
5
|
"main": "dist/alphavalid.umd.js",
|
|
6
6
|
"module": "dist/alphavalid.es.js",
|
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
"scripts": {
|
|
9
9
|
"build": "npx vite build",
|
|
10
10
|
"types": "tsc -p tsconfig.json",
|
|
11
|
-
"build:all": "rm -rf dist && npm run build && npm run types"
|
|
11
|
+
"build:all": "rm -rf dist && npm run build && npm run types",
|
|
12
|
+
"test:local": "vite preview"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"face-api.js": "^0.22.2"
|
|
12
16
|
},
|
|
13
17
|
"devDependencies": {
|
|
14
18
|
"@types/node": "^25.5.0",
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export interface CameraHandle {
|
|
2
|
+
video: HTMLVideoElement;
|
|
3
|
+
stream: MediaStream;
|
|
4
|
+
stop: () => void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function startUserCamera(container: HTMLElement): Promise<CameraHandle> {
|
|
8
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
9
|
+
video: { facingMode: 'user' }
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const video = document.createElement('video');
|
|
13
|
+
video.autoplay = true;
|
|
14
|
+
video.playsInline = true;
|
|
15
|
+
video.muted = true;
|
|
16
|
+
video.srcObject = stream;
|
|
17
|
+
|
|
18
|
+
// Make it fill container; user can override via CSS
|
|
19
|
+
video.style.width = '100%';
|
|
20
|
+
video.style.height = '100%';
|
|
21
|
+
video.style.objectFit = 'cover';
|
|
22
|
+
|
|
23
|
+
container.appendChild(video);
|
|
24
|
+
|
|
25
|
+
await new Promise<void>((resolve, reject) => {
|
|
26
|
+
const onLoaded = () => {
|
|
27
|
+
video.removeEventListener('loadedmetadata', onLoaded);
|
|
28
|
+
resolve();
|
|
29
|
+
};
|
|
30
|
+
const onError = () => {
|
|
31
|
+
video.removeEventListener('error', onError);
|
|
32
|
+
reject(new Error('Failed to load camera stream into video element'));
|
|
33
|
+
};
|
|
34
|
+
video.addEventListener('loadedmetadata', onLoaded);
|
|
35
|
+
video.addEventListener('error', onError);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const stop = () => {
|
|
39
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
40
|
+
try {
|
|
41
|
+
(video as HTMLVideoElement & { srcObject: MediaStream | null }).srcObject = null;
|
|
42
|
+
} catch {
|
|
43
|
+
// ignore
|
|
44
|
+
}
|
|
45
|
+
if (video.parentElement) video.parentElement.removeChild(video);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return { video, stream, stop };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function captureVideoFrameToJpegBlob(video: HTMLVideoElement, quality = 0.9): Promise<Blob> {
|
|
52
|
+
const canvas = document.createElement('canvas');
|
|
53
|
+
canvas.width = video.videoWidth;
|
|
54
|
+
canvas.height = video.videoHeight;
|
|
55
|
+
|
|
56
|
+
const ctx = canvas.getContext('2d');
|
|
57
|
+
ctx?.drawImage(video, 0, 0);
|
|
58
|
+
|
|
59
|
+
return await new Promise<Blob>((resolve, reject) => {
|
|
60
|
+
canvas.toBlob(
|
|
61
|
+
(blob) => {
|
|
62
|
+
if (!blob) return reject(new Error('Failed to capture image blob'));
|
|
63
|
+
resolve(blob);
|
|
64
|
+
},
|
|
65
|
+
'image/jpeg',
|
|
66
|
+
quality
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AlphaValidFeedbackMessage, FaceDetectionStatus } from '../types/sdk';
|
|
2
|
+
|
|
3
|
+
export interface FeedbackResult {
|
|
4
|
+
message: AlphaValidFeedbackMessage;
|
|
5
|
+
valid: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function computeFeedback(status: FaceDetectionStatus): FeedbackResult {
|
|
9
|
+
if (status.faces === 0) {
|
|
10
|
+
return { message: 'Centralize seu rosto', valid: false };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (status.faces > 1) {
|
|
14
|
+
return { message: 'Apenas 1 rosto por vez', valid: false };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!status.box) {
|
|
18
|
+
return { message: 'Centralize seu rosto', valid: false };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Positioning rules (simple heuristics)
|
|
22
|
+
const { x, y, width, height } = status.box;
|
|
23
|
+
const cx = x + width / 2;
|
|
24
|
+
const cy = y + height / 2;
|
|
25
|
+
|
|
26
|
+
// Size thresholds (normalized)
|
|
27
|
+
const faceArea = width * height;
|
|
28
|
+
if (faceArea < 0.07) return { message: 'Aproxime o rosto', valid: false };
|
|
29
|
+
if (faceArea > 0.22) return { message: 'Afaste o rosto', valid: false };
|
|
30
|
+
|
|
31
|
+
// Centering thresholds
|
|
32
|
+
if (Math.abs(cx - 0.5) > 0.12 || Math.abs(cy - 0.5) > 0.12) {
|
|
33
|
+
return { message: 'Centralize seu rosto', valid: false };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { message: 'Pronto para capturar', valid: true };
|
|
37
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,29 +1,120 @@
|
|
|
1
|
+
import type { AlphaValidStartOptions } from './types/sdk';
|
|
2
|
+
import { ensureHTMLElement } from './utils/dom';
|
|
3
|
+
import { startUserCamera, captureVideoFrameToJpegBlob, type CameraHandle } from './core/camera';
|
|
4
|
+
import { createOverlay, type OverlayHandle } from './ui/overlay';
|
|
5
|
+
import { createFaceDetector, type FaceDetector } from './vision/faceDetector';
|
|
6
|
+
import { computeFeedback } from './core/feedback';
|
|
7
|
+
|
|
1
8
|
export class AlphaValid {
|
|
2
|
-
private
|
|
9
|
+
private _camera: CameraHandle | null = null;
|
|
10
|
+
private _overlay: OverlayHandle | null = null;
|
|
11
|
+
private _container: HTMLElement | null = null;
|
|
12
|
+
private _faceDetector: FaceDetector = createFaceDetector();
|
|
13
|
+
private _loopTimer: number | null = null;
|
|
14
|
+
private _lastStatusValid = false;
|
|
15
|
+
private _options: AlphaValidStartOptions | null = null;
|
|
16
|
+
|
|
17
|
+
async start(options: AlphaValidStartOptions) {
|
|
18
|
+
ensureHTMLElement(options.container, 'options.container');
|
|
19
|
+
|
|
20
|
+
// Stop any existing session
|
|
21
|
+
await this.stop();
|
|
22
|
+
|
|
23
|
+
this._options = {
|
|
24
|
+
overlay: true,
|
|
25
|
+
guideCircleRatio: 0.72,
|
|
26
|
+
detectionIntervalMs: 200,
|
|
27
|
+
modelsPath: '/models',
|
|
28
|
+
...options
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
this._container = this._options.container;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
this._options.onFeedback?.('Inicializando câmera...');
|
|
3
35
|
|
|
4
|
-
|
|
5
|
-
const stream = await navigator.mediaDevices.getUserMedia({
|
|
6
|
-
video: { facingMode: "user" }
|
|
7
|
-
});
|
|
36
|
+
this._camera = await startUserCamera(this._container);
|
|
8
37
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
this.video.playsInline = true;
|
|
38
|
+
if (this._options.overlay !== false) {
|
|
39
|
+
this._overlay = createOverlay(this._container, this._options.guideCircleRatio);
|
|
40
|
+
}
|
|
13
41
|
|
|
14
|
-
|
|
42
|
+
this._options.onReady?.();
|
|
43
|
+
|
|
44
|
+
// Face-api models (user must host them)
|
|
45
|
+
if (this._options.modelsPath) {
|
|
46
|
+
await this._faceDetector.load(this._options.modelsPath);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
this.startDetectionLoop();
|
|
50
|
+
} catch (err) {
|
|
51
|
+
this._options?.onError?.(err);
|
|
52
|
+
await this.stop();
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Stop camera + overlay + detection loop */
|
|
58
|
+
async stop(): Promise<void> {
|
|
59
|
+
if (this._loopTimer != null) {
|
|
60
|
+
window.clearTimeout(this._loopTimer);
|
|
61
|
+
this._loopTimer = null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this._lastStatusValid = false;
|
|
65
|
+
|
|
66
|
+
if (this._overlay) {
|
|
67
|
+
this._overlay.dispose();
|
|
68
|
+
this._overlay = null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (this._camera) {
|
|
72
|
+
this._camera.stop();
|
|
73
|
+
this._camera = null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
this._container = null;
|
|
77
|
+
this._options = null;
|
|
15
78
|
}
|
|
16
79
|
|
|
17
80
|
async capture(): Promise<Blob> {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
81
|
+
if (!this._camera) throw new Error('Camera not started. Call start() first.');
|
|
82
|
+
|
|
83
|
+
// Optional: enforce valid pose before capture
|
|
84
|
+
if (!this._lastStatusValid) {
|
|
85
|
+
throw new Error('Face not valid for capture yet. Wait for feedback "Pronto para capturar".');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return await captureVideoFrameToJpegBlob(this._camera.video, 0.9);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private startDetectionLoop(): void {
|
|
92
|
+
const tick = async () => {
|
|
93
|
+
const camera = this._camera;
|
|
94
|
+
const options = this._options;
|
|
95
|
+
|
|
96
|
+
if (!camera || !options) return;
|
|
21
97
|
|
|
22
|
-
|
|
23
|
-
|
|
98
|
+
try {
|
|
99
|
+
const status = await this._faceDetector.detect(camera.video);
|
|
100
|
+
const fb = computeFeedback(status);
|
|
101
|
+
this._lastStatusValid = fb.valid;
|
|
24
102
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
103
|
+
options.onFeedback?.(fb.message);
|
|
104
|
+
this._overlay?.render({
|
|
105
|
+
message: fb.message,
|
|
106
|
+
box: status.box,
|
|
107
|
+
valid: fb.valid
|
|
108
|
+
});
|
|
109
|
+
} catch (err) {
|
|
110
|
+
options.onError?.(err);
|
|
111
|
+
} finally {
|
|
112
|
+
this._loopTimer = window.setTimeout(tick, options.detectionIntervalMs ?? 200);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
void tick();
|
|
28
117
|
}
|
|
29
|
-
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type { AlphaValidStartOptions } from './types/sdk';
|
package/src/types/sdk.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type AlphaValidFeedbackMessage =
|
|
2
|
+
| 'Inicializando câmera...'
|
|
3
|
+
| 'Centralize seu rosto'
|
|
4
|
+
| 'Aproxime o rosto'
|
|
5
|
+
| 'Afaste o rosto'
|
|
6
|
+
| 'Ajuste a iluminação'
|
|
7
|
+
| 'Apenas 1 rosto por vez'
|
|
8
|
+
| 'Rosto detectado'
|
|
9
|
+
| 'Pronto para capturar';
|
|
10
|
+
|
|
11
|
+
export interface AlphaValidStartOptions {
|
|
12
|
+
container: HTMLElement;
|
|
13
|
+
|
|
14
|
+
/** Called when the camera stream is attached and video can play. */
|
|
15
|
+
onReady?: () => void;
|
|
16
|
+
|
|
17
|
+
/** Called frequently as face detection runs, to guide the user. */
|
|
18
|
+
onFeedback?: (message: AlphaValidFeedbackMessage) => void;
|
|
19
|
+
|
|
20
|
+
/** Called on errors (permission denied, no camera, etc). */
|
|
21
|
+
onError?: (error: unknown) => void;
|
|
22
|
+
|
|
23
|
+
/** Optional: enable/disable overlay guide (default true). */
|
|
24
|
+
overlay?: boolean;
|
|
25
|
+
|
|
26
|
+
/** Optional: circle size ratio (0-1). Default 0.72 of the smallest side. */
|
|
27
|
+
guideCircleRatio?: number;
|
|
28
|
+
|
|
29
|
+
/** How often to run face detection (ms). Default 200. */
|
|
30
|
+
detectionIntervalMs?: number;
|
|
31
|
+
|
|
32
|
+
/** Provide face-api models base URL (must contain /tiny_face_detector_model-weights_manifest.json etc). */
|
|
33
|
+
modelsPath?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface FaceDetectionStatus {
|
|
37
|
+
faces: number;
|
|
38
|
+
/** Face box normalized [0..1] relative to video square viewport. */
|
|
39
|
+
box?: { x: number; y: number; width: number; height: number };
|
|
40
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export interface OverlayHandle {
|
|
2
|
+
canvas: HTMLCanvasElement;
|
|
3
|
+
dispose: () => void;
|
|
4
|
+
render: (status: {
|
|
5
|
+
message?: string;
|
|
6
|
+
box?: { x: number; y: number; width: number; height: number };
|
|
7
|
+
valid?: boolean;
|
|
8
|
+
}) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createOverlay(container: HTMLElement, guideCircleRatio = 0.72): OverlayHandle {
|
|
12
|
+
const canvas = document.createElement('canvas');
|
|
13
|
+
const ctx = canvas.getContext('2d');
|
|
14
|
+
|
|
15
|
+
canvas.style.position = 'absolute';
|
|
16
|
+
canvas.style.left = '0';
|
|
17
|
+
canvas.style.top = '0';
|
|
18
|
+
canvas.style.width = '100%';
|
|
19
|
+
canvas.style.height = '100%';
|
|
20
|
+
canvas.style.pointerEvents = 'none';
|
|
21
|
+
|
|
22
|
+
const prevPos = getComputedStyle(container).position;
|
|
23
|
+
if (prevPos === 'static') {
|
|
24
|
+
container.style.position = 'relative';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
container.appendChild(canvas);
|
|
28
|
+
|
|
29
|
+
const resizeToContainer = () => {
|
|
30
|
+
const rect = container.getBoundingClientRect();
|
|
31
|
+
canvas.width = Math.max(1, Math.round(rect.width));
|
|
32
|
+
canvas.height = Math.max(1, Math.round(rect.height));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const draw = (status: Parameters<OverlayHandle['render']>[0]) => {
|
|
36
|
+
if (!ctx) return;
|
|
37
|
+
resizeToContainer();
|
|
38
|
+
|
|
39
|
+
const w = canvas.width;
|
|
40
|
+
const h = canvas.height;
|
|
41
|
+
|
|
42
|
+
ctx.clearRect(0, 0, w, h);
|
|
43
|
+
|
|
44
|
+
// Dark backdrop
|
|
45
|
+
ctx.fillStyle = 'rgba(0,0,0,0.45)';
|
|
46
|
+
ctx.fillRect(0, 0, w, h);
|
|
47
|
+
|
|
48
|
+
// "Hole" circle
|
|
49
|
+
const r = Math.min(w, h) * guideCircleRatio * 0.5;
|
|
50
|
+
const cx = w / 2;
|
|
51
|
+
const cy = h / 2;
|
|
52
|
+
|
|
53
|
+
ctx.globalCompositeOperation = 'destination-out';
|
|
54
|
+
ctx.beginPath();
|
|
55
|
+
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
56
|
+
ctx.fill();
|
|
57
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
58
|
+
|
|
59
|
+
// Circle stroke
|
|
60
|
+
ctx.lineWidth = 4;
|
|
61
|
+
ctx.strokeStyle = status.valid ? 'rgba(46, 204, 113, 0.95)' : 'rgba(255,255,255,0.9)';
|
|
62
|
+
ctx.beginPath();
|
|
63
|
+
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
64
|
+
ctx.stroke();
|
|
65
|
+
|
|
66
|
+
// Face box (optional, normalized)
|
|
67
|
+
if (status.box) {
|
|
68
|
+
ctx.lineWidth = 2;
|
|
69
|
+
ctx.strokeStyle = 'rgba(52, 152, 219, 0.9)';
|
|
70
|
+
ctx.strokeRect(status.box.x * w, status.box.y * h, status.box.width * w, status.box.height * h);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Text
|
|
74
|
+
if (status.message) {
|
|
75
|
+
ctx.fillStyle = 'rgba(0,0,0,0.6)';
|
|
76
|
+
const pad = 10;
|
|
77
|
+
const boxH = 44;
|
|
78
|
+
ctx.fillRect(0, h - boxH, w, boxH);
|
|
79
|
+
|
|
80
|
+
ctx.fillStyle = 'white';
|
|
81
|
+
ctx.font = '16px system-ui, -apple-system, Segoe UI, Roboto, Arial';
|
|
82
|
+
ctx.textAlign = 'center';
|
|
83
|
+
ctx.textBaseline = 'middle';
|
|
84
|
+
ctx.fillText(status.message, w / 2, h - boxH / 2);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const dispose = () => {
|
|
89
|
+
if (canvas.parentElement) canvas.parentElement.removeChild(canvas);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return { canvas, dispose, render: draw };
|
|
93
|
+
}
|
package/src/utils/dom.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function ensureHTMLElement(el: unknown, name: string): asserts el is HTMLElement {
|
|
2
|
+
if (!(el instanceof HTMLElement)) {
|
|
3
|
+
throw new Error(`${name} must be an HTMLElement`);
|
|
4
|
+
}
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function cleanupElement(el: HTMLElement | null | undefined): void {
|
|
8
|
+
if (!el) return;
|
|
9
|
+
try {
|
|
10
|
+
el.remove();
|
|
11
|
+
} catch {
|
|
12
|
+
// ignore
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as faceapi from 'face-api.js';
|
|
2
|
+
import type { FaceDetectionStatus } from '../types/sdk';
|
|
3
|
+
|
|
4
|
+
export interface FaceDetector {
|
|
5
|
+
load: (modelsPath: string) => Promise<void>;
|
|
6
|
+
detect: (video: HTMLVideoElement) => Promise<FaceDetectionStatus>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createFaceDetector(): FaceDetector {
|
|
10
|
+
let loaded = false;
|
|
11
|
+
|
|
12
|
+
const load = async (modelsPath: string) => {
|
|
13
|
+
if (loaded) return;
|
|
14
|
+
|
|
15
|
+
// tinyFaceDetector is the lightest for MVP.
|
|
16
|
+
await faceapi.nets.tinyFaceDetector.loadFromUri(modelsPath);
|
|
17
|
+
loaded = true;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const detect = async (video: HTMLVideoElement): Promise<FaceDetectionStatus> => {
|
|
21
|
+
if (!loaded) {
|
|
22
|
+
return { faces: 0 };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const result = await faceapi.detectAllFaces(
|
|
26
|
+
video,
|
|
27
|
+
new faceapi.TinyFaceDetectorOptions({ inputSize: 224, scoreThreshold: 0.5 })
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
if (!result || result.length === 0) return { faces: 0 };
|
|
31
|
+
if (result.length > 1) return { faces: result.length };
|
|
32
|
+
|
|
33
|
+
const box = result[0].box;
|
|
34
|
+
const vw = video.videoWidth || 1;
|
|
35
|
+
const vh = video.videoHeight || 1;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
faces: 1,
|
|
39
|
+
box: {
|
|
40
|
+
x: box.x / vw,
|
|
41
|
+
y: box.y / vh,
|
|
42
|
+
width: box.width / vw,
|
|
43
|
+
height: box.height / vh
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return { load, detect };
|
|
49
|
+
}
|