Compare commits

..

No commits in common. "master" and "zelda.fix-ui-responsive-client" have entirely different histories.

35 changed files with 142 additions and 5476 deletions

View File

@ -1 +0,0 @@
{}

View File

@ -1,4 +1,4 @@
Run client: cd client && npm run dev or npm run build && npm run preview Run 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,7 +20,6 @@
"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",
@ -2446,22 +2445,6 @@
"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",
@ -2530,11 +2513,6 @@
"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",
@ -2555,21 +2533,6 @@
"@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",
@ -3550,20 +3513,6 @@
"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",
@ -4481,14 +4430,6 @@
"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",
@ -4824,11 +4765,6 @@
"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,7 +22,6 @@
"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

@ -1 +0,0 @@
[{"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

@ -1,388 +0,0 @@
/* 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,7 +2,6 @@
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));
@ -19,50 +18,12 @@ 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 = Math.round(srcW); canvas.width = video.videoWidth;
canvas.height = Math.round(srcH); canvas.height = video.videoHeight;
context.drawImage( context.drawImage(video, 0, 0, canvas.width, canvas.height);
video,
srcX,
srcY,
srcW,
srcH,
0,
0,
canvas.width,
canvas.height
);
canvas.toBlob( canvas.toBlob(
(blob: unknown) => { (blob: unknown) => {

View File

@ -1,94 +0,0 @@
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,17 +19,27 @@ export default function TabFeatures() {
const { currentUser, setCurrentUser } = useUserStore(); const { currentUser, setCurrentUser } = useUserStore();
const { isAutoChecking, setIsAutoChecking, setRefreshLog, autoCheckinTick } = const { isAutoChecking, setIsAutoChecking, setRefreshLog } = useAppStore();
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 = () => {
// Auto mode is now driven by stable-face detection in <Main> — toggling if (isAutoChecking) {
// this flag enables/disables the 2s presence timer + auto fire. if (autoCheckIntervalRef.current) {
setIsAutoChecking(!isAutoChecking); clearInterval(autoCheckIntervalRef.current);
autoCheckIntervalRef.current = null;
}
setIsAutoChecking(false);
} else {
autoCheckIntervalRef.current = setInterval(() => {
captureAndCheck();
}, 3000);
setIsAutoChecking(true);
}
}; };
const createCheckpoint = async () => { const createCheckpoint = async () => {
@ -110,18 +120,6 @@ 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,14 +6,11 @@ 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 { useCallback, useEffect, useRef, useState } from "react"; import { 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";
@ -23,47 +20,13 @@ export default function Main() {
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false); const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false);
// const { currentUser, setCurrentUser } = useUserStore(); // const { currentUser, setCurrentUser } = useUserStore();
const { const { setCanvasRef, setVideoRef } = useAppStore();
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();
@ -125,12 +88,6 @@ 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">
@ -150,34 +107,10 @@ export default function Main() {
ref={videoRef} ref={videoRef}
autoPlay autoPlay
playsInline playsInline
className="w-full h-full object-cover will-change-transform" className="w-full h-full object-cover"
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,6 +1,5 @@
/* 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 = {
@ -10,9 +9,6 @@ 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;
@ -21,8 +17,6 @@ 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) => ({
@ -32,8 +26,6 @@ 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 }),
@ -41,9 +33,6 @@ 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": "TrackingToolWeb", "name": "school-checkin",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": {} "packages": {}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@ -8,12 +8,12 @@
<script <script
type="module" type="module"
crossorigin crossorigin
src="/camera/static/assets/index-_MGhSlOr.js" src="/camera/static/assets/index-DW_Nku2j.js"
></script> ></script>
<link <link
rel="stylesheet" rel="stylesheet"
crossorigin crossorigin
href="/camera/static/assets/index-B9NPk65I.css" href="/camera/static/assets/index-CDZdzCu6.css"
/> />
</head> </head>
<body> <body>

View File

@ -1 +0,0 @@
[{"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

@ -20,7 +20,6 @@
"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",
@ -2446,23 +2445,6 @@
"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==",
"license": "Apache-2.0",
"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",
@ -2531,12 +2513,6 @@
"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==",
"license": "MIT"
},
"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",
@ -2557,24 +2533,6 @@
"@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==",
"license": "MIT"
},
"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==",
"license": "MIT"
},
"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==",
"license": "MIT"
},
"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",
@ -3555,22 +3513,6 @@
"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==",
"license": "MIT",
"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==",
"license": "0BSD"
},
"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",
@ -4488,15 +4430,6 @@
"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==",
"license": "MIT",
"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",
@ -4832,12 +4765,6 @@
"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==",
"license": "MIT"
},
"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,7 +22,6 @@
"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

@ -1 +0,0 @@
[{"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

@ -1,416 +0,0 @@
/* 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 hasWebGL = () => {
try {
const c = document.createElement("canvas");
return !!(
(c.getContext("webgl2") as WebGLRenderingContext | null) ||
(c.getContext("webgl") as WebGLRenderingContext | null) ||
(c.getContext(
"experimental-webgl"
) as unknown as WebGLRenderingContext | null)
);
} catch {
return false;
}
};
const load = async () => {
try {
faceapi = await import("face-api.js");
// Detect WebGL support BEFORE letting tfjs try to initialize it —
// tf.setBackend('webgl') logs a noisy "Initialization of backend
// webgl failed" warning when it fails, even if we then fall back.
const tf = faceapi.tf;
if (tf?.setBackend && tf?.ready) {
const backend = hasWebGL() ? "webgl" : "cpu";
try {
await tf.setBackend(backend);
} catch (e) {
console.warn(`[useFaceZoom] setBackend(${backend}) failed`, e);
}
await tf.ready();
}
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,7 +2,6 @@
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));
@ -19,50 +18,12 @@ 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 = Math.round(srcW); canvas.width = video.videoWidth;
canvas.height = Math.round(srcH); canvas.height = video.videoHeight;
context.drawImage( context.drawImage(video, 0, 0, canvas.width, canvas.height);
video,
srcX,
srcY,
srcW,
srcH,
0,
0,
canvas.width,
canvas.height
);
canvas.toBlob( canvas.toBlob(
(blob: unknown) => { (blob: unknown) => {

View File

@ -1,99 +0,0 @@
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;
}
// Arm length tính theo % cạnh bracket để scale đúng trên mọi kích thước Card
// (mobile ~350px, desktop ~1000px). Sàn tối thiểu 10px để không tàng hình
// khi mặt detect rất nhỏ; trần 36px để khi mặt to không bị quá dày.
const cornerSize = {
width: "min(36px, max(10px, 22%))",
height: "min(36px, max(10px, 22%))",
} 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

@ -38,7 +38,7 @@ export function CameraNotificationModal({
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger> <DialogTrigger>{children}</DialogTrigger>
<DialogContent className="w-[95vw] max-w-md sm:max-w-md"> <DialogContent className="w-[95vw] max-w-md sm:max-w-md">
<DialogHeader> <DialogHeader>
<div className="flex items-center justify-center mb-4"> <div className="flex items-center justify-center mb-4">

View File

@ -3,11 +3,11 @@
import { checkingApi } from "@/api/checking-api"; import { checkingApi } from "@/api/checking-api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { speak } from "@/lib/speak"; import { speak } from "@/lib/speak";
import { capture, cn, formatTime } from "@/lib/utils"; import { capture, formatTime } from "@/lib/utils";
import useAppStore from "@/stores/use-app-store"; import useAppStore from "@/stores/use-app-store";
import useUserStore from "@/stores/use-user-store"; import useUserStore from "@/stores/use-user-store";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { Camera, Image, Loader, Play, Square } from "lucide-react"; import { Camera, Image, Loader } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import Register from "./register"; import Register from "./register";
@ -19,19 +19,12 @@ export default function TabFeatures({ inline = false }: { inline?: boolean }) {
const { currentUser, setCurrentUser } = useUserStore(); const { currentUser, setCurrentUser } = useUserStore();
const { isAutoChecking, setIsAutoChecking, setRefreshLog, autoCheckinTick } = const { setRefreshLog } = useAppStore();
useAppStore();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [checkPoinLoading, setCheckPoinLoading] = useState(false); const [checkPoinLoading, setCheckPoinLoading] = useState(false);
const toggleAutoCheck = () => {
// Auto mode is now driven by stable-face detection in <Main> — toggling
// this flag enables/disables the 2s presence timer + auto fire.
setIsAutoChecking(!isAutoChecking);
};
const createCheckpoint = async () => { const createCheckpoint = async () => {
if (!currentUser) { if (!currentUser) {
toast.warning("Vui lòng chọn user để tạo checkpoint"); toast.warning("Vui lòng chọn user để tạo checkpoint");
@ -111,22 +104,6 @@ export default function TabFeatures({ inline = false }: { inline?: boolean }) {
}; };
}, []); }, []);
// 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. Khởi tạo null để mỗi lần remount (vd: resize đổi layout) không
// bắn nhầm tick cũ — lần chạy đầu chỉ "ghi nhớ" tick hiện tại rồi return.
const lastHandledTick = useRef<number | null>(null);
useEffect(() => {
if (lastHandledTick.current === null) {
lastHandledTick.current = autoCheckinTick;
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") {
@ -148,10 +125,9 @@ export default function TabFeatures({ inline = false }: { inline?: boolean }) {
if (inline) { if (inline) {
return ( return (
<div className="grid grid-cols-3 gap-2 w-full"> <div className="grid grid-cols-2 gap-2 w-full">
<Button <Button
onClick={captureAndCheck} onClick={captureAndCheck}
disabled={isAutoChecking}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold text-xs" className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold text-xs"
> >
{!loading ? ( {!loading ? (
@ -164,37 +140,16 @@ export default function TabFeatures({ inline = false }: { inline?: boolean }) {
)} )}
</Button> </Button>
<Button
onClick={toggleAutoCheck}
variant={isAutoChecking ? "destructive" : "outline"}
className={cn(
"w-full font-semibold text-xs",
isAutoChecking && "animate-pulse",
)}
>
{isAutoChecking ? (
<>
<Square className="mr-1 size-4" />
Dừng Tự Đng
</>
) : (
<>
<Play className="mr-1 size-4" />
Tự Đng
</>
)}
</Button>
{currentUser ? ( {currentUser ? (
<Button <Button
disabled={checkPoinLoading || isAutoChecking} disabled={checkPoinLoading}
onClick={createCheckpoint} onClick={createCheckpoint}
className="w-full font-semibold text-xs" className="w-full font-semibold text-xs"
> >
{!checkPoinLoading ? ( {!checkPoinLoading ? (
<> <>
<Image className="mr-1 size-4" /> <Image className="mr-1 size-4" />
Check Point Tạo Check Point
</> </>
) : ( ) : (
<Loader className="size-4 animate-spin" /> <Loader className="size-4 animate-spin" />
@ -208,10 +163,9 @@ export default function TabFeatures({ inline = false }: { inline?: boolean }) {
} }
return ( return (
<div className="absolute bottom-4 sm:bottom-6 lg:bottom-10 px-3 sm:px-4 right-0 left-0 grid grid-cols-3 gap-2 sm:gap-3 lg:gap-4 max-w-3xl mx-auto"> <div className="absolute bottom-4 sm:bottom-6 lg:bottom-10 px-3 sm:px-4 right-0 left-0 grid grid-cols-2 gap-2 sm:gap-3 lg:gap-4 max-w-3xl mx-auto">
<Button <Button
onClick={captureAndCheck} onClick={captureAndCheck}
disabled={isAutoChecking}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold text-xs sm:text-sm" className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold text-xs sm:text-sm"
> >
{!loading && ( {!loading && (
@ -224,32 +178,9 @@ export default function TabFeatures({ inline = false }: { inline?: boolean }) {
{loading && <Loader className="size-4 animate-spin" />} {loading && <Loader className="size-4 animate-spin" />}
</Button> </Button>
<Button
onClick={toggleAutoCheck}
variant={isAutoChecking ? "destructive" : "outline"}
className={cn(
"w-full font-semibold text-xs sm:text-sm",
isAutoChecking && "animate-pulse",
)}
>
{!loading && isAutoChecking ? (
<>
<Square className="mr-2 size-4" />
Dừng Tự Đng
</>
) : (
<>
<Play className="mr-2 size-4" />
Tự Đng Điểm Danh
</>
)}
{loading && <Loader className="size-4 animate-spin" />}
</Button>
{currentUser && ( {currentUser && (
<Button <Button
disabled={checkPoinLoading || isAutoChecking} disabled={checkPoinLoading}
onClick={createCheckpoint} onClick={createCheckpoint}
className="w-full font-semibold text-xs sm:text-sm" className="w-full font-semibold text-xs sm:text-sm"
> >

View File

@ -1,5 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { checkingApi } from "@/api/checking-api"; import { checkingApi } from "@/api/checking-api";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { TabsContent } from "@/components/ui/tabs"; import { TabsContent } from "@/components/ui/tabs";
@ -15,14 +14,7 @@ export function LogList({ className }: { className?: string }) {
const loadLogs = async () => { const loadLogs = async () => {
try { try {
const { data } = await checkingApi.logs(); const { data } = await checkingApi.logs();
const list: ILog[] = Array.isArray(data) setLogs(data);
? data
: Array.isArray((data as any)?.data)
? (data as any).data
: Array.isArray((data as any)?.logs)
? (data as any).logs
: [];
setLogs(list);
setRefreshLog(false); setRefreshLog(false);
} catch (error) { } catch (error) {
console.log(error); console.log(error);

View File

@ -21,14 +21,7 @@ export default function TabUsers({ value }: { value: string }) {
const loadUsers = async () => { const loadUsers = async () => {
try { try {
const { data } = await checkingApi.users(); const { data } = await checkingApi.users();
const list: IUser[] = Array.isArray(data) setUsers(data);
? data
: Array.isArray((data as any)?.data)
? (data as any).data
: Array.isArray((data as any)?.users)
? (data as any).users
: [];
setUsers(list);
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} }

View File

@ -1,116 +1,30 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client"; "use client";
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 { useCallback, useEffect, useRef, useState } from "react"; import { 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";
import { LogList } from "./components/tab-log"; import { LogList } from "./components/tab-log";
const DESKTOP_MQ = "(min-width: 1024px)";
const matchDesktop = () =>
typeof window !== "undefined"
? window.matchMedia(DESKTOP_MQ).matches
: true;
export default function Main() { export default function Main() {
const [isDesktop, setIsDesktop] = useState(matchDesktop); const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [isSidebarOpen, setIsSidebarOpen] = useState(matchDesktop);
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false); const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
const mq = window.matchMedia(DESKTOP_MQ);
const handler = (e: MediaQueryListEvent) => {
setIsDesktop(e.matches);
// Resize xuống tablet/mobile thì đóng luôn drawer logs (tắt auto-open).
if (!e.matches) setIsSidebarOpen(false);
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
// const { currentUser, setCurrentUser } = useUserStore(); // const { currentUser, setCurrentUser } = useUserStore();
const { const { setCanvasRef, setVideoRef } = useAppStore();
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 cardRef = useRef<HTMLDivElement>(null);
// Kích thước thực của khung hiển thị video bên trong Card sau khi
// letterbox theo aspect-ratio nguồn (1280x720 = 16:9). FaceBracket dùng %
// của container này nên bám đúng vùng video, không lệch ra vùng đen.
const [videoFrame, setVideoFrame] = useState<{ w: number; h: number }>({
w: 0,
h: 0,
});
useEffect(() => {
const el = cardRef.current;
if (!el) return;
const VIDEO_ASPECT = 16 / 9;
const ro = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
if (!width || !height) return;
const cardAspect = width / height;
let w: number, h: number;
if (cardAspect >= VIDEO_ASPECT) {
h = height;
w = height * VIDEO_ASPECT;
} else {
w = width;
h = width / VIDEO_ASPECT;
}
setVideoFrame({ w: Math.round(w), h: Math.round(h) });
});
ro.observe(el);
return () => ro.disconnect();
}, []);
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,
});
// Initialize camera // Initialize camera
useEffect(() => { useEffect(() => {
@ -142,12 +56,6 @@ 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 relative"> <div className="flex h-screen relative">
@ -175,55 +83,20 @@ export default function Main() {
)} )}
> >
<div className="h-full flex flex-col p-2 sm:p-4 lg:p-6 gap-2 lg:gap-0"> <div className="h-full flex flex-col p-2 sm:p-4 lg:p-6 gap-2 lg:gap-0">
{/* Mobile/tablet: Buttons at top conditional render đ tránh {/* Mobile/tablet: Buttons at top */}
mount 2 instance TabFeatures cùng lúc (duplicate auto-check). */} <div className="lg:hidden">
{!isDesktop && <TabFeatures inline />} <TabFeatures inline />
</div>
{/* Video Feed */} {/* Video Feed */}
<Card className="flex-1 min-h-0 overflow-hidden bg-black relative group"> <Card className="flex-1 min-h-0 overflow-hidden bg-black relative group">
<div ref={cardRef} className="absolute inset-0"> <video
{/* Khung video centered theo aspect ngun đt video + bracket ref={videoRef}
chung trong inner container đ toạ đ % của bracket bám autoPlay
đúng vùng video, không tràn ra letterbox đen. */} playsInline
<div className="w-full h-full object-contain lg:object-cover"
className="absolute inset-0 m-auto" />
style={{ <div className="absolute inset-0 border-4 border-blue-500/30 pointer-events-none" />
width: videoFrame.w || "100%",
height: videoFrame.h || "100%",
}}
>
<video
ref={videoRef}
autoPlay
playsInline
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",
}}
/>
<FaceBracket
box={faceBox}
transform={faceZoom}
progress={isAutoChecking ? faceStability : 0}
/>
</div>
<div className="absolute inset-0 border-4 border-blue-500/30 pointer-events-none" />
{/* 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 && (
@ -291,17 +164,16 @@ export default function Main() {
)} )}
</Button> </Button>
{/* Desktop only: buttons overlaid at bottom of video */} {/* Desktop only: buttons overlaid at bottom of video */}
{isDesktop && <TabFeatures />} <div className="hidden lg:block">
<TabFeatures />
</div> </div>
</Card> </Card>
{/* Mobile/tablet: Log at bottom */} {/* Mobile/tablet: Log at bottom */}
{!isDesktop && ( <div className="lg:hidden h-44 border rounded-lg bg-white overflow-hidden">
<div className="h-44 border rounded-lg bg-white overflow-hidden"> <LogList />
<LogList /> </div>
</div>
)}
</div> </div>
</div> </div>

View File

@ -1,6 +1,5 @@
/* 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 = {
@ -11,8 +10,6 @@ type AppState = {
captureRegisterImage: any; captureRegisterImage: any;
videoRef: any; videoRef: any;
canvasRef: any; canvasRef: any;
faceZoomTransform: FaceZoomTransform | null;
autoCheckinTick: number;
// actions // actions
setIsAutoChecking: (data: boolean) => void; setIsAutoChecking: (data: boolean) => void;
@ -22,8 +19,6 @@ 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) => ({
@ -34,8 +29,6 @@ const useAppStore = create<AppState>((set) => ({
videoRef: null, videoRef: null,
refreshLog: false, refreshLog: false,
refreshUsers: false, refreshUsers: 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 }),
@ -44,9 +37,6 @@ 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

@ -4,7 +4,6 @@ import path from "path";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
base: "/au/checkin/static/",
plugins: [ plugins: [
react({ react({
babel: { babel: {