| |
| |
| |
| import React, { useEffect, useRef, useState, useCallback } from "react"; |
| import { FaceMesh, FACEMESH_TESSELATION, FACEMESH_RIGHT_EYE, FACEMESH_LEFT_EYE, FACEMESH_LIPS, FACEMESH_FACE_OVAL } from "@mediapipe/face_mesh"; |
| import { motion, AnimatePresence } from "framer-motion"; |
| import { Eye, ArrowLeft, ArrowRight, ArrowDown, Smile, Loader2, ShieldCheck, ShieldAlert, RotateCcw } from "lucide-react"; |
|
|
| |
| const RIGHT_EYE = [33, 160, 158, 133, 153, 144]; |
| const LEFT_EYE = [362, 385, 387, 263, 373, 380]; |
| const UPPER_LIP_CENTER = 13; |
| const LOWER_LIP_CENTER = 14; |
| const MOUTH_LEFT = 61; |
| const MOUTH_RIGHT = 291; |
| const NOSE_TIP = 1; |
| const LEFT_CHEEK = 234; |
| const RIGHT_CHEEK = 454; |
| const FOREHEAD = 10; |
| const CHIN = 152; |
|
|
| const CHALLENGE_META = { |
| blink: { label: "Silakan Berkedip", subtitle: "Kedipkan mata Anda perlahan", Icon: Eye }, |
| turn_left: { label: "Tengok ke Kiri", subtitle: "Putar kepala ke kiri", Icon: ArrowLeft }, |
| turn_right: { label: "Tengok ke Kanan", subtitle: "Putar kepala ke kanan", Icon: ArrowRight }, |
| nod: { label: "Anggukkan Kepala", subtitle: "Turunkan dagu ke bawah", Icon: ArrowDown }, |
| smile: { label: "Silakan Senyum", subtitle: "Buka mulut atau senyum lebar", Icon: Smile }, |
| }; |
|
|
| function dist(a, b) { |
| const dx = a.x - b.x; |
| const dy = a.y - b.y; |
| return Math.sqrt(dx * dx + dy * dy); |
| } |
|
|
| function eyeEAR(lm, idx) { |
| const p1 = lm[idx[0]]; |
| const p2 = lm[idx[1]]; |
| const p3 = lm[idx[2]]; |
| const p4 = lm[idx[3]]; |
| const p5 = lm[idx[4]]; |
| const p6 = lm[idx[5]]; |
| return (dist(p2, p6) + dist(p3, p5)) / (2 * dist(p1, p4)); |
| } |
|
|
| function mouthOpenness(lm) { |
| const v = dist(lm[UPPER_LIP_CENTER], lm[LOWER_LIP_CENTER]); |
| const h = dist(lm[MOUTH_LEFT], lm[MOUTH_RIGHT]); |
| return v / Math.max(0.0001, h); |
| } |
|
|
| function mouthWidthRatio(lm) { |
| |
| const mw = dist(lm[MOUTH_LEFT], lm[MOUTH_RIGHT]); |
| const fw = dist(lm[LEFT_CHEEK], lm[RIGHT_CHEEK]); |
| return mw / Math.max(0.0001, fw); |
| } |
|
|
| |
| function estimateYaw(lm) { |
| const nose = lm[NOSE_TIP]; |
| const leftCheek = lm[LEFT_CHEEK]; |
| const rightCheek = lm[RIGHT_CHEEK]; |
| const midX = (leftCheek.x + rightCheek.x) / 2; |
| const width = rightCheek.x - leftCheek.x; |
| |
| return (nose.x - midX) / Math.max(0.0001, width / 2); |
| } |
|
|
| function estimatePitch(lm) { |
| const nose = lm[NOSE_TIP]; |
| const forehead = lm[FOREHEAD]; |
| const chin = lm[CHIN]; |
| const midY = (forehead.y + chin.y) / 2; |
| const height = chin.y - forehead.y; |
| return (nose.y - midY) / Math.max(0.0001, height / 2); |
| } |
|
|
| |
| |
| |
| |
| function drawGradientMesh(ctx, lm, width, height, phase) { |
| const grad = ctx.createLinearGradient(0, 0, width, height); |
| const p = (phase % 1); |
| |
| grad.addColorStop((0 + p) % 1, "#8A2BE2"); |
| grad.addColorStop((0.33 + p) % 1, "#0057FF"); |
| grad.addColorStop((0.66 + p) % 1, "#00F0FF"); |
| grad.addColorStop((1 + p) % 1, "#39FF14"); |
| ctx.strokeStyle = grad; |
| ctx.lineWidth = 0.6; |
| ctx.globalAlpha = 0.55; |
| ctx.beginPath(); |
| for (const [i, j] of FACEMESH_TESSELATION) { |
| const a = lm[i]; |
| const b = lm[j]; |
| if (!a || !b) continue; |
| ctx.moveTo(a.x * width, a.y * height); |
| ctx.lineTo(b.x * width, b.y * height); |
| } |
| ctx.stroke(); |
|
|
| |
| ctx.globalAlpha = 0.95; |
| ctx.lineWidth = 1.4; |
| const accents = [FACEMESH_LEFT_EYE, FACEMESH_RIGHT_EYE, FACEMESH_LIPS, FACEMESH_FACE_OVAL]; |
| const strokes = ["#00F0FF", "#00F0FF", "#39FF14", "#8A2BE2"]; |
| accents.forEach((set, k) => { |
| ctx.strokeStyle = strokes[k]; |
| ctx.beginPath(); |
| for (const [i, j] of set) { |
| const a = lm[i]; |
| const b = lm[j]; |
| if (!a || !b) continue; |
| ctx.moveTo(a.x * width, a.y * height); |
| ctx.lineTo(b.x * width, b.y * height); |
| } |
| ctx.stroke(); |
| }); |
|
|
| |
| ctx.globalAlpha = 0.9; |
| const pts = [NOSE_TIP, FOREHEAD, CHIN, LEFT_CHEEK, RIGHT_CHEEK]; |
| ctx.fillStyle = "#00F0FF"; |
| pts.forEach((idx) => { |
| const p = lm[idx]; |
| if (!p) return; |
| ctx.beginPath(); |
| ctx.arc(p.x * width, p.y * height, 2.4, 0, Math.PI * 2); |
| ctx.fill(); |
| }); |
| ctx.globalAlpha = 1; |
| } |
|
|
| export default function FaceScanner({ |
| challenges, |
| onCapture, |
| onAllChallengesDone, |
| autoCapture = false, |
| captureOnDone = false, |
| width = 640, |
| height = 480, |
| showChallenges = true, |
| }) { |
| const videoRef = useRef(null); |
| const canvasRef = useRef(null); |
| const meshRef = useRef(null); |
| const streamRef = useRef(null); |
| const rafRef = useRef(null); |
| const lastLmRef = useRef(null); |
| const phaseRef = useRef(0); |
|
|
| |
| const [currentIdx, setCurrentIdx] = useState(0); |
| const [passed, setPassed] = useState([]); |
| const [faceDetected, setFaceDetected] = useState(false); |
| const [statusText, setStatusText] = useState("Menunggu wajah…"); |
| const [ready, setReady] = useState(false); |
|
|
| |
| const stateRef = useRef({ |
| blinkEyeClosed: false, |
| blinkCount: 0, |
| baselineYaw: null, |
| baselineMouth: null, |
| baselineSmile: null, |
| baselinePitch: null, |
| lastMoveT: 0, |
| }); |
|
|
| const currentChallenge = challenges?.[currentIdx]; |
|
|
| const resetInternal = useCallback(() => { |
| stateRef.current = { |
| blinkEyeClosed: false, |
| blinkCount: 0, |
| baselineYaw: null, |
| baselineMouth: null, |
| baselineSmile: null, |
| baselinePitch: null, |
| lastMoveT: 0, |
| }; |
| }, []); |
|
|
| const onResults = useCallback( |
| (results) => { |
| const canvas = canvasRef.current; |
| const video = videoRef.current; |
| if (!canvas || !video) return; |
| const ctx = canvas.getContext("2d"); |
| const w = canvas.width; |
| const h = canvas.height; |
| ctx.clearRect(0, 0, w, h); |
| phaseRef.current = (phaseRef.current + 0.008) % 1; |
|
|
| const lm = results.multiFaceLandmarks?.[0]; |
| if (!lm) { |
| setFaceDetected(false); |
| setStatusText("Posisikan wajah di tengah kamera…"); |
| lastLmRef.current = null; |
| return; |
| } |
| setFaceDetected(true); |
| lastLmRef.current = lm; |
| |
| drawGradientMesh(ctx, lm, w, h, phaseRef.current); |
|
|
| |
| if (!challenges || challenges.length === 0) return; |
| const chal = challenges[currentIdx]; |
| if (!chal) return; |
| const s = stateRef.current; |
|
|
| const earR = eyeEAR(lm, RIGHT_EYE); |
| const earL = eyeEAR(lm, LEFT_EYE); |
| const ear = (earR + earL) / 2; |
| const mopen = mouthOpenness(lm); |
| const smileRatio = mouthWidthRatio(lm); |
| const yaw = estimateYaw(lm); |
| const pitch = estimatePitch(lm); |
|
|
| let done = false; |
| let hint = ""; |
| if (chal === "blink") { |
| |
| const CLOSED = 0.19; |
| const OPEN = 0.26; |
| if (!s.blinkEyeClosed && ear < CLOSED) { |
| s.blinkEyeClosed = true; |
| } else if (s.blinkEyeClosed && ear > OPEN) { |
| s.blinkCount += 1; |
| s.blinkEyeClosed = false; |
| } |
| hint = `Blink terdeteksi: ${s.blinkCount}/1`; |
| if (s.blinkCount >= 1) done = true; |
| } else if (chal === "turn_left" || chal === "turn_right") { |
| if (s.baselineYaw == null) s.baselineYaw = yaw; |
| |
| const delta = yaw - s.baselineYaw; |
| const THRESH = 0.35; |
| if (chal === "turn_left" && delta >= THRESH) done = true; |
| if (chal === "turn_right" && delta <= -THRESH) done = true; |
| hint = `Yaw: ${delta.toFixed(2)}`; |
| } else if (chal === "nod") { |
| if (s.baselinePitch == null) s.baselinePitch = pitch; |
| const delta = pitch - s.baselinePitch; |
| if (delta > 0.3) done = true; |
| hint = `Pitch: ${delta.toFixed(2)}`; |
| } else if (chal === "smile") { |
| if (s.baselineMouth == null) s.baselineMouth = mopen; |
| if (s.baselineSmile == null) s.baselineSmile = smileRatio; |
| const dOpen = mopen - s.baselineMouth; |
| const dSmile = smileRatio - s.baselineSmile; |
| if (dOpen > 0.06 || dSmile > 0.05 || mopen > 0.35 || smileRatio > 0.55) done = true; |
| hint = `Mulut: ${(mopen).toFixed(2)} lebar: ${smileRatio.toFixed(2)}`; |
| } |
|
|
| setStatusText(hint); |
|
|
| if (done) { |
| const newPassed = [...passed, chal]; |
| setPassed(newPassed); |
| resetInternal(); |
| const next = currentIdx + 1; |
| if (next >= challenges.length) { |
| setStatusText("Semua verifikasi terlewati"); |
| if (onAllChallengesDone) onAllChallengesDone(newPassed); |
| if (captureOnDone) { |
| |
| setTimeout(() => doCapture(), 250); |
| } |
| } else { |
| setCurrentIdx(next); |
| } |
| } |
| }, |
| |
| [currentIdx, challenges, passed] |
| ); |
|
|
| const doCapture = useCallback(() => { |
| const video = videoRef.current; |
| if (!video) return null; |
| const tmp = document.createElement("canvas"); |
| tmp.width = video.videoWidth || width; |
| tmp.height = video.videoHeight || height; |
| const tctx = tmp.getContext("2d"); |
| |
| tctx.save(); |
| tctx.scale(-1, 1); |
| tctx.drawImage(video, -tmp.width, 0, tmp.width, tmp.height); |
| tctx.restore(); |
| const dataUrl = tmp.toDataURL("image/jpeg", 0.85); |
| if (onCapture) onCapture(dataUrl); |
| return dataUrl; |
| }, [onCapture, width, height]); |
|
|
| |
| useEffect(() => { |
| |
| }, []); |
|
|
| |
| useEffect(() => { |
| let cancelled = false; |
| async function init() { |
| const mesh = new FaceMesh({ |
| locateFile: (file) => |
| `https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/${file}`, |
| }); |
| mesh.setOptions({ |
| maxNumFaces: 1, |
| refineLandmarks: true, |
| minDetectionConfidence: 0.6, |
| minTrackingConfidence: 0.6, |
| }); |
| mesh.onResults(onResults); |
| meshRef.current = mesh; |
| try { |
| const stream = await navigator.mediaDevices.getUserMedia({ |
| video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: "user" }, |
| audio: false, |
| }); |
| if (cancelled) { |
| stream.getTracks().forEach((t) => t.stop()); |
| return; |
| } |
| streamRef.current = stream; |
| const video = videoRef.current; |
| video.srcObject = stream; |
| await video.play(); |
| setReady(true); |
| const loop = async () => { |
| if (cancelled) return; |
| if (meshRef.current && video.readyState >= 2) { |
| try { |
| await meshRef.current.send({ image: video }); |
| } catch (_) { |
| |
| } |
| } |
| rafRef.current = requestAnimationFrame(loop); |
| }; |
| loop(); |
| } catch (err) { |
| setStatusText("Kamera tidak dapat diakses: " + err.message); |
| } |
| } |
| init(); |
| return () => { |
| cancelled = true; |
| if (rafRef.current) cancelAnimationFrame(rafRef.current); |
| if (streamRef.current) streamRef.current.getTracks().forEach((t) => t.stop()); |
| try { meshRef.current?.close?.(); } catch (_) {} |
| }; |
| |
| }, []); |
|
|
| |
| useEffect(() => { |
| if (meshRef.current) meshRef.current.onResults(onResults); |
| }, [onResults]); |
|
|
| const progress = challenges?.length ? passed.length / challenges.length : 0; |
| const currentMeta = currentChallenge ? CHALLENGE_META[currentChallenge] : null; |
|
|
| return ( |
| <div className="relative w-full" data-testid="face-scanner"> |
| <div |
| className="relative mx-auto rounded-2xl overflow-hidden corner-brackets" |
| style={{ maxWidth: "760px", aspectRatio: "4/3" }} |
| > |
| <span className="cb1" /> |
| <span className="cb2" /> |
| <video |
| ref={videoRef} |
| className="absolute inset-0 w-full h-full object-cover" |
| style={{ transform: "scaleX(-1)" }} |
| playsInline |
| muted |
| data-testid="scanner-video" |
| /> |
| <canvas |
| ref={canvasRef} |
| width={640} |
| height={480} |
| className="absolute inset-0 w-full h-full pointer-events-none" |
| style={{ transform: "scaleX(-1)" }} |
| /> |
| {/* Laser sweep */} |
| <div className="laser-line" style={{ top: "0%" }} /> |
| |
| {/* Status ribbon top */} |
| <div className="absolute top-3 left-3 right-3 flex items-center justify-between z-20"> |
| <div className="flex items-center gap-2"> |
| <span className={`status-dot ${faceDetected ? "" : "off"}`}></span> |
| <span className="tag-eyebrow text-white/80"> |
| {ready ? (faceDetected ? "WAJAH TERDETEKSI" : "MENUNGGU WAJAH") : "INISIALISASI…"} |
| </span> |
| </div> |
| <span className="tag-eyebrow text-white/60 hidden sm:inline"> |
| MEDIAPIPE · 468-POINT MESH |
| </span> |
| </div> |
| |
| {/* Challenge prompt bottom */} |
| <AnimatePresence mode="wait"> |
| {showChallenges && currentMeta && ( |
| <motion.div |
| key={currentChallenge} |
| initial={{ y: 40, opacity: 0 }} |
| animate={{ y: 0, opacity: 1 }} |
| exit={{ y: -40, opacity: 0 }} |
| transition={{ duration: 0.35 }} |
| className="absolute bottom-4 left-4 right-4 z-20" |
| > |
| <div className="glass rounded-xl px-5 py-4 flex items-center gap-4"> |
| <div |
| className="w-12 h-12 flex items-center justify-center rounded-lg" |
| style={{ |
| background: |
| "linear-gradient(45deg, #8A2BE2 0%, #0057FF 50%, #00F0FF 100%)", |
| boxShadow: "0 0 20px rgba(0,240,255,0.4)", |
| }} |
| > |
| <currentMeta.Icon size={22} className="text-black" /> |
| </div> |
| <div className="flex-1 min-w-0"> |
| <div className="text-white font-display font-bold text-xl leading-tight" data-testid="challenge-label"> |
| {currentMeta.label} |
| </div> |
| <div className="text-white/60 text-sm">{currentMeta.subtitle}</div> |
| </div> |
| <div className="hidden sm:block text-right"> |
| <div className="tag-eyebrow text-white/70"> |
| STEP {currentIdx + 1}/{challenges.length} |
| </div> |
| <div className="data-cell text-cyan_glow">{statusText}</div> |
| </div> |
| </div> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| |
| {/* Progress ring / bar (top-right) */} |
| {showChallenges && challenges?.length > 0 && ( |
| <div className="absolute top-14 right-3 z-20 flex gap-1"> |
| {challenges.map((c, i) => ( |
| <div |
| key={i} |
| className={`h-1.5 w-8 rounded-full ${ |
| i < passed.length ? "bg-neon_green shadow-green" : "bg-white/10" |
| }`} |
| /> |
| ))} |
| </div> |
| )} |
| </div> |
| |
| {/* Small legend */} |
| <div className="mt-3 flex items-center justify-center gap-3 flex-wrap"> |
| <span className="chip on">MESH AKTIF</span> |
| <span className={`chip ${faceDetected ? "ok" : ""}`}> |
| FACE {faceDetected ? "LOCKED" : "SCANNING"} |
| </span> |
| <span className="chip"> |
| {Math.round(progress * 100)}% LIVENESS |
| </span> |
| </div> |
| </div> |
| ); |
| } |
|
|
| export { CHALLENGE_META }; |
|
|