File size: 7,243 Bytes
701628b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
const video = document.getElementById('videoFeed');
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const modal = document.getElementById('successModal');
const modalTitle = document.getElementById('modalTitle');
const modalName = document.getElementById('modalName');
const modalTime = document.getElementById('modalTime');
const cameraContainer = document.getElementById('cameraContainer');
const statusHint = document.getElementById('statusHint');

// Spoof toast
const spoofToast = document.getElementById('spoofToast');
const spoofToastMessage = document.getElementById('spoofToastMessage');

// Frame buffer for sequence liveness (motion + blink); need enough frames to catch a blink
const FRAME_BUFFER_SIZE = 14;
const CAPTURE_INTERVAL_MS = 280;
let frameBuffer = [];

let isScanning = true;
let stream = null;
let toastDismissTimer = null;
let lastFaceAlertAt = 0;
const FACE_ALERT_COOLDOWN_MS = 4000;

// ─── Camera ─────────────────────────────────────────────────────────────────

async function startCamera() {
    try {
        stream = await navigator.mediaDevices.getUserMedia({ 
            video: { facingMode: 'user', width: 640, height: 480 } 
        });
        video.srcObject = stream;
        startCaptureLoop();
    } catch (err) {
        console.error("Camera error:", err);
        statusText.textContent = "Camera access denied or unavailable";
        statusText.style.color = "red";
    }
}

function stopCamera() {
    if (stream) {
        stream.getTracks().forEach(track => track.stop());
    }
}

function captureFrame() {
    const canvas = document.createElement('canvas');
    canvas.width = video.videoWidth;
    canvas.height = video.videoHeight;
    const ctx = canvas.getContext('2d');
    ctx.drawImage(video, 0, 0);
    return canvas.toDataURL('image/jpeg', 0.8);
}

// ─── Capture loop ───────────────────────────────────────────────────────────

async function startCaptureLoop() {
    while (isScanning) {
        if (video.readyState === video.HAVE_ENOUGH_DATA) {
            const frame = captureFrame();
            frameBuffer.push(frame);
            if (frameBuffer.length > FRAME_BUFFER_SIZE) frameBuffer.shift();

            // Send sequence only when we have enough frames for blink detection (backend needs ~4+ frames)
const payload = frameBuffer.length >= 6
                ? { frames: frameBuffer.slice() }
                : { frame: frame };

            try {
                const response = await fetch('/api/recognize', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify(payload)
                });

                const result = await response.json();
                handleResult(result);
            } catch (e) {
                console.log("Network error", e);
            }
        }

        await new Promise(r => setTimeout(r, CAPTURE_INTERVAL_MS));
    }
}

// ─── Result handler ─────────────────────────────────────────────────────────

function handleResult(result) {
    if (result.status === 'success') {
        showSuccess(result);
        var action = (result.punch_type === 'out') ? 'Punched out' : 'Punched in';
        if (typeof showSnackbar === 'function') {
            showSnackbar(action + ' at ' + (result.timestamp || ''), 'success');
        }
    } else if (result.status === 'cooldown') {
        statusText.textContent = 'Please wait 1 min';
        statusText.style.color = "#FFD700";
        if (typeof showSnackbar === 'function') showSnackbar(result.message || 'Please wait 1 minute before punching again.', 'info');
    } else if (result.status === 'spoof') {
        showSpoofToast(result);
    } else if (result.status === 'unknown') {
        statusText.textContent = "Face not recognized";
        statusText.style.color = "#A5A5A5";
        if (typeof showSnackbar === 'function' && Date.now() - lastFaceAlertAt > FACE_ALERT_COOLDOWN_MS) {
            lastFaceAlertAt = Date.now();
            showSnackbar('Face not recognized β€” ensure your face is clearly visible.', 'info');
        }
    } else if (result.status === 'no_face') {
        statusText.textContent = "Position your face in the frame";
        statusText.style.color = "#A5A5A5";
        if (typeof showSnackbar === 'function' && Date.now() - lastFaceAlertAt > FACE_ALERT_COOLDOWN_MS) {
            lastFaceAlertAt = Date.now();
            showSnackbar('Adjust position β€” keep your face clearly visible in the frame.', 'info');
        }
    } else if (result.status === 'spoof' && result.reason && result.reason.toLowerCase().includes('blink')) {
        statusText.textContent = "Please blink to verify";
        statusText.style.color = "#FFD700";
    }
}

// ─── Spoof toast (banner, auto-dismiss) ─────────────────────────────────────

function showSpoofToast(data) {
    const msg = data.reason || data.message || "Use a live face, not a photo or screen.";
    spoofToastMessage.textContent = msg;
    spoofToast.classList.add('show');
    statusText.textContent = "⚠ Spoofing detected";
    statusText.style.color = "#FF3B30";

    if (toastDismissTimer) clearTimeout(toastDismissTimer);
    toastDismissTimer = setTimeout(() => {
        spoofToast.classList.remove('show');
        statusText.textContent = "Position your face in the frame";
        statusText.style.color = "#A5A5A5";
        toastDismissTimer = null;
    }, 4500);
}

// ─── Success modal ──────────────────────────────────────────────────────────

function showSuccess(data) {
    isScanning = false;
    statusDot.classList.add('active');
    if (statusHint) statusHint.style.visibility = 'hidden';

    if (modalTitle) modalTitle.textContent = (data.punch_type === 'out') ? 'Punched out' : 'Punched in';
    if (modalName) modalName.textContent = data.name;
    if (modalTime) modalTime.textContent = data.timestamp || '';
    
    modal.classList.add('show');
    
    // Auto dismiss after 4s
    setTimeout(dismissModal, 4000);
}

function dismissModal() {
    modal.classList.remove('show');
    statusDot.classList.remove('active');
    statusText.textContent = "Position your face in the frame";
    statusText.style.color = "#A5A5A5";
    if (statusHint) statusHint.style.visibility = "";
    isScanning = true;
    startCaptureLoop();
}

// ─── Init ───────────────────────────────────────────────────────────────────
startCamera();
window.addEventListener('beforeunload', stopCamera);