diff --git a/TrackingToolWeb/README.md b/TrackingToolWeb/README.md index 716eedd..6d2fbb1 100644 --- a/TrackingToolWeb/README.md +++ b/TrackingToolWeb/README.md @@ -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 diff --git a/TrackingToolWeb/client/package-lock.json b/TrackingToolWeb/client/package-lock.json index 2b8319c..1186a2c 100644 --- a/TrackingToolWeb/client/package-lock.json +++ b/TrackingToolWeb/client/package-lock.json @@ -20,6 +20,7 @@ "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "face-api.js": "^0.22.2", "framer-motion": "^12.23.25", "lucide-react": "^0.556.0", "moment": "^2.30.1", @@ -2445,6 +2446,22 @@ "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": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2513,6 +2530,11 @@ "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": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", @@ -2533,6 +2555,21 @@ "@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": { "version": "8.49.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", @@ -3513,6 +3550,20 @@ "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": { "version": "3.1.3", "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" } }, + "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": { "version": "2.0.27", "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==", "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": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", diff --git a/TrackingToolWeb/client/package.json b/TrackingToolWeb/client/package.json index 1893b02..4fc80bf 100644 --- a/TrackingToolWeb/client/package.json +++ b/TrackingToolWeb/client/package.json @@ -22,6 +22,7 @@ "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "face-api.js": "^0.22.2", "framer-motion": "^12.23.25", "lucide-react": "^0.556.0", "moment": "^2.30.1", diff --git a/TrackingToolWeb/client/public/models/tiny_face_detector_model-shard1 b/TrackingToolWeb/client/public/models/tiny_face_detector_model-shard1 new file mode 100644 index 0000000..a3f113a Binary files /dev/null and b/TrackingToolWeb/client/public/models/tiny_face_detector_model-shard1 differ diff --git a/TrackingToolWeb/client/public/models/tiny_face_detector_model-weights_manifest.json b/TrackingToolWeb/client/public/models/tiny_face_detector_model-weights_manifest.json new file mode 100644 index 0000000..7d3b222 --- /dev/null +++ b/TrackingToolWeb/client/public/models/tiny_face_detector_model-weights_manifest.json @@ -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"]}] \ No newline at end of file diff --git a/TrackingToolWeb/client/src/lib/use-face-zoom.ts b/TrackingToolWeb/client/src/lib/use-face-zoom.ts new file mode 100644 index 0000000..747e5eb --- /dev/null +++ b/TrackingToolWeb/client/src/lib/use-face-zoom.ts @@ -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, + { + 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(IDLE_TRANSFORM); + const [box, setBox] = useState(null); + const [confidence, setConfidence] = useState(0); + const [stabilityProgress, setStabilityProgress] = useState(0); + const stabilityProgressRef = useRef(0); + const currentRef = useRef(IDLE_TRANSFORM); + const targetRef = useRef(IDLE_TRANSFORM); + const filteredRef = useRef(null); + const filteredBoxRef = useRef(null); + const filteredConfRef = useRef(0); + const missCountRef = useRef(0); + + // Stability state — kept in refs so they survive ticks without re-rendering. + const stableSinceRef = useRef(null); + const stableFiredRef = useRef(false); + const stablePrevRef = useRef(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 }; +} diff --git a/TrackingToolWeb/client/src/lib/utils.ts b/TrackingToolWeb/client/src/lib/utils.ts index 666c57f..ea9d478 100644 --- a/TrackingToolWeb/client/src/lib/utils.ts +++ b/TrackingToolWeb/client/src/lib/utils.ts @@ -2,6 +2,7 @@ import { clsx, type ClassValue } from "clsx"; import moment from "moment"; import { twMerge } from "tailwind-merge"; +import useAppStore from "@/stores/use-app-store"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -18,12 +19,50 @@ export function capture(videoRef: any, canvasRef: any) { const video = videoRef.current; 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) => { try { - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; + canvas.width = Math.round(srcW); + 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( (blob: unknown) => { diff --git a/TrackingToolWeb/client/src/pages/main/components/face-bracket.tsx b/TrackingToolWeb/client/src/pages/main/components/face-bracket.tsx new file mode 100644 index 0000000..28853d0 --- /dev/null +++ b/TrackingToolWeb/client/src/pages/main/components/face-bracket.tsx @@ -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 ( +
+
+
+
+
+ + {/* 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). */} +
+
+
+
+ ); +} diff --git a/TrackingToolWeb/client/src/pages/main/components/tab-features/index.tsx b/TrackingToolWeb/client/src/pages/main/components/tab-features/index.tsx index 093bb9e..9bb89da 100644 --- a/TrackingToolWeb/client/src/pages/main/components/tab-features/index.tsx +++ b/TrackingToolWeb/client/src/pages/main/components/tab-features/index.tsx @@ -19,27 +19,17 @@ export default function TabFeatures() { const { currentUser, setCurrentUser } = useUserStore(); - const { isAutoChecking, setIsAutoChecking, setRefreshLog } = useAppStore(); - - const autoCheckIntervalRef = useRef(null); + const { isAutoChecking, setIsAutoChecking, setRefreshLog, autoCheckinTick } = + useAppStore(); const [loading, setLoading] = useState(false); const [checkPoinLoading, setCheckPoinLoading] = useState(false); const toggleAutoCheck = () => { - if (isAutoChecking) { - if (autoCheckIntervalRef.current) { - clearInterval(autoCheckIntervalRef.current); - autoCheckIntervalRef.current = null; - } - setIsAutoChecking(false); - } else { - autoCheckIntervalRef.current = setInterval(() => { - captureAndCheck(); - }, 3000); - setIsAutoChecking(true); - } + // Auto mode is now driven by stable-face detection in
— toggling + // this flag enables/disables the 2s presence timer + auto fire. + setIsAutoChecking(!isAutoChecking); }; 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(() => { const down = (e: KeyboardEvent) => { if (e.code === "Space") { diff --git a/TrackingToolWeb/client/src/pages/main/index.tsx b/TrackingToolWeb/client/src/pages/main/index.tsx index c2eaf5d..212c132 100644 --- a/TrackingToolWeb/client/src/pages/main/index.tsx +++ b/TrackingToolWeb/client/src/pages/main/index.tsx @@ -6,11 +6,14 @@ import { checkingApi } from "@/api/checking-api"; import { msApi } from "@/api/ms-api"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; +import { useFaceZoom } from "@/lib/use-face-zoom"; import { capture, cn } from "@/lib/utils"; import useAppStore from "@/stores/use-app-store"; +import { AnimatePresence, motion } from "framer-motion"; 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 FaceBracket from "./components/face-bracket"; import LeftSlidebar from "./components/left-slidebar"; import RightSlidebar from "./components/right-slidebar"; import TabFeatures from "./components/tab-features"; @@ -20,13 +23,47 @@ export default function Main() { const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false); // const { currentUser, setCurrentUser } = useUserStore(); - const { setCanvasRef, setVideoRef } = useAppStore(); + const { + setCanvasRef, + setVideoRef, + setFaceZoomTransform, + isAutoChecking, + bumpAutoCheckinTick, + } = useAppStore(); const { isCountDown, setCaptureRegisterImage, setIsCountDown } = useAppStore(); + const [flashKey, setFlashKey] = useState(0); + const videoRef = useRef(null); const canvasRef = useRef(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 () => { try { const { data } = await msApi.timekeepings(); @@ -88,6 +125,12 @@ export default function Main() { setVideoRef(videoRef); }, [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 (
@@ -107,10 +150,34 @@ export default function Main() { ref={videoRef} autoPlay 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", + }} />
+ + + {/* Camera shutter flash — keyed so each fire replays the animation. */} + + {flashKey > 0 && ( + + )} + + {/* {currentUser && ( void; @@ -17,6 +21,8 @@ type AppState = { setVideoRef: (data: any) => void; setCanvasRef: (data: any) => void; setCaptureRegisterImage: (data: any) => void; + setFaceZoomTransform: (data: FaceZoomTransform | null) => void; + bumpAutoCheckinTick: () => void; }; const useAppStore = create((set) => ({ @@ -26,6 +32,8 @@ const useAppStore = create((set) => ({ canvasRef: null, videoRef: null, refreshLog: false, + faceZoomTransform: null, + autoCheckinTick: 0, setIsAutoChecking: (data) => set({ isAutoChecking: data }), setRefreshLog: (data) => set({ refreshLog: data }), @@ -33,6 +41,9 @@ const useAppStore = create((set) => ({ setCaptureRegisterImage: (data) => set({ captureRegisterImage: data }), setVideoRef: (data) => set({ videoRef: data }), setCanvasRef: (data) => set({ canvasRef: data }), + setFaceZoomTransform: (data) => set({ faceZoomTransform: data }), + bumpAutoCheckinTick: () => + set((s) => ({ autoCheckinTick: s.autoCheckinTick + 1 })), })); export default useAppStore; diff --git a/TrackingToolWeb/package-lock.json b/TrackingToolWeb/package-lock.json index 5072db3..f21ab7d 100644 --- a/TrackingToolWeb/package-lock.json +++ b/TrackingToolWeb/package-lock.json @@ -1,5 +1,5 @@ { - "name": "school-checkin", + "name": "TrackingToolWeb", "lockfileVersion": 3, "requires": true, "packages": {}