Rekam wajah setelah register: screen/modal, kirim frame ke enroll-live, integrasi flow
This commit is contained in:
182
app.js
182
app.js
@@ -88,6 +88,20 @@
|
|||||||
var pendingQrToken = null;
|
var pendingQrToken = null;
|
||||||
var html5QrCodeInstance = 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
|
// Device credentials — bisa diisi di Pengaturan, default untuk development
|
||||||
var DEFAULT_DEVICE_CODE = 'MOBILE_APP';
|
var DEFAULT_DEVICE_CODE = 'MOBILE_APP';
|
||||||
var DEFAULT_API_KEY = 'MOBILE_APP_SECRET';
|
var DEFAULT_API_KEY = 'MOBILE_APP_SECRET';
|
||||||
@@ -535,8 +549,8 @@
|
|||||||
saveStudentProfile(profile);
|
saveStudentProfile(profile);
|
||||||
renderStudentSummary(homeStudentSummary, profile);
|
renderStudentSummary(homeStudentSummary, profile);
|
||||||
|
|
||||||
goToScreen('home');
|
openEnrollFaceModal();
|
||||||
showToast('Registrasi berhasil. Siap presensi.', 'success');
|
showToast('Registrasi berhasil. Rekam wajah untuk verifikasi absen.', 'success');
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
btnCompleteRegister.disabled = false;
|
btnCompleteRegister.disabled = false;
|
||||||
btnCompleteRegister.textContent = 'Simpan & Mulai';
|
btnCompleteRegister.textContent = 'Simpan & Mulai';
|
||||||
@@ -877,6 +891,162 @@
|
|||||||
if (cameraModal) cameraModal.classList.add('hidden');
|
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 (3–5 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 (3–5 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 (3–5 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 (3–5 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 (3–5 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 (3–5 foto)'; }
|
||||||
|
if (enrollFaceStatus) enrollFaceStatus.textContent = 'Gagal memuat deteksi wajah. Lewati atau coba lagi.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function stopCamera() {
|
function stopCamera() {
|
||||||
if (cameraStream) {
|
if (cameraStream) {
|
||||||
cameraStream.getTracks().forEach(function (t) { t.stop(); });
|
cameraStream.getTracks().forEach(function (t) { t.stop(); });
|
||||||
@@ -1235,6 +1405,14 @@
|
|||||||
if (cameraBackdrop) {
|
if (cameraBackdrop) {
|
||||||
cameraBackdrop.addEventListener('click', closeCameraModal);
|
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) {
|
if (resultCloseBtn) {
|
||||||
resultCloseBtn.addEventListener('click', function () {
|
resultCloseBtn.addEventListener('click', function () {
|
||||||
resultBox.classList.add('hidden');
|
resultBox.classList.add('hidden');
|
||||||
|
|||||||
21
index.html
21
index.html
@@ -315,6 +315,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 (3–5 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="https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/dist/face-api.min.js"></script>
|
||||||
<script src="./app.js"></script>
|
<script src="./app.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
Reference in New Issue
Block a user