update auto detect face

This commit is contained in:
Joseph 2026-05-20 15:22:24 +07:00
parent 25918fcb62
commit a23f2155dc
12 changed files with 690 additions and 23 deletions

View File

@ -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

View File

@ -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",

View File

@ -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",

View File

@ -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"]}]

View File

@ -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 };
}

View File

@ -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) => {

View File

@ -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>
);
}

View File

@ -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") {

View File

@ -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

View File

@ -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;

View File

@ -1,5 +1,5 @@
{ {
"name": "school-checkin", "name": "TrackingToolWeb",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": {} "packages": {}