update auto detect face
This commit is contained in:
parent
25918fcb62
commit
a23f2155dc
|
|
@ -1,4 +1,4 @@
|
||||||
Run client: npm run dev or npm run build && npm run preview
|
Run client: cd client && npm run dev or npm run build && npm run preview
|
||||||
|
|
||||||
==> Build client xong => coppy file asset và index vào folder static của server => thêm prefix static vào link của assets trong file index VD: /camera/static/assets
|
==> Build client xong => coppy file asset và index vào folder static của server => thêm prefix static vào link của assets trong file index VD: /camera/static/assets
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"face-api.js": "^0.22.2",
|
||||||
"framer-motion": "^12.23.25",
|
"framer-motion": "^12.23.25",
|
||||||
"lucide-react": "^0.556.0",
|
"lucide-react": "^0.556.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
|
|
@ -2445,6 +2446,22 @@
|
||||||
"vite": "^5.2.0 || ^6 || ^7"
|
"vite": "^5.2.0 || ^6 || ^7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tensorflow/tfjs-core": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-core/-/tfjs-core-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-uwQdiklNjqBnHPeseOdG0sGxrI3+d6lybaKu2+ou3ajVeKdPEwpWbgqA6iHjq1iylnOGkgkbbnQ6r2lwkiIIHw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/offscreencanvas": "~2019.3.0",
|
||||||
|
"@types/seedrandom": "2.4.27",
|
||||||
|
"@types/webgl-ext": "0.0.30",
|
||||||
|
"@types/webgl2": "0.0.4",
|
||||||
|
"node-fetch": "~2.1.2",
|
||||||
|
"seedrandom": "2.4.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"yarn": ">= 1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
|
|
@ -2513,6 +2530,11 @@
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/offscreencanvas": {
|
||||||
|
"version": "2019.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.3.0.tgz",
|
||||||
|
"integrity": "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q=="
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.7",
|
"version": "19.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||||
|
|
@ -2533,6 +2555,21 @@
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/seedrandom": {
|
||||||
|
"version": "2.4.27",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-2.4.27.tgz",
|
||||||
|
"integrity": "sha512-YvMLqFak/7rt//lPBtEHv3M4sRNA+HGxrhFZ+DQs9K2IkYJbNwVIb8avtJfhDiuaUBX/AW0jnjv48FV8h3u9bQ=="
|
||||||
|
},
|
||||||
|
"node_modules/@types/webgl-ext": {
|
||||||
|
"version": "0.0.30",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz",
|
||||||
|
"integrity": "sha512-LKVgNmBxN0BbljJrVUwkxwRYqzsAEPcZOe6S2T6ZaBDIrFp0qu4FNlpc5sM1tGbXUYFgdVQIoeLk1Y1UoblyEg=="
|
||||||
|
},
|
||||||
|
"node_modules/@types/webgl2": {
|
||||||
|
"version": "0.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/webgl2/-/webgl2-0.0.4.tgz",
|
||||||
|
"integrity": "sha512-PACt1xdErJbMUOUweSrbVM7gSIYm1vTncW2hF6Os/EeWi6TXYAYMPp+8v6rzHmypE5gHrxaxZNXgMkJVIdZpHw=="
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.49.0",
|
"version": "8.49.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz",
|
||||||
|
|
@ -3513,6 +3550,20 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/face-api.js": {
|
||||||
|
"version": "0.22.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/face-api.js/-/face-api.js-0.22.2.tgz",
|
||||||
|
"integrity": "sha512-9Bbv/yaBRTKCXjiDqzryeKhYxmgSjJ7ukvOvEBy6krA0Ah/vNBlsf7iBNfJljWiPA8Tys1/MnB3lyP2Hfmsuyw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@tensorflow/tfjs-core": "1.7.0",
|
||||||
|
"tslib": "^1.11.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/face-api.js/node_modules/tslib": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
|
@ -4430,6 +4481,14 @@
|
||||||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-IHLHYskTc2arMYsHZH82PVX8CSKT5lzb7AXeyO06QnjGDKtkv+pv3mEki6S7reB/x1QPo+YPxQRNEVgR5V/w3Q==",
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.27",
|
"version": "2.0.27",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||||
|
|
@ -4765,6 +4824,11 @@
|
||||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/seedrandom": {
|
||||||
|
"version": "2.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-2.4.3.tgz",
|
||||||
|
"integrity": "sha512-2CkZ9Wn2dS4mMUWQaXLsOAfGD+irMlLEeSP3cMxpGbgyOOzJGFa+MWCOMTOCMyZinHRPxyOj/S/C57li/1to6Q=="
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"face-api.js": "^0.22.2",
|
||||||
"framer-motion": "^12.23.25",
|
"framer-motion": "^12.23.25",
|
||||||
"lucide-react": "^0.556.0",
|
"lucide-react": "^0.556.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
[{"weights":[{"name":"conv0/filters","shape":[3,3,3,16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.009007044399485869,"min":-1.2069439495311063}},{"name":"conv0/bias","shape":[16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005263455241334205,"min":-0.9211046672334858}},{"name":"conv1/depthwise_filter","shape":[3,3,16,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.004001977630690033,"min":-0.5042491814669441}},{"name":"conv1/pointwise_filter","shape":[1,1,16,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.013836609615999109,"min":-1.411334180831909}},{"name":"conv1/bias","shape":[32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0015159862590771096,"min":-0.30926119685173037}},{"name":"conv2/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002666276225856706,"min":-0.317286870876948}},{"name":"conv2/pointwise_filter","shape":[1,1,32,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.015265831292844286,"min":-1.6792414422128714}},{"name":"conv2/bias","shape":[64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0020280554598453,"min":-0.37113414915168985}},{"name":"conv3/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006100742489683862,"min":-0.8907084034938438}},{"name":"conv3/pointwise_filter","shape":[1,1,64,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.016276211832083907,"min":-2.0508026908425725}},{"name":"conv3/bias","shape":[128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.003394414279975143,"min":-0.7637432129944072}},{"name":"conv4/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006716050119961009,"min":-0.8059260143953211}},{"name":"conv4/pointwise_filter","shape":[1,1,128,256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.021875603993733724,"min":-2.8875797271728514}},{"name":"conv4/bias","shape":[256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0041141652009066415,"min":-0.8187188749804216}},{"name":"conv5/depthwise_filter","shape":[3,3,256,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008423839597141042,"min":-0.9013508368940915}},{"name":"conv5/pointwise_filter","shape":[1,1,256,512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.030007277283014035,"min":-3.8709387695088107}},{"name":"conv5/bias","shape":[512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008402082966823203,"min":-1.4871686851277068}},{"name":"conv8/filters","shape":[1,1,512,25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.028336129469030042,"min":-4.675461362389957}},{"name":"conv8/bias","shape":[25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002268134028303857,"min":-0.41053225912299807}}],"paths":["tiny_face_detector_model-shard1"]}]
|
||||||
|
|
@ -0,0 +1,388 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||||
|
|
||||||
|
export type FaceZoomTransform = {
|
||||||
|
scale: number;
|
||||||
|
translateX: number; // percent of element width
|
||||||
|
translateY: number; // percent of element height
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FaceBox = {
|
||||||
|
/** All values are ratios (0..1) of the source video frame. */
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FaceZoomResult = {
|
||||||
|
transform: FaceZoomTransform;
|
||||||
|
box: FaceBox | null;
|
||||||
|
/** Detector confidence (0..1). */
|
||||||
|
confidence: number;
|
||||||
|
/** 0..1 fill progress of the stable-face timer. Updates at 60fps. */
|
||||||
|
stabilityProgress: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const IDLE_TRANSFORM: FaceZoomTransform = {
|
||||||
|
scale: 1,
|
||||||
|
translateX: 0,
|
||||||
|
translateY: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
enabled?: boolean;
|
||||||
|
intervalMs?: number;
|
||||||
|
targetFaceRatio?: number;
|
||||||
|
minScale?: number;
|
||||||
|
maxScale?: number;
|
||||||
|
/** Per-frame lerp factor at 60fps (0..1). Higher = snappier. */
|
||||||
|
followSpeed?: number;
|
||||||
|
/** EMA weight on each new detection (0..1). Lower = smoother target. */
|
||||||
|
detectionSmoothing?: number;
|
||||||
|
detectorInputSize?: number;
|
||||||
|
detectorScoreThreshold?: number;
|
||||||
|
|
||||||
|
// --- Stable-face auto trigger ---
|
||||||
|
/** Enable the stable-face callback. */
|
||||||
|
stabilityEnabled?: boolean;
|
||||||
|
/** Continuous duration (ms) the same face must stay before firing. */
|
||||||
|
stableMs?: number;
|
||||||
|
/** Max center movement (frame ratio) to still count as same face. */
|
||||||
|
stabilityPositionThreshold?: number;
|
||||||
|
/** Max relative face-size change to still count as same face. */
|
||||||
|
stabilitySizeThreshold?: number;
|
||||||
|
/** Fires once when stability duration is met; re-arms on absence/new person. */
|
||||||
|
onStableFace?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FilteredSample = {
|
||||||
|
fx: number; // face center x ratio
|
||||||
|
fy: number; // face center y ratio
|
||||||
|
fh: number; // face height ratio
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useFaceZoom(
|
||||||
|
videoRef: RefObject<HTMLVideoElement | null>,
|
||||||
|
{
|
||||||
|
enabled = true,
|
||||||
|
intervalMs = 120,
|
||||||
|
targetFaceRatio = 0.6,
|
||||||
|
minScale = 1.35,
|
||||||
|
maxScale = 4,
|
||||||
|
followSpeed = 0.11,
|
||||||
|
detectionSmoothing = 0.35,
|
||||||
|
detectorInputSize = 416,
|
||||||
|
detectorScoreThreshold = 0.3,
|
||||||
|
stabilityEnabled = false,
|
||||||
|
stableMs = 2000,
|
||||||
|
stabilityPositionThreshold = 0.12,
|
||||||
|
stabilitySizeThreshold = 0.4,
|
||||||
|
onStableFace,
|
||||||
|
}: Options = {}
|
||||||
|
): FaceZoomResult {
|
||||||
|
const [transform, setTransform] = useState<FaceZoomTransform>(IDLE_TRANSFORM);
|
||||||
|
const [box, setBox] = useState<FaceBox | null>(null);
|
||||||
|
const [confidence, setConfidence] = useState(0);
|
||||||
|
const [stabilityProgress, setStabilityProgress] = useState(0);
|
||||||
|
const stabilityProgressRef = useRef(0);
|
||||||
|
const currentRef = useRef<FaceZoomTransform>(IDLE_TRANSFORM);
|
||||||
|
const targetRef = useRef<FaceZoomTransform>(IDLE_TRANSFORM);
|
||||||
|
const filteredRef = useRef<FilteredSample | null>(null);
|
||||||
|
const filteredBoxRef = useRef<FaceBox | null>(null);
|
||||||
|
const filteredConfRef = useRef(0);
|
||||||
|
const missCountRef = useRef(0);
|
||||||
|
|
||||||
|
// Stability state — kept in refs so they survive ticks without re-rendering.
|
||||||
|
const stableSinceRef = useRef<number | null>(null);
|
||||||
|
const stableFiredRef = useRef(false);
|
||||||
|
const stablePrevRef = useRef<FilteredSample | null>(null);
|
||||||
|
// Latest stability config (so updating it doesn't tear down the detection loop).
|
||||||
|
const stabilityCfgRef = useRef({
|
||||||
|
enabled: stabilityEnabled,
|
||||||
|
stableMs,
|
||||||
|
positionThreshold: stabilityPositionThreshold,
|
||||||
|
sizeThreshold: stabilitySizeThreshold,
|
||||||
|
onStableFace,
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
stabilityCfgRef.current = {
|
||||||
|
enabled: stabilityEnabled,
|
||||||
|
stableMs,
|
||||||
|
positionThreshold: stabilityPositionThreshold,
|
||||||
|
sizeThreshold: stabilitySizeThreshold,
|
||||||
|
onStableFace,
|
||||||
|
};
|
||||||
|
// When disabling, also clear pending state so it re-arms cleanly next time.
|
||||||
|
if (!stabilityEnabled) {
|
||||||
|
stableSinceRef.current = null;
|
||||||
|
stableFiredRef.current = false;
|
||||||
|
stablePrevRef.current = null;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
stabilityEnabled,
|
||||||
|
stableMs,
|
||||||
|
stabilityPositionThreshold,
|
||||||
|
stabilitySizeThreshold,
|
||||||
|
onStableFace,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
currentRef.current = IDLE_TRANSFORM;
|
||||||
|
targetRef.current = IDLE_TRANSFORM;
|
||||||
|
filteredRef.current = null;
|
||||||
|
filteredBoxRef.current = null;
|
||||||
|
filteredConfRef.current = 0;
|
||||||
|
setTransform(IDLE_TRANSFORM);
|
||||||
|
setBox(null);
|
||||||
|
setConfidence(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let intervalId: number | null = null;
|
||||||
|
let rafId: number | null = null;
|
||||||
|
let lastTs = 0;
|
||||||
|
let faceapi: any = null;
|
||||||
|
let detectorOptions: any = null;
|
||||||
|
let running = false;
|
||||||
|
|
||||||
|
const animate = (ts: number) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const dt = lastTs ? (ts - lastTs) / 1000 : 1 / 60;
|
||||||
|
lastTs = ts;
|
||||||
|
// Frame-rate-aware lerp: stays consistent at 60/120Hz.
|
||||||
|
const k = 1 - Math.pow(1 - followSpeed, dt * 60);
|
||||||
|
|
||||||
|
const cur = currentRef.current;
|
||||||
|
const tgt = targetRef.current;
|
||||||
|
const next: FaceZoomTransform = {
|
||||||
|
scale: cur.scale + (tgt.scale - cur.scale) * k,
|
||||||
|
translateX: cur.translateX + (tgt.translateX - cur.translateX) * k,
|
||||||
|
translateY: cur.translateY + (tgt.translateY - cur.translateY) * k,
|
||||||
|
};
|
||||||
|
|
||||||
|
currentRef.current = next;
|
||||||
|
|
||||||
|
const dScale = Math.abs(next.scale - tgt.scale);
|
||||||
|
const dTx = Math.abs(next.translateX - tgt.translateX);
|
||||||
|
const dTy = Math.abs(next.translateY - tgt.translateY);
|
||||||
|
// Skip re-render when essentially settled — avoids React churn.
|
||||||
|
if (dScale > 0.0005 || dTx > 0.02 || dTy > 0.02) {
|
||||||
|
setTransform(next);
|
||||||
|
} else if (
|
||||||
|
next.scale !== tgt.scale ||
|
||||||
|
next.translateX !== tgt.translateX ||
|
||||||
|
next.translateY !== tgt.translateY
|
||||||
|
) {
|
||||||
|
currentRef.current = tgt;
|
||||||
|
setTransform(tgt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth 60fps fill of the stability progress (independent of the
|
||||||
|
// ~110ms detector tick → no stair-step in the progress bar).
|
||||||
|
const cfg = stabilityCfgRef.current;
|
||||||
|
let nextProgress = 0;
|
||||||
|
if (cfg.enabled) {
|
||||||
|
if (stableFiredRef.current) {
|
||||||
|
nextProgress = 1;
|
||||||
|
} else if (stableSinceRef.current !== null && cfg.stableMs > 0) {
|
||||||
|
nextProgress = Math.min(
|
||||||
|
1,
|
||||||
|
(ts - stableSinceRef.current) / cfg.stableMs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Math.abs(nextProgress - stabilityProgressRef.current) > 0.003) {
|
||||||
|
stabilityProgressRef.current = nextProgress;
|
||||||
|
setStabilityProgress(nextProgress);
|
||||||
|
} else if (
|
||||||
|
(nextProgress === 0 || nextProgress === 1) &&
|
||||||
|
stabilityProgressRef.current !== nextProgress
|
||||||
|
) {
|
||||||
|
// Snap to exact endpoints so the bar fully clears / fills.
|
||||||
|
stabilityProgressRef.current = nextProgress;
|
||||||
|
setStabilityProgress(nextProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
rafId = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
if (running) return;
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || video.readyState < 2 || !video.videoWidth) return;
|
||||||
|
running = true;
|
||||||
|
try {
|
||||||
|
const detection = await faceapi.detectSingleFace(video, detectorOptions);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (!detection) {
|
||||||
|
missCountRef.current += 1;
|
||||||
|
// Stability resets quickly so the timer truly restarts on absence.
|
||||||
|
if (missCountRef.current >= 2) {
|
||||||
|
stableSinceRef.current = null;
|
||||||
|
stableFiredRef.current = false;
|
||||||
|
stablePrevRef.current = null;
|
||||||
|
}
|
||||||
|
// Hold zoom position longer to avoid jarring zoom-out on brief misses.
|
||||||
|
if (missCountRef.current >= 25) {
|
||||||
|
targetRef.current = IDLE_TRANSFORM;
|
||||||
|
filteredRef.current = null;
|
||||||
|
filteredBoxRef.current = null;
|
||||||
|
filteredConfRef.current = 0;
|
||||||
|
setBox(null);
|
||||||
|
setConfidence(0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
missCountRef.current = 0;
|
||||||
|
|
||||||
|
const { x, y, width, height } = detection.box;
|
||||||
|
const score = detection.score ?? detection.classScore ?? 0;
|
||||||
|
const vw = video.videoWidth;
|
||||||
|
const vh = video.videoHeight;
|
||||||
|
if (!vw || !vh) return;
|
||||||
|
|
||||||
|
const sample: FilteredSample = {
|
||||||
|
fx: (x + width / 2) / vw,
|
||||||
|
fy: (y + height / 2) / vh,
|
||||||
|
fh: height / vh,
|
||||||
|
};
|
||||||
|
const sampleBox: FaceBox = {
|
||||||
|
x: x / vw,
|
||||||
|
y: y / vh,
|
||||||
|
width: width / vw,
|
||||||
|
height: height / vh,
|
||||||
|
};
|
||||||
|
|
||||||
|
const a = detectionSmoothing;
|
||||||
|
const prev = filteredRef.current;
|
||||||
|
const filt: FilteredSample = prev
|
||||||
|
? {
|
||||||
|
fx: prev.fx + (sample.fx - prev.fx) * a,
|
||||||
|
fy: prev.fy + (sample.fy - prev.fy) * a,
|
||||||
|
fh: prev.fh + (sample.fh - prev.fh) * a,
|
||||||
|
}
|
||||||
|
: sample;
|
||||||
|
filteredRef.current = filt;
|
||||||
|
|
||||||
|
const prevBox = filteredBoxRef.current;
|
||||||
|
const filtBox: FaceBox = prevBox
|
||||||
|
? {
|
||||||
|
x: prevBox.x + (sampleBox.x - prevBox.x) * a,
|
||||||
|
y: prevBox.y + (sampleBox.y - prevBox.y) * a,
|
||||||
|
width: prevBox.width + (sampleBox.width - prevBox.width) * a,
|
||||||
|
height: prevBox.height + (sampleBox.height - prevBox.height) * a,
|
||||||
|
}
|
||||||
|
: sampleBox;
|
||||||
|
filteredBoxRef.current = filtBox;
|
||||||
|
setBox(filtBox);
|
||||||
|
|
||||||
|
const filtConf =
|
||||||
|
filteredConfRef.current + (score - filteredConfRef.current) * a;
|
||||||
|
filteredConfRef.current = filtConf;
|
||||||
|
setConfidence(filtConf);
|
||||||
|
|
||||||
|
// --- Stable-face tracking (uses RAW sample so a new person trips it
|
||||||
|
// immediately, before EMA can drift toward the new position). ---
|
||||||
|
const cfg = stabilityCfgRef.current;
|
||||||
|
if (cfg.enabled) {
|
||||||
|
const prevS = stablePrevRef.current;
|
||||||
|
const now = performance.now();
|
||||||
|
let sameFace = false;
|
||||||
|
if (prevS) {
|
||||||
|
const dx = Math.abs(sample.fx - prevS.fx);
|
||||||
|
const dy = Math.abs(sample.fy - prevS.fy);
|
||||||
|
const dh =
|
||||||
|
Math.abs(sample.fh - prevS.fh) / Math.max(prevS.fh, 0.01);
|
||||||
|
sameFace =
|
||||||
|
dx < cfg.positionThreshold &&
|
||||||
|
dy < cfg.positionThreshold &&
|
||||||
|
dh < cfg.sizeThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sameFace) {
|
||||||
|
// First detection after absence OR a different person → restart timer.
|
||||||
|
stableSinceRef.current = now;
|
||||||
|
stableFiredRef.current = false;
|
||||||
|
} else if (
|
||||||
|
!stableFiredRef.current &&
|
||||||
|
stableSinceRef.current !== null &&
|
||||||
|
now - stableSinceRef.current >= cfg.stableMs
|
||||||
|
) {
|
||||||
|
stableFiredRef.current = true;
|
||||||
|
try {
|
||||||
|
cfg.onStableFace?.();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("onStableFace handler threw", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stablePrevRef.current = sample;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scale = targetFaceRatio / Math.max(filt.fh, 0.05);
|
||||||
|
scale = Math.max(minScale, Math.min(scale, maxScale));
|
||||||
|
|
||||||
|
const halfViewX = 0.5 / scale;
|
||||||
|
const halfViewY = 0.5 / scale;
|
||||||
|
const fx = Math.min(1 - halfViewX, Math.max(halfViewX, filt.fx));
|
||||||
|
const fy = Math.min(1 - halfViewY, Math.max(halfViewY, filt.fy));
|
||||||
|
|
||||||
|
targetRef.current = {
|
||||||
|
scale,
|
||||||
|
translateX: (0.5 - fx * scale) * 100,
|
||||||
|
translateY: (0.5 - fy * scale) * 100,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.debug("face detect error", err);
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
faceapi = await import("face-api.js");
|
||||||
|
const modelUrl = `${import.meta.env.BASE_URL}models`.replace(
|
||||||
|
/\/\/+/g,
|
||||||
|
"/"
|
||||||
|
);
|
||||||
|
if (!faceapi.nets.tinyFaceDetector.isLoaded) {
|
||||||
|
await faceapi.nets.tinyFaceDetector.loadFromUri(modelUrl);
|
||||||
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
detectorOptions = new faceapi.TinyFaceDetectorOptions({
|
||||||
|
inputSize: detectorInputSize,
|
||||||
|
scoreThreshold: detectorScoreThreshold,
|
||||||
|
});
|
||||||
|
intervalId = window.setInterval(tick, intervalMs);
|
||||||
|
rafId = requestAnimationFrame(animate);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[useFaceZoom] failed to load face-api models", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (intervalId !== null) clearInterval(intervalId);
|
||||||
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
videoRef,
|
||||||
|
enabled,
|
||||||
|
intervalMs,
|
||||||
|
targetFaceRatio,
|
||||||
|
minScale,
|
||||||
|
maxScale,
|
||||||
|
followSpeed,
|
||||||
|
detectionSmoothing,
|
||||||
|
detectorInputSize,
|
||||||
|
detectorScoreThreshold,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { transform, box, confidence, stabilityProgress };
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { clsx, type ClassValue } from "clsx";
|
import { clsx, type ClassValue } from "clsx";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import useAppStore from "@/stores/use-app-store";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
|
|
@ -18,12 +19,50 @@ export function capture(videoRef: any, canvasRef: any) {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
const context = canvas.getContext("2d");
|
const context = canvas.getContext("2d");
|
||||||
|
|
||||||
|
const vw: number = video.videoWidth;
|
||||||
|
const vh: number = video.videoHeight;
|
||||||
|
|
||||||
|
// Crop to the same region the user sees on screen (zoomed/focused view).
|
||||||
|
// The video element has transform: translate(tx%, ty%) scale(s) with
|
||||||
|
// transform-origin (0,0). The visible source region in video coords is:
|
||||||
|
// srcX = (-tx/100)/s * vw
|
||||||
|
// srcY = (-ty/100)/s * vh
|
||||||
|
// srcW = vw / s
|
||||||
|
// srcH = vh / s
|
||||||
|
let srcX = 0;
|
||||||
|
let srcY = 0;
|
||||||
|
let srcW = vw;
|
||||||
|
let srcH = vh;
|
||||||
|
|
||||||
|
const t = useAppStore.getState().faceZoomTransform;
|
||||||
|
if (t && t.scale > 1.0001) {
|
||||||
|
const s = t.scale;
|
||||||
|
srcW = vw / s;
|
||||||
|
srcH = vh / s;
|
||||||
|
srcX = (-t.translateX / 100 / s) * vw;
|
||||||
|
srcY = (-t.translateY / 100 / s) * vh;
|
||||||
|
// Clamp inside the source frame (the on-screen clamp already prevents
|
||||||
|
// empty edges, but rounding can drift a sub-pixel out of range).
|
||||||
|
srcX = Math.max(0, Math.min(vw - srcW, srcX));
|
||||||
|
srcY = Math.max(0, Math.min(vh - srcH, srcY));
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
canvas.width = video.videoWidth;
|
canvas.width = Math.round(srcW);
|
||||||
canvas.height = video.videoHeight;
|
canvas.height = Math.round(srcH);
|
||||||
|
|
||||||
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
context.drawImage(
|
||||||
|
video,
|
||||||
|
srcX,
|
||||||
|
srcY,
|
||||||
|
srcW,
|
||||||
|
srcH,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
canvas.width,
|
||||||
|
canvas.height
|
||||||
|
);
|
||||||
|
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
(blob: unknown) => {
|
(blob: unknown) => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
import type { FaceBox, FaceZoomTransform } from "@/lib/use-face-zoom";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
box: FaceBox | null;
|
||||||
|
transform: FaceZoomTransform;
|
||||||
|
/** 0..1 stability fill — drives the progress bar above the brackets. */
|
||||||
|
progress: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders 4 AF-style corner brackets around the detected face, plus a thin
|
||||||
|
* progress bar above them that fills as the same face remains in view.
|
||||||
|
*
|
||||||
|
* Coordinates: `box` is in source-video ratios (0..1). The video element on
|
||||||
|
* screen has a zoom transform applied. We re-apply the same `translate + scale`
|
||||||
|
* to the bracket position so it stays glued to the face — but place it OUTSIDE
|
||||||
|
* the transformed element so border width does not scale with zoom.
|
||||||
|
*/
|
||||||
|
export default function FaceBracket({ box, transform, progress }: Props) {
|
||||||
|
if (!box) return null;
|
||||||
|
|
||||||
|
const s = transform.scale;
|
||||||
|
const tx = transform.translateX / 100;
|
||||||
|
const ty = transform.translateY / 100;
|
||||||
|
|
||||||
|
// Apply the same transform as the video (origin 0,0): point' = point * s + t
|
||||||
|
const left = box.x * s + tx;
|
||||||
|
const top = box.y * s + ty;
|
||||||
|
const width = box.width * s;
|
||||||
|
const height = box.height * s;
|
||||||
|
|
||||||
|
if (
|
||||||
|
left + width < -0.05 ||
|
||||||
|
top + height < -0.05 ||
|
||||||
|
left > 1.05 ||
|
||||||
|
top > 1.05 ||
|
||||||
|
width <= 0 ||
|
||||||
|
height <= 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const armPx = Math.max(14, Math.min(40, width * 1000 * 0.18));
|
||||||
|
const cornerSize = { width: armPx, height: armPx } as const;
|
||||||
|
|
||||||
|
const cornerBase =
|
||||||
|
"absolute border-emerald-400/90 transition-opacity duration-150";
|
||||||
|
|
||||||
|
const clamped = Math.max(0, Math.min(1, progress));
|
||||||
|
const isFull = clamped >= 0.999;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute pointer-events-none"
|
||||||
|
style={{
|
||||||
|
left: `${left * 100}%`,
|
||||||
|
top: `${top * 100}%`,
|
||||||
|
width: `${width * 100}%`,
|
||||||
|
height: `${height * 100}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${cornerBase} border-t-[3px] border-l-[3px] rounded-tl-md`}
|
||||||
|
style={{ ...cornerSize, left: 0, top: 0 }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`${cornerBase} border-t-[3px] border-r-[3px] rounded-tr-md`}
|
||||||
|
style={{ ...cornerSize, right: 0, top: 0 }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`${cornerBase} border-b-[3px] border-l-[3px] rounded-bl-md`}
|
||||||
|
style={{ ...cornerSize, left: 0, bottom: 0 }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`${cornerBase} border-b-[3px] border-r-[3px] rounded-br-md`}
|
||||||
|
style={{ ...cornerSize, right: 0, bottom: 0 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Stability progress bar — sits above the box. Width is driven by the
|
||||||
|
60fps `progress` value from the hook, so no CSS transition is needed
|
||||||
|
(and a transition would actually fight the rAF updates). */}
|
||||||
|
<div className="absolute left-0 right-0 -top-3 h-1.5 rounded-full bg-white/25 overflow-hidden backdrop-blur-sm">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${
|
||||||
|
isFull
|
||||||
|
? "bg-emerald-300 shadow-[0_0_12px_rgba(110,231,183,0.9)]"
|
||||||
|
: "bg-emerald-400"
|
||||||
|
}`}
|
||||||
|
style={{ width: `${clamped * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -19,27 +19,17 @@ export default function TabFeatures() {
|
||||||
|
|
||||||
const { currentUser, setCurrentUser } = useUserStore();
|
const { currentUser, setCurrentUser } = useUserStore();
|
||||||
|
|
||||||
const { isAutoChecking, setIsAutoChecking, setRefreshLog } = useAppStore();
|
const { isAutoChecking, setIsAutoChecking, setRefreshLog, autoCheckinTick } =
|
||||||
|
useAppStore();
|
||||||
const autoCheckIntervalRef = useRef<any>(null);
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const [checkPoinLoading, setCheckPoinLoading] = useState(false);
|
const [checkPoinLoading, setCheckPoinLoading] = useState(false);
|
||||||
|
|
||||||
const toggleAutoCheck = () => {
|
const toggleAutoCheck = () => {
|
||||||
if (isAutoChecking) {
|
// Auto mode is now driven by stable-face detection in <Main> — toggling
|
||||||
if (autoCheckIntervalRef.current) {
|
// this flag enables/disables the 2s presence timer + auto fire.
|
||||||
clearInterval(autoCheckIntervalRef.current);
|
setIsAutoChecking(!isAutoChecking);
|
||||||
autoCheckIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
setIsAutoChecking(false);
|
|
||||||
} else {
|
|
||||||
autoCheckIntervalRef.current = setInterval(() => {
|
|
||||||
captureAndCheck();
|
|
||||||
}, 3000);
|
|
||||||
setIsAutoChecking(true);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const createCheckpoint = async () => {
|
const createCheckpoint = async () => {
|
||||||
|
|
@ -120,6 +110,18 @@ export default function TabFeatures() {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Stable-face auto trigger: Main bumps autoCheckinTick when a face has been
|
||||||
|
// present for 2s, and we fire the same checkin path used by the manual
|
||||||
|
// button. Skip the initial 0 tick on mount.
|
||||||
|
const lastHandledTick = useRef(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoCheckinTick === 0) return;
|
||||||
|
if (autoCheckinTick === lastHandledTick.current) return;
|
||||||
|
lastHandledTick.current = autoCheckinTick;
|
||||||
|
if (loading) return;
|
||||||
|
captureAndCheck();
|
||||||
|
}, [autoCheckinTick, captureAndCheck, loading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const down = (e: KeyboardEvent) => {
|
const down = (e: KeyboardEvent) => {
|
||||||
if (e.code === "Space") {
|
if (e.code === "Space") {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,14 @@ import { checkingApi } from "@/api/checking-api";
|
||||||
import { msApi } from "@/api/ms-api";
|
import { msApi } from "@/api/ms-api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { useFaceZoom } from "@/lib/use-face-zoom";
|
||||||
import { capture, cn } from "@/lib/utils";
|
import { capture, cn } from "@/lib/utils";
|
||||||
import useAppStore from "@/stores/use-app-store";
|
import useAppStore from "@/stores/use-app-store";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import CountDown from "./components/count-down";
|
import CountDown from "./components/count-down";
|
||||||
|
import FaceBracket from "./components/face-bracket";
|
||||||
import LeftSlidebar from "./components/left-slidebar";
|
import LeftSlidebar from "./components/left-slidebar";
|
||||||
import RightSlidebar from "./components/right-slidebar";
|
import RightSlidebar from "./components/right-slidebar";
|
||||||
import TabFeatures from "./components/tab-features";
|
import TabFeatures from "./components/tab-features";
|
||||||
|
|
@ -20,13 +23,47 @@ export default function Main() {
|
||||||
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false);
|
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false);
|
||||||
// const { currentUser, setCurrentUser } = useUserStore();
|
// const { currentUser, setCurrentUser } = useUserStore();
|
||||||
|
|
||||||
const { setCanvasRef, setVideoRef } = useAppStore();
|
const {
|
||||||
|
setCanvasRef,
|
||||||
|
setVideoRef,
|
||||||
|
setFaceZoomTransform,
|
||||||
|
isAutoChecking,
|
||||||
|
bumpAutoCheckinTick,
|
||||||
|
} = useAppStore();
|
||||||
const { isCountDown, setCaptureRegisterImage, setIsCountDown } =
|
const { isCountDown, setCaptureRegisterImage, setIsCountDown } =
|
||||||
useAppStore();
|
useAppStore();
|
||||||
|
|
||||||
|
const [flashKey, setFlashKey] = useState(0);
|
||||||
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
const onStableFace = useCallback(() => {
|
||||||
|
// Fire the same path as the "Điểm Danh Ngay" button via the store trigger,
|
||||||
|
// and play a camera-shutter flash to acknowledge the capture.
|
||||||
|
setFlashKey((k) => k + 1);
|
||||||
|
bumpAutoCheckinTick();
|
||||||
|
}, [bumpAutoCheckinTick]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
transform: faceZoom,
|
||||||
|
box: faceBox,
|
||||||
|
stabilityProgress: faceStability,
|
||||||
|
} = useFaceZoom(videoRef, {
|
||||||
|
enabled: true,
|
||||||
|
intervalMs: 110,
|
||||||
|
targetFaceRatio: 0.6,
|
||||||
|
minScale: 1.35,
|
||||||
|
maxScale: 4,
|
||||||
|
followSpeed: 0.11,
|
||||||
|
detectionSmoothing: 0.3,
|
||||||
|
detectorInputSize: 416,
|
||||||
|
detectorScoreThreshold: 0.3,
|
||||||
|
stabilityEnabled: isAutoChecking,
|
||||||
|
stableMs: 2000,
|
||||||
|
onStableFace,
|
||||||
|
});
|
||||||
|
|
||||||
const sync = async () => {
|
const sync = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await msApi.timekeepings();
|
const { data } = await msApi.timekeepings();
|
||||||
|
|
@ -88,6 +125,12 @@ export default function Main() {
|
||||||
setVideoRef(videoRef);
|
setVideoRef(videoRef);
|
||||||
}, [videoRef, canvasRef]);
|
}, [videoRef, canvasRef]);
|
||||||
|
|
||||||
|
// Keep store in sync with the live zoom so capture() can crop to the
|
||||||
|
// visible focused region instead of the full frame.
|
||||||
|
useEffect(() => {
|
||||||
|
setFaceZoomTransform(faceZoom);
|
||||||
|
}, [faceZoom, setFaceZoomTransform]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-white">
|
||||||
<div className="flex h-screen">
|
<div className="flex h-screen">
|
||||||
|
|
@ -107,10 +150,34 @@ export default function Main() {
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
autoPlay
|
autoPlay
|
||||||
playsInline
|
playsInline
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover will-change-transform"
|
||||||
|
style={{
|
||||||
|
transformOrigin: "0 0",
|
||||||
|
transform: `translate3d(${faceZoom.translateX}%, ${faceZoom.translateY}%, 0) scale(${faceZoom.scale})`,
|
||||||
|
backfaceVisibility: "hidden",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 border-4 border-blue-500/30 pointer-events-none" />
|
<div className="absolute inset-0 border-4 border-blue-500/30 pointer-events-none" />
|
||||||
|
|
||||||
|
<FaceBracket
|
||||||
|
box={faceBox}
|
||||||
|
transform={faceZoom}
|
||||||
|
progress={isAutoChecking ? faceStability : 0}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Camera shutter flash — keyed so each fire replays the animation. */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{flashKey > 0 && (
|
||||||
|
<motion.div
|
||||||
|
key={flashKey}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: [0, 0.85, 0] }}
|
||||||
|
transition={{ duration: 0.45, times: [0, 0.18, 1] }}
|
||||||
|
className="absolute inset-0 bg-white pointer-events-none z-40"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* <AnimatePresence>
|
{/* <AnimatePresence>
|
||||||
{currentUser && (
|
{currentUser && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
// src/stores/useUserStore.ts
|
// src/stores/useUserStore.ts
|
||||||
|
import type { FaceZoomTransform } from "@/lib/use-face-zoom";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
|
||||||
type AppState = {
|
type AppState = {
|
||||||
|
|
@ -9,6 +10,9 @@ type AppState = {
|
||||||
captureRegisterImage: any;
|
captureRegisterImage: any;
|
||||||
videoRef: any;
|
videoRef: any;
|
||||||
canvasRef: any;
|
canvasRef: any;
|
||||||
|
faceZoomTransform: FaceZoomTransform | null;
|
||||||
|
/** Incrementing trigger — bumped when the stable-face timer fires. */
|
||||||
|
autoCheckinTick: number;
|
||||||
|
|
||||||
// actions
|
// actions
|
||||||
setIsAutoChecking: (data: boolean) => void;
|
setIsAutoChecking: (data: boolean) => void;
|
||||||
|
|
@ -17,6 +21,8 @@ type AppState = {
|
||||||
setVideoRef: (data: any) => void;
|
setVideoRef: (data: any) => void;
|
||||||
setCanvasRef: (data: any) => void;
|
setCanvasRef: (data: any) => void;
|
||||||
setCaptureRegisterImage: (data: any) => void;
|
setCaptureRegisterImage: (data: any) => void;
|
||||||
|
setFaceZoomTransform: (data: FaceZoomTransform | null) => void;
|
||||||
|
bumpAutoCheckinTick: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useAppStore = create<AppState>((set) => ({
|
const useAppStore = create<AppState>((set) => ({
|
||||||
|
|
@ -26,6 +32,8 @@ const useAppStore = create<AppState>((set) => ({
|
||||||
canvasRef: null,
|
canvasRef: null,
|
||||||
videoRef: null,
|
videoRef: null,
|
||||||
refreshLog: false,
|
refreshLog: false,
|
||||||
|
faceZoomTransform: null,
|
||||||
|
autoCheckinTick: 0,
|
||||||
|
|
||||||
setIsAutoChecking: (data) => set({ isAutoChecking: data }),
|
setIsAutoChecking: (data) => set({ isAutoChecking: data }),
|
||||||
setRefreshLog: (data) => set({ refreshLog: data }),
|
setRefreshLog: (data) => set({ refreshLog: data }),
|
||||||
|
|
@ -33,6 +41,9 @@ const useAppStore = create<AppState>((set) => ({
|
||||||
setCaptureRegisterImage: (data) => set({ captureRegisterImage: data }),
|
setCaptureRegisterImage: (data) => set({ captureRegisterImage: data }),
|
||||||
setVideoRef: (data) => set({ videoRef: data }),
|
setVideoRef: (data) => set({ videoRef: data }),
|
||||||
setCanvasRef: (data) => set({ canvasRef: data }),
|
setCanvasRef: (data) => set({ canvasRef: data }),
|
||||||
|
setFaceZoomTransform: (data) => set({ faceZoomTransform: data }),
|
||||||
|
bumpAutoCheckinTick: () =>
|
||||||
|
set((s) => ({ autoCheckinTick: s.autoCheckinTick + 1 })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export default useAppStore;
|
export default useAppStore;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "school-checkin",
|
"name": "TrackingToolWeb",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {}
|
"packages": {}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue