lifedebugger's picture
Deploy files from GitHub repository with LFS
e249c2c
Raw
History Blame Contribute Delete
16.4 kB
// FaceScanner.jsx — MediaPipe FaceMesh + gradient mesh overlay + liveness challenges
// Real client-side liveness: blink (EAR), head yaw/pitch, mouth open (MAR), smile
// Draws animated gradient tessellation like immigration kiosk.
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";
// MediaPipe landmark indexes
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) {
// measure width vs face width (cheeks) — smiling stretches this
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);
}
// yaw sign: nose x relative to face midline
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;
// normalized offset [-1..1]
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);
}
/**
* Draw a gradient tessellation mesh on canvas using MediaPipe connections.
* Each frame we shift gradient phase so it feels alive.
*/
function drawGradientMesh(ctx, lm, width, height, phase) {
const grad = ctx.createLinearGradient(0, 0, width, height);
const p = (phase % 1);
// rotate stops for animated color travel
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();
// Emphasize eyes / mouth / face oval with brighter accent
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();
});
// Dot on key points
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);
// challenge state
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);
// per-challenge internal states
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;
// draw mesh
drawGradientMesh(ctx, lm, w, h, phaseRef.current);
// process current challenge (only if provided)
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") {
// baseline eye open ~0.28, closed ~<0.19
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;
// In mirrored video, "turn_left" from user's POV = camera sees nose move right
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) {
// capture immediately from current frame
setTimeout(() => doCapture(), 250);
}
} else {
setCurrentIdx(next);
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[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");
// Un-mirror capture (backend expects real orientation)
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]);
// expose capture via ref-like callback
useEffect(() => {
// attach capture handler to window if needed? Actually parent uses onCapture
}, []);
// Initialize mediapipe + camera
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 (_) {
// ignore
}
}
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 (_) {}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// update onResults binding when currentIdx changes (React closure)
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 };