Rekam wajah setelah register: screen/modal, kirim frame ke enroll-live, integrasi flow

This commit is contained in:
mwpn
2026-03-08 14:32:13 +07:00
parent b61f6579ee
commit d3a08aa9d3
2 changed files with 201 additions and 2 deletions

182
app.js
View File

@@ -88,6 +88,20 @@
var pendingQrToken = null;
var html5QrCodeInstance = null;
// Enroll wajah saat daftar
var enrollFaceModal = document.getElementById('enroll-face-modal');
var enrollFaceVideo = document.getElementById('enroll-face-video');
var enrollFaceStatus = document.getElementById('enroll-face-status');
var enrollFaceError = document.getElementById('enroll-face-error');
var enrollFaceStart = document.getElementById('enroll-face-start');
var enrollFaceBackdrop = document.getElementById('enroll-face-backdrop');
var enrollStream = null;
var enrollIntervalId = null;
var enrollCollectedImages = [];
var enrollTargetCount = 5;
var enrollMinCount = 3;
var enrollSubmitting = false;
// Device credentials — bisa diisi di Pengaturan, default untuk development
var DEFAULT_DEVICE_CODE = 'MOBILE_APP';
var DEFAULT_API_KEY = 'MOBILE_APP_SECRET';
@@ -535,8 +549,8 @@
saveStudentProfile(profile);
renderStudentSummary(homeStudentSummary, profile);
goToScreen('home');
showToast('Registrasi berhasil. Siap presensi.', 'success');
openEnrollFaceModal();
showToast('Registrasi berhasil. Rekam wajah untuk verifikasi absen.', 'success');
}).catch(function (err) {
btnCompleteRegister.disabled = false;
btnCompleteRegister.textContent = 'Simpan & Mulai';
@@ -877,6 +891,162 @@
if (cameraModal) cameraModal.classList.add('hidden');
}
function captureEnrollFrameAsBase64() {
if (!enrollFaceVideo || enrollFaceVideo.readyState < 2) return null;
var w = enrollFaceVideo.videoWidth || 640;
var h = enrollFaceVideo.videoHeight || 480;
if (w === 0 || h === 0) return null;
var canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
var ctx = canvas.getContext('2d');
ctx.drawImage(enrollFaceVideo, 0, 0, w, h);
return canvas.toDataURL('image/jpeg', 0.9);
}
function closeEnrollFaceModal() {
if (enrollIntervalId) {
clearInterval(enrollIntervalId);
enrollIntervalId = null;
}
enrollCollectedImages = [];
if (enrollStream) {
enrollStream.getTracks().forEach(function (t) { t.stop(); });
enrollStream = null;
}
if (enrollFaceVideo) enrollFaceVideo.srcObject = null;
if (enrollFaceModal) enrollFaceModal.classList.add('hidden');
if (enrollFaceStatus) {
enrollFaceStatus.classList.add('hidden');
enrollFaceStatus.textContent = '';
}
}
function openEnrollFaceModal() {
if (!ensureConfig()) {
goToScreen('home');
return;
}
if (!enrollFaceModal || !enrollFaceVideo || !currentStudentProfile || !currentStudentProfile.student_id) {
goToScreen('home');
return;
}
enrollCollectedImages = [];
enrollSubmitting = false;
if (enrollFaceError) enrollFaceError.classList.add('hidden');
if (enrollFaceStatus) {
enrollFaceStatus.textContent = 'Menyiapkan kamera…';
enrollFaceStatus.classList.remove('hidden');
}
enrollFaceModal.classList.remove('hidden');
enrollFaceVideo.style.display = 'block';
var constraints = { video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } }, audio: false };
navigator.mediaDevices.getUserMedia(constraints).then(function (stream) {
enrollStream = stream;
enrollFaceVideo.srcObject = stream;
enrollFaceVideo.onloadedmetadata = function () {
enrollFaceVideo.play().then(function () {
if (enrollFaceStatus) enrollFaceStatus.textContent = 'Arahkan wajah ke kamera lalu tekan Rekam.';
}).catch(function () {});
};
}).catch(function () {
if (enrollFaceError) {
enrollFaceError.textContent = 'Kamera tidak tersedia atau izin ditolak. Bisa dilewati.';
enrollFaceError.classList.remove('hidden');
}
if (enrollFaceStatus) enrollFaceStatus.textContent = 'Kamera gagal. Tekan Lewati untuk lanjut.';
});
}
function startEnrollCapture() {
if (enrollSubmitting || !enrollStream || !enrollFaceVideo || typeof faceapi === 'undefined') return;
if (!currentStudentProfile || !currentStudentProfile.student_id) return;
enrollSubmitting = true;
enrollCollectedImages = [];
if (enrollFaceStart) { enrollFaceStart.disabled = true; enrollFaceStart.textContent = 'Merekam…'; }
var weightsUrl = 'https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/weights/';
faceapi.nets.tinyFaceDetector.loadFromUri(weightsUrl).then(function () {
var tick = 0;
var lastFaceCount = 0;
enrollIntervalId = setInterval(function () {
if (!enrollStream || !enrollFaceVideo.srcObject || enrollFaceVideo.readyState < 2) return;
var detect = faceapi.detectSingleFace(enrollFaceVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: 224, scoreThreshold: 0.5 }));
detect.then(function (detection) {
if (detection && enrollCollectedImages.length < enrollTargetCount) {
var b64 = captureEnrollFrameAsBase64();
if (b64) {
enrollCollectedImages.push(b64);
if (enrollFaceStatus) enrollFaceStatus.textContent = 'Rekam ' + enrollCollectedImages.length + '/' + enrollTargetCount + '…';
}
}
if (enrollCollectedImages.length >= enrollMinCount) {
clearInterval(enrollIntervalId);
enrollIntervalId = null;
var imgs = enrollCollectedImages.slice(0, enrollTargetCount);
var url = backendBase() + '/api/face/enroll-live';
if (enrollFaceStatus) enrollFaceStatus.textContent = 'Mengirim ke server…';
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ student_id: currentStudentProfile.student_id, images: imgs })
}).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }); })
.then(function (r) {
enrollSubmitting = false;
if (enrollFaceStart) { enrollFaceStart.disabled = false; enrollFaceStart.textContent = 'Rekam (35 foto)'; }
if (r.ok && r.data && (r.data.saved || 0) > 0) {
showToast('Wajah berhasil direkam. Siap untuk verifikasi absen.', 'success');
} else {
showToast('Rekam wajah gagal. Bisa dicoba nanti dari pengaturan.', 'info');
}
closeEnrollFaceModal();
goToScreen('home');
}).catch(function () {
enrollSubmitting = false;
if (enrollFaceStart) { enrollFaceStart.disabled = false; enrollFaceStart.textContent = 'Rekam (35 foto)'; }
if (enrollFaceStatus) enrollFaceStatus.textContent = 'Gagal mengirim. Coba lagi atau Lewati.';
showToast('Gagal mengirim rekaman wajah.', 'error');
});
}
}).catch(function () {});
tick++;
if (tick > 80) {
clearInterval(enrollIntervalId);
enrollIntervalId = null;
enrollSubmitting = false;
if (enrollFaceStart) { enrollFaceStart.disabled = false; enrollFaceStart.textContent = 'Rekam (35 foto)'; }
if (enrollCollectedImages.length >= 1) {
var url = backendBase() + '/api/face/enroll-live';
if (enrollFaceStatus) enrollFaceStatus.textContent = 'Mengirim ' + enrollCollectedImages.length + ' foto…';
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ student_id: currentStudentProfile.student_id, images: enrollCollectedImages })
}).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }); })
.then(function (r) {
if (r.ok && (r.data && r.data.saved > 0)) showToast('Wajah berhasil direkam.', 'success');
else showToast('Rekam wajah gagal. Bisa dicoba nanti.', 'info');
closeEnrollFaceModal();
goToScreen('home');
}).catch(function () {
enrollSubmitting = false;
if (enrollFaceStart) { enrollFaceStart.disabled = false; enrollFaceStart.textContent = 'Rekam (35 foto)'; }
if (enrollFaceStatus) enrollFaceStatus.textContent = 'Gagal mengirim. Coba lagi atau Lewati.';
showToast('Gagal mengirim rekaman wajah.', 'error');
});
} else {
enrollSubmitting = false;
if (enrollFaceStart) { enrollFaceStart.disabled = false; enrollFaceStart.textContent = 'Rekam (35 foto)'; }
if (enrollFaceStatus) enrollFaceStatus.textContent = 'Wajah tidak terdeteksi. Arahkan wajah ke kamera lalu Rekam lagi.';
}
}
}, 500);
}).catch(function () {
enrollSubmitting = false;
if (enrollFaceStart) { enrollFaceStart.disabled = false; enrollFaceStart.textContent = 'Rekam (35 foto)'; }
if (enrollFaceStatus) enrollFaceStatus.textContent = 'Gagal memuat deteksi wajah. Lewati atau coba lagi.';
});
}
function stopCamera() {
if (cameraStream) {
cameraStream.getTracks().forEach(function (t) { t.stop(); });
@@ -1235,6 +1405,14 @@
if (cameraBackdrop) {
cameraBackdrop.addEventListener('click', closeCameraModal);
}
if (enrollFaceBackdrop) {
enrollFaceBackdrop.addEventListener('click', function () {
showToast('Rekam wajah wajib untuk melanjutkan.', 'info');
});
}
if (enrollFaceStart) {
enrollFaceStart.addEventListener('click', startEnrollCapture);
}
if (resultCloseBtn) {
resultCloseBtn.addEventListener('click', function () {
resultBox.classList.add('hidden');

View File

@@ -315,6 +315,27 @@
</div>
</div>
<!-- Modal Rekam Wajah (saat daftar pertama) -->
<div id="enroll-face-modal" class="modal modal-overlay hidden">
<div class="modal-backdrop" id="enroll-face-backdrop"></div>
<div class="modal-panel modal-panel-camera">
<div class="modal-panel-header">
<h2 class="modal-panel-title">Rekam Wajah (Wajib)</h2>
</div>
<div class="modal-panel-body">
<p class="modal-panel-hint">Arahkan wajah ke kamera lalu tekan <strong>Rekam</strong>. Data wajah dari HP dipakai untuk verifikasi saat absen masuk/pulang.</p>
<p id="enroll-face-status" class="camera-face-status hidden"></p>
<div class="camera-frame">
<video id="enroll-face-video" class="camera-video" playsinline autoplay muted></video>
<div id="enroll-face-error" class="camera-frame-error hidden">Kamera tidak tersedia atau izin ditolak.</div>
</div>
<div class="modal-panel-actions">
<button type="button" id="enroll-face-start" class="btn-primary btn-full">Rekam (35 foto)</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/dist/face-api.min.js"></script>
<script src="./app.js"></script>
<script>