alphavalid-sdk 0.0.4 → 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.
@@ -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 video;
3
- start(options: any): Promise<void>;
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
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,qBAAa,UAAU;IACrB,OAAO,CAAC,KAAK,CAAoB;IAE3B,KAAK,CAAC,OAAO,EAAE,GAAG;IAalB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;CAY/B"}
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,3 @@
1
+ export declare function ensureHTMLElement(el: unknown, name: string): asserts el is HTMLElement;
2
+ export declare function cleanupElement(el: HTMLElement | null | undefined): void;
3
+ //# sourceMappingURL=dom.d.ts.map
@@ -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.4",
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 video!: HTMLVideoElement;
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
- async start(options: any) {
5
- const stream = await navigator.mediaDevices.getUserMedia({
6
- video: { facingMode: "user" }
7
- });
36
+ this._camera = await startUserCamera(this._container);
8
37
 
9
- this.video = document.createElement("video");
10
- this.video.srcObject = stream;
11
- this.video.autoplay = true;
12
- this.video.playsInline = true;
38
+ if (this._options.overlay !== false) {
39
+ this._overlay = createOverlay(this._container, this._options.guideCircleRatio);
40
+ }
13
41
 
14
- options.container.appendChild(this.video);
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
- const canvas = document.createElement("canvas");
19
- canvas.width = this.video.videoWidth;
20
- canvas.height = this.video.videoHeight;
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
- const ctx = canvas.getContext("2d");
23
- ctx?.drawImage(this.video, 0, 0);
98
+ try {
99
+ const status = await this._faceDetector.detect(camera.video);
100
+ const fb = computeFeedback(status);
101
+ this._lastStatusValid = fb.valid;
24
102
 
25
- return new Promise((resolve) => {
26
- canvas.toBlob((blob) => resolve(blob!), "image/jpeg");
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';
@@ -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
+ }
@@ -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
+ }