1336 lines
53 KiB
JavaScript
1336 lines
53 KiB
JavaScript
/*
|
|
* Candidate-side proctoring pipeline.
|
|
* MediaPipe runs locally for ML face/gaze/object detection.
|
|
* Enforces Tab/Focus Loss, Clipboard Activity, AI-Tool/Hotkey Detection,
|
|
* Continuous Speech Analysis, Screen Share Enforcement & System Desktop Screenshots.
|
|
* All events (MediaPipe ML + Focus/AI/Speech) are transmitted to the server endpoint.
|
|
*/
|
|
|
|
// MediaPipe Published Browser Bundle CDN URLs
|
|
const VISION_MODULE_URL = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.22-rc.20250304/vision_bundle.mjs';
|
|
const VISION_MODULE_FALLBACK_URL = 'https://unpkg.com/@mediapipe/tasks-vision@0.10.22-rc.20250304/vision_bundle.mjs';
|
|
const WASM_ROOT = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.22-rc.20250304/wasm';
|
|
const FACE_MODEL_URL = 'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task';
|
|
const OBJECT_MODEL_URL = 'https://storage.googleapis.com/mediapipe-models/object_detector/efficientdet_lite0/float32/1/efficientdet_lite0.tflite';
|
|
|
|
const SAMPLE_INTERVAL_MS = 200;
|
|
const NO_FACE_GRACE_MS = 1000;
|
|
const LOOKING_AWAY_GRACE_MS = 700;
|
|
const PROLONGED_LOOKING_AWAY_MS = 10000;
|
|
const PROLONGED_LOOKING_AWAY_RECHECK_MS = 120000;
|
|
const PHONE_EVENT_COOLDOWN_MS = 3000;
|
|
const SNAPSHOT_QUALITY = 0.72;
|
|
const HEAD_YAW_THRESHOLD = 0.34;
|
|
const HEAD_PITCH_UP_THRESHOLD = 0.2;
|
|
const HEAD_PITCH_DOWN_THRESHOLD = 0.74;
|
|
const GAZE_HORIZONTAL_LEFT_THRESHOLD = 0.28;
|
|
const GAZE_HORIZONTAL_RIGHT_THRESHOLD = 0.72;
|
|
const GAZE_DOWN_THRESHOLD = 0.62;
|
|
const CORNER_FIXATION_MS = 18000;
|
|
const CORNER_RECHECK_MS = 20000;
|
|
const CORNER_JITTER_GRACE_MS = 3000;
|
|
|
|
// --- State Variables for Violation & Focus Detection ---
|
|
let isTabSwitchScreenshotEnabled = true;
|
|
let lastTabSwitchTime = 0;
|
|
let pendingNativePrompts = 0;
|
|
let suppressFocusViolationsUntil = 0;
|
|
|
|
let screenShareStream = null;
|
|
let screenShareVideoElem = null;
|
|
let overlayCheckInterval = null;
|
|
let candidateSpeechHistory = [];
|
|
|
|
// --- Native Prompt Suppression Window ---
|
|
export function beginNativePrompt() {
|
|
pendingNativePrompts++;
|
|
}
|
|
|
|
export function endNativePrompt() {
|
|
pendingNativePrompts = Math.max(0, pendingNativePrompts - 1);
|
|
}
|
|
|
|
export function armTrailingBuffer(ms) {
|
|
suppressFocusViolationsUntil = Math.max(suppressFocusViolationsUntil, Date.now() + ms);
|
|
}
|
|
|
|
export function isFocusSuppressed() {
|
|
return pendingNativePrompts > 0 || Date.now() < suppressFocusViolationsUntil;
|
|
}
|
|
|
|
// --- Structured Logging & Backend Reporting ---
|
|
class StructuredEventLogger {
|
|
constructor(sessionId) {
|
|
this.sessionId = sessionId;
|
|
this.events = [];
|
|
}
|
|
|
|
record(type, confidence, details, snapshot = null) {
|
|
const event = {
|
|
schema: 'candidate-proctoring/v1',
|
|
session_id: this.sessionId,
|
|
event_id: `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
type,
|
|
timestamp: new Date().toISOString(),
|
|
confidence: Number(Math.max(0, Math.min(1, confidence || 0)).toFixed(4)),
|
|
details: details || {},
|
|
snapshot: snapshot || null,
|
|
};
|
|
this.events.push(event);
|
|
console.log('[proctoring:event]', event);
|
|
|
|
this.sendToServer(event);
|
|
|
|
return event;
|
|
}
|
|
|
|
sendToServer(event) {
|
|
const interviewId = this.sessionId || document.body.dataset.interviewId;
|
|
if (!interviewId) return;
|
|
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
|
|
|
|
fetch(`/candidate/log-violation/${interviewId}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken
|
|
},
|
|
body: JSON.stringify({
|
|
type: event.type,
|
|
details: typeof event.details === 'object' ? JSON.stringify(event.details) : event.details,
|
|
confidence: event.confidence,
|
|
snapshot: event.snapshot,
|
|
session_id: event.session_id,
|
|
event_id: event.event_id,
|
|
timestamp: event.timestamp
|
|
})
|
|
})
|
|
.then(r => r.json())
|
|
.then(() => {
|
|
if (typeof BroadcastChannel !== 'undefined') {
|
|
const callSigChannel = new BroadcastChannel('webrtc_call_' + interviewId);
|
|
const liveSyncChannel = new BroadcastChannel('live_sync_' + interviewId);
|
|
callSigChannel.postMessage({ type: 'violation_occurred', violation_type: event.type, details: event.details });
|
|
liveSyncChannel.postMessage({ type: 'violation_logged', violation_type: event.type, details: event.details });
|
|
}
|
|
if (typeof window.pollReviewData === 'function') {
|
|
window.pollReviewData();
|
|
}
|
|
})
|
|
.catch(err => console.log('[proctoring:sync-error]', err));
|
|
}
|
|
|
|
report(startedAt, endedAt, durations) {
|
|
const report = {
|
|
schema: 'candidate-proctoring/report-v1',
|
|
session_id: this.sessionId,
|
|
started_at: startedAt,
|
|
ended_at: endedAt,
|
|
generated_at: new Date().toISOString(),
|
|
review_required: true,
|
|
summary: {
|
|
looking_away_ms: Math.round(durations.lookingAwayMs),
|
|
no_face_ms: Math.round(durations.noFaceMs),
|
|
multiple_faces_ms: Math.round(durations.multipleFacesMs),
|
|
prolonged_looking_away_event_count: this.events.filter((event) => event.type === 'prolonged_looking_away').length,
|
|
phone_event_count: this.events.filter((event) => event.type === 'phone_detected').length,
|
|
total_events: this.events.length,
|
|
},
|
|
events: this.events,
|
|
};
|
|
console.log('[proctoring:report]', report);
|
|
return report;
|
|
}
|
|
}
|
|
|
|
export function logViolation(type, details, confidence = 1.0, snapshot = null) {
|
|
if (window.candidateProctoring && window.candidateProctoring.logger) {
|
|
window.candidateProctoring.logger.record(type, confidence, details, snapshot);
|
|
} else {
|
|
const interviewId = document.body.dataset.interviewId;
|
|
if (!interviewId) return;
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
|
|
|
|
fetch(`/candidate/log-violation/${interviewId}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken
|
|
},
|
|
body: JSON.stringify({
|
|
type: type,
|
|
details: typeof details === 'object' ? JSON.stringify(details) : details,
|
|
confidence: confidence,
|
|
snapshot: snapshot
|
|
})
|
|
})
|
|
.then(r => r.json())
|
|
.then(() => {
|
|
if (typeof BroadcastChannel !== 'undefined') {
|
|
const callSigChannel = new BroadcastChannel('webrtc_call_' + interviewId);
|
|
const liveSyncChannel = new BroadcastChannel('live_sync_' + interviewId);
|
|
callSigChannel.postMessage({ type: 'violation_occurred', violation_type: type, details: details });
|
|
liveSyncChannel.postMessage({ type: 'violation_logged', violation_type: type, details: details });
|
|
}
|
|
if (typeof window.pollReviewData === 'function') {
|
|
window.pollReviewData();
|
|
}
|
|
})
|
|
.catch(err => console.log('Log violation fetch error:', err));
|
|
}
|
|
}
|
|
|
|
// --- System Desktop Screenshot Capture Engine ---
|
|
function getScreenShareVideoElement() {
|
|
if (!screenShareVideoElem) {
|
|
screenShareVideoElem = document.createElement('video');
|
|
screenShareVideoElem.autoplay = true;
|
|
screenShareVideoElem.muted = true;
|
|
screenShareVideoElem.playsInline = true;
|
|
screenShareVideoElem.style.position = 'fixed';
|
|
screenShareVideoElem.style.top = '0px';
|
|
screenShareVideoElem.style.left = '0px';
|
|
screenShareVideoElem.style.width = '320px';
|
|
screenShareVideoElem.style.height = '180px';
|
|
screenShareVideoElem.style.opacity = '0.001';
|
|
screenShareVideoElem.style.pointerEvents = 'none';
|
|
screenShareVideoElem.style.zIndex = '-9999';
|
|
document.body.appendChild(screenShareVideoElem);
|
|
}
|
|
return screenShareVideoElem;
|
|
}
|
|
|
|
export function captureAndUploadTabScreenshot(reason = 'tab_switch') {
|
|
if (!isTabSwitchScreenshotEnabled) return;
|
|
const callStatusBadge = document.getElementById('call-status-badge');
|
|
const isCallActive = callStatusBadge ? callStatusBadge.textContent.includes('LIVE') || callStatusBadge.textContent.includes('CONNECTED') : true;
|
|
if (!isCallActive) return;
|
|
if (reason !== 'tab_switch' && reason !== 'call_start' && reason !== 'external_ai_detected' && reason !== 'copy_event') return;
|
|
|
|
try {
|
|
const tempCanvas = document.createElement('canvas');
|
|
let drewScreenStream = false;
|
|
|
|
const isCallStart = (reason === 'call_start');
|
|
const bannerLabel = isCallStart
|
|
? '📸 CALL STARTED • CANDIDATE SYSTEM SCREEN SNAPSHOT • ' + new Date().toLocaleString()
|
|
: '🚨 CANDIDATE SYSTEM COMPUTER DESKTOP SNAPSHOT • ' + new Date().toLocaleString();
|
|
|
|
if (typeof screenShareStream !== 'undefined' && screenShareStream && screenShareStream.getVideoTracks().length > 0) {
|
|
const screenTrack = screenShareStream.getVideoTracks()[0];
|
|
if (screenTrack && screenTrack.readyState === 'live') {
|
|
try {
|
|
const vidElem = getScreenShareVideoElement();
|
|
if (vidElem.srcObject !== screenShareStream) {
|
|
vidElem.srcObject = screenShareStream;
|
|
vidElem.play().catch(() => {});
|
|
}
|
|
|
|
let rawW = vidElem.videoWidth || 1280;
|
|
let rawH = vidElem.videoHeight || 720;
|
|
|
|
if (vidElem.readyState >= 2 && rawW > 0 && rawH > 0) {
|
|
const maxW = 1280;
|
|
let targetW = rawW;
|
|
let targetH = rawH;
|
|
if (targetW > maxW) {
|
|
targetH = Math.round(targetH * (maxW / targetW));
|
|
targetW = maxW;
|
|
}
|
|
|
|
tempCanvas.width = targetW;
|
|
tempCanvas.height = targetH;
|
|
const tempCtx = tempCanvas.getContext('2d');
|
|
tempCtx.drawImage(vidElem, 0, 0, targetW, targetH);
|
|
|
|
tempCtx.fillStyle = 'rgba(15, 23, 42, 0.90)';
|
|
tempCtx.fillRect(0, tempCanvas.height - 42, tempCanvas.width, 42);
|
|
tempCtx.fillStyle = isCallStart ? '#10b981' : '#ef4444';
|
|
tempCtx.font = 'bold 14px sans-serif';
|
|
tempCtx.fillText(bannerLabel, 16, tempCanvas.height - 15);
|
|
|
|
uploadTabScreenshotBlob(tempCanvas.toDataURL('image/jpeg', 0.75), reason);
|
|
drewScreenStream = true;
|
|
}
|
|
} catch(e) {
|
|
console.log('Screen stream draw exception:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (drewScreenStream) return;
|
|
|
|
if (typeof window.html2canvas !== 'undefined' && !document.hidden) {
|
|
window.html2canvas(document.body, { logging: false, useCORS: true, allowTaint: true, scale: 0.85 }).then(canvas => {
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.fillStyle = 'rgba(15, 23, 42, 0.90)';
|
|
ctx.fillRect(0, canvas.height - 42, canvas.width, 42);
|
|
ctx.fillStyle = isCallStart ? '#10b981' : '#ef4444';
|
|
ctx.font = 'bold 14px sans-serif';
|
|
ctx.fillText(bannerLabel, 16, canvas.height - 15);
|
|
uploadTabScreenshotBlob(canvas.toDataURL('image/jpeg', 0.75), reason);
|
|
}).catch(() => {
|
|
fallbackCompositeSnapshot(reason);
|
|
});
|
|
} else {
|
|
fallbackCompositeSnapshot(reason);
|
|
}
|
|
} catch(e) {
|
|
console.log('Candidate system screenshot capture exception:', e);
|
|
fallbackCompositeSnapshot(reason);
|
|
}
|
|
}
|
|
|
|
export function fallbackCompositeSnapshot(reason = 'tab_switch') {
|
|
const interviewId = document.body.dataset.interviewId || '';
|
|
const tempCanvas = document.createElement('canvas');
|
|
const w = 1280;
|
|
const h = 720;
|
|
tempCanvas.width = w;
|
|
tempCanvas.height = h;
|
|
const tempCtx = tempCanvas.getContext('2d');
|
|
|
|
tempCtx.fillStyle = '#0b0f19';
|
|
tempCtx.fillRect(0, 0, w, h);
|
|
tempCtx.fillStyle = '#1e293b';
|
|
tempCtx.fillRect(0, 0, w, 50);
|
|
tempCtx.fillStyle = '#38bdf8';
|
|
tempCtx.font = 'bold 16px sans-serif';
|
|
tempCtx.fillText('💻 Candidate System Workspace • ID: ' + interviewId, 20, 32);
|
|
|
|
const codeDisplay = document.querySelector('.CodeMirror') || document.getElementById('code-editor') || document.getElementById('monaco-editor-container');
|
|
tempCtx.fillStyle = '#020617';
|
|
tempCtx.fillRect(20, 70, w - 40, h - 130);
|
|
tempCtx.fillStyle = '#94a3b8';
|
|
tempCtx.font = '13px monospace';
|
|
const codeText = codeDisplay ? (codeDisplay.innerText || codeDisplay.value || '') : '';
|
|
const lines = codeText.split('\n').slice(0, 30);
|
|
lines.forEach((line, i) => {
|
|
tempCtx.fillText(line.substring(0, 120), 35, 95 + (i * 18));
|
|
});
|
|
|
|
const isCallStart = (reason === 'call_start');
|
|
tempCtx.fillStyle = 'rgba(15, 23, 42, 0.90)';
|
|
tempCtx.fillRect(0, tempCanvas.height - 42, tempCanvas.width, 42);
|
|
tempCtx.fillStyle = isCallStart ? '#10b981' : '#ef4444';
|
|
tempCtx.font = 'bold 14px sans-serif';
|
|
const text = isCallStart
|
|
? '📸 CALL STARTED • CANDIDATE SYSTEM SNAPSHOT • ' + new Date().toLocaleString()
|
|
: '🚨 CANDIDATE SYSTEM SNAPSHOT • ' + new Date().toLocaleString();
|
|
tempCtx.fillText(text, 16, tempCanvas.height - 15);
|
|
|
|
uploadTabScreenshotBlob(tempCanvas.toDataURL('image/jpeg', 0.85), reason);
|
|
}
|
|
|
|
export function uploadTabScreenshotBlob(imageData, reason = 'tab_switch') {
|
|
const interviewId = document.body.dataset.interviewId;
|
|
if (!interviewId) return;
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
|
|
|
|
fetch(`/candidate/upload-tab-screenshot/${interviewId}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken
|
|
},
|
|
body: JSON.stringify({ image: imageData, reason: reason })
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
console.log('Candidate system screenshot saved:', data);
|
|
})
|
|
.catch(err => console.log('System screenshot fetch error:', err));
|
|
}
|
|
|
|
export function isEntireScreenShare(track) {
|
|
if (!track || !track.getSettings) return true;
|
|
const settings = track.getSettings();
|
|
const surface = settings.displaySurface;
|
|
|
|
if (surface) {
|
|
const allowedSurfaces = ['monitor', 'screen'];
|
|
if (!allowedSurfaces.includes(surface)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (window.screen && window.screen.width && settings.width) {
|
|
if (settings.width < (window.screen.width * 0.65) && settings.height < (window.screen.height * 0.65)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
export function isScreenSharingActive() {
|
|
return Boolean(screenShareStream && screenShareStream.active && screenShareStream.getVideoTracks().some(t => t.readyState === 'live'));
|
|
}
|
|
|
|
export function enforceBrowserFullscreen() {
|
|
if (!isScreenSharingActive() || document.fullscreenElement) return;
|
|
|
|
beginNativePrompt();
|
|
if (document.documentElement.requestFullscreen) {
|
|
document.documentElement.requestFullscreen()
|
|
.then(() => {
|
|
endNativePrompt();
|
|
armTrailingBuffer(1000);
|
|
})
|
|
.catch(() => {
|
|
endNativePrompt();
|
|
showFullscreenRequiredModal();
|
|
});
|
|
} else {
|
|
endNativePrompt();
|
|
showFullscreenRequiredModal();
|
|
}
|
|
}
|
|
|
|
export function showFullscreenRequiredModal() {
|
|
if (!isScreenSharingActive() || document.fullscreenElement) return;
|
|
if (typeof window.Swal !== 'undefined') {
|
|
window.Swal.fire({
|
|
icon: 'warning',
|
|
title: 'Fullscreen Mode Required',
|
|
text: 'Your assessment must be conducted in Fullscreen mode while screen sharing. Please click below to enter Fullscreen.',
|
|
confirmButtonText: 'Enter Fullscreen Mode',
|
|
confirmButtonColor: '#6366f1',
|
|
allowOutsideClick: false,
|
|
allowEscapeKey: false
|
|
}).then(() => {
|
|
if (isScreenSharingActive()) {
|
|
enforceBrowserFullscreen();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// --- Screen Share Enforcement & Live Overlay Detector (Category 6) ---
|
|
export function startScreenShare() {
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
|
|
alert('Screen sharing is not supported on your browser.');
|
|
return;
|
|
}
|
|
|
|
beginNativePrompt();
|
|
|
|
navigator.mediaDevices.getDisplayMedia({
|
|
video: {
|
|
displaySurface: 'monitor'
|
|
},
|
|
audio: false
|
|
})
|
|
.then(stream => {
|
|
endNativePrompt();
|
|
const track = stream.getVideoTracks()[0];
|
|
|
|
// Enforce ENTIRE SCREEN share across all browsers
|
|
if (!isEntireScreenShare(track)) {
|
|
track.stop();
|
|
screenShareStream = null;
|
|
logViolation('tab_switch', 'Candidate attempted non-entire screen share (tab or window)');
|
|
|
|
if (typeof window.Swal !== 'undefined') {
|
|
window.Swal.fire({
|
|
icon: 'error',
|
|
title: 'Entire Screen Share Required',
|
|
html: 'You selected a single tab or window.<br><br><b>You MUST select "Entire Screen" (or whole monitor)</b> to continue your interview.',
|
|
confirmButtonText: 'Reshare Entire Screen Now',
|
|
confirmButtonColor: '#ef4444',
|
|
allowOutsideClick: false,
|
|
allowEscapeKey: false
|
|
}).then(() => {
|
|
setTimeout(startScreenShare, 300);
|
|
});
|
|
} else {
|
|
alert('Entire Screen share is required. Please select Entire Screen.');
|
|
setTimeout(startScreenShare, 300);
|
|
}
|
|
return;
|
|
}
|
|
|
|
screenShareStream = stream;
|
|
const vidElem = getScreenShareVideoElement();
|
|
vidElem.srcObject = stream;
|
|
vidElem.play().catch(() => {});
|
|
|
|
updateScreenShareButton(true);
|
|
|
|
enforceBrowserFullscreen();
|
|
|
|
const interviewId = document.body.dataset.interviewId;
|
|
if (typeof window.Peer !== 'undefined' && interviewId) {
|
|
const screenPeer = new window.Peer('cand_screen_' + interviewId + '_' + Date.now());
|
|
screenPeer.on('open', () => {
|
|
screenPeer.call('interviewer_screen_' + interviewId, stream);
|
|
});
|
|
}
|
|
|
|
track.onended = () => {
|
|
screenShareStream = null;
|
|
updateScreenShareButton(false);
|
|
|
|
if (document.fullscreenElement) {
|
|
try { document.exitFullscreen().catch(() => {}); } catch(e) {}
|
|
}
|
|
|
|
logViolation('tab_switch', 'Candidate stopped screen sharing');
|
|
};
|
|
|
|
initLiveOverlayDetector();
|
|
})
|
|
.catch(err => {
|
|
endNativePrompt();
|
|
console.log('Screen sharing cancelled/denied:', err);
|
|
screenShareStream = null;
|
|
updateScreenShareButton(false);
|
|
});
|
|
}
|
|
|
|
export function stopScreenShare() {
|
|
if (screenShareStream) {
|
|
try {
|
|
screenShareStream.getTracks().forEach(track => track.stop());
|
|
} catch(e) {}
|
|
screenShareStream = null;
|
|
}
|
|
updateScreenShareButton(false);
|
|
|
|
if (document.fullscreenElement) {
|
|
try { document.exitFullscreen().catch(() => {}); } catch(e) {}
|
|
}
|
|
|
|
logViolation('tab_switch', 'Candidate stopped screen sharing');
|
|
}
|
|
|
|
export function toggleScreenShare() {
|
|
if (screenShareStream && screenShareStream.active && screenShareStream.getVideoTracks().length > 0 && screenShareStream.getVideoTracks()[0].readyState === 'live') {
|
|
stopScreenShare();
|
|
} else {
|
|
startScreenShare();
|
|
}
|
|
}
|
|
|
|
export function updateScreenShareButton(active) {
|
|
const btn = document.getElementById('share-screen-btn');
|
|
if (!btn) return;
|
|
if (active) {
|
|
btn.innerHTML = '🛑 Stop Screen Sharing';
|
|
btn.style.background = '#ef4444';
|
|
btn.style.borderColor = '#dc2626';
|
|
btn.onclick = stopScreenShare;
|
|
} else {
|
|
btn.innerHTML = '🖥️ Share Screen with Interviewer';
|
|
btn.style.background = 'linear-gradient(135deg, #6366f1 0%, #0078d4 100%)';
|
|
btn.style.borderColor = '#6366f1';
|
|
btn.onclick = startScreenShare;
|
|
}
|
|
}
|
|
|
|
export function initLiveOverlayDetector() {
|
|
if (overlayCheckInterval) clearInterval(overlayCheckInterval);
|
|
|
|
overlayCheckInterval = setInterval(() => {
|
|
if (!screenShareStream || screenShareStream.getVideoTracks().length === 0) return;
|
|
const track = screenShareStream.getVideoTracks()[0];
|
|
if (track.readyState !== 'live') return;
|
|
|
|
const vidElem = getScreenShareVideoElement();
|
|
if (!vidElem || vidElem.videoWidth === 0) return;
|
|
|
|
try {
|
|
if (!document.hasFocus()) {
|
|
const now = Date.now();
|
|
if (now - lastTabSwitchTime > 3000) {
|
|
lastTabSwitchTime = now;
|
|
logViolation('tab_switch', 'Candidate window lost focus during active screen share');
|
|
captureAndUploadTabScreenshot('tab_switch');
|
|
}
|
|
}
|
|
} catch(e) {}
|
|
}, 2000);
|
|
}
|
|
|
|
// --- Continuous Speech Recognition (Category 5) ---
|
|
export function initQuestionRepeatDetection() {
|
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
if (!SpeechRecognition) return;
|
|
|
|
try {
|
|
const speechRec = new SpeechRecognition();
|
|
speechRec.continuous = true;
|
|
speechRec.interimResults = false;
|
|
speechRec.lang = 'en-US';
|
|
|
|
speechRec.onresult = function(event) {
|
|
for (let i = event.resultIndex; i < event.results.length; ++i) {
|
|
if (event.results[i].isFinal) {
|
|
const transcript = event.results[i][0].transcript.trim().toLowerCase();
|
|
if (transcript.length < 4) continue;
|
|
|
|
const pageText = (document.body ? document.body.innerText : '').toLowerCase();
|
|
|
|
const words = transcript.split(/\s+/).filter(w => w.length > 3);
|
|
let matchedWordCount = 0;
|
|
words.forEach(w => {
|
|
if (pageText.includes(w)) matchedWordCount++;
|
|
});
|
|
const isReadingQuestionOnScreen = words.length >= 3 && (matchedWordCount / words.length) >= 0.5;
|
|
|
|
const isRepeatedSpeech = candidateSpeechHistory.some(prev => {
|
|
return prev === transcript || (transcript.length > 8 && prev.includes(transcript)) || (prev.length > 8 && transcript.includes(prev));
|
|
});
|
|
|
|
candidateSpeechHistory.push(transcript);
|
|
if (candidateSpeechHistory.length > 15) candidateSpeechHistory.shift();
|
|
|
|
const phoneKeywords = ['hello', 'can you hear me', 'on phone', 'calling', 'call you back', 'hold on', 'on the line', 'speaker', 'phone', 'mobile', 'call me', 'speak later', 'hey call', 'on a call'];
|
|
const isPhoneTalk = phoneKeywords.some(kw => transcript.includes(kw));
|
|
const isCallConnected = window.isCallConnected ? window.isCallConnected() : false;
|
|
|
|
if (isPhoneTalk) {
|
|
logViolation('talking_on_phone', `Candidate detected talking on phone / phone call during assessment: "${transcript}"`);
|
|
} else if (isReadingQuestionOnScreen || isRepeatedSpeech) {
|
|
if (window.candidateProctoring?.lastStates?.lookingAway) {
|
|
logViolation('reading_external_device', `Candidate detected reading from external device or mobile screen: "${transcript}"`);
|
|
} else {
|
|
logViolation('question_repetition', `Candidate detected reading / repeating question out loud: "${transcript}" (suspected AI prompt ingestion)`);
|
|
}
|
|
} else {
|
|
if (!isCallConnected && transcript.length > 10) {
|
|
logViolation('talking_secondary_person', `Candidate speaking out loud to secondary person in room: "${transcript}"`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
speechRec.onerror = function() {
|
|
setTimeout(() => { try { speechRec.start(); } catch(e) {} }, 4000);
|
|
};
|
|
|
|
speechRec.onend = function() {
|
|
setTimeout(() => { try { speechRec.start(); } catch(e) {} }, 1500);
|
|
};
|
|
|
|
speechRec.start();
|
|
} catch(e) {}
|
|
}
|
|
|
|
// --- Candidate Proctoring Event Listeners (Tab focus, Clipboard, Hotkeys, DOM Observer, PiP, Fullscreen) ---
|
|
export function initCandidateProctoringListeners() {
|
|
// 1. Visibility Change (Tab Switched / Window Minimized)
|
|
document.addEventListener('visibilitychange', function () {
|
|
if (isFocusSuppressed()) return;
|
|
if (document.hidden) {
|
|
const now = Date.now();
|
|
if (now - lastTabSwitchTime > 1000) {
|
|
lastTabSwitchTime = now;
|
|
logViolation('tab_switch', 'Candidate switched browser tab or minimized window');
|
|
captureAndUploadTabScreenshot();
|
|
}
|
|
}
|
|
});
|
|
|
|
// 2. Window Blur (Assessment Window Lost Focus)
|
|
window.addEventListener('blur', function () {
|
|
if (isFocusSuppressed()) return;
|
|
const now = Date.now();
|
|
if (now - lastTabSwitchTime > 1000) {
|
|
lastTabSwitchTime = now;
|
|
logViolation('tab_switch', 'Candidate assessment window lost focus');
|
|
captureAndUploadTabScreenshot('tab_switch');
|
|
}
|
|
});
|
|
|
|
// 3. Mouse Viewport Departure
|
|
document.addEventListener('mouseleave', function () {
|
|
if (isFocusSuppressed()) return;
|
|
const now = Date.now();
|
|
if (now - lastTabSwitchTime > 2500) {
|
|
lastTabSwitchTime = now;
|
|
logViolation('tab_switch', 'Candidate cursor departed assessment window viewport');
|
|
captureAndUploadTabScreenshot('tab_switch');
|
|
}
|
|
});
|
|
|
|
// 4. Clipboard Events (Paste into Editor / Copy from Assessment Window)
|
|
document.addEventListener('paste', function () {
|
|
logViolation('paste_event', 'Candidate pasted content into editor window (possible AI answer paste)');
|
|
}, true);
|
|
|
|
document.addEventListener('copy', function () {
|
|
logViolation('copy_event', 'Candidate copied text from assessment window (possible Parakeet AI prompt ingestion)');
|
|
captureAndUploadTabScreenshot();
|
|
});
|
|
|
|
// 5. Global Keydown AI Hotkey Interception (Capture Phase)
|
|
window.addEventListener('keydown', function (e) {
|
|
if (e.code === 'Tab' || e.key === 'Tab') return;
|
|
|
|
const key = (e.key || '').toLowerCase();
|
|
const code = (e.code || '').toLowerCase();
|
|
const isAlt = e.altKey;
|
|
const isCmd = e.metaKey;
|
|
const isCtrl = e.ctrlKey;
|
|
const isShift = e.shiftKey;
|
|
|
|
const isAiHotkey = (isAlt && (key === ' ' || code === 'space' || key === 'p' || key === 'a' || key === 's' || key === 'c' || key === 'v' || key === 'q' || key === 'w' || key === 'e' || key === 'x' || key === 'z')) ||
|
|
(isCmd && (key === ' ' || code === 'space' || key === 'p' || key === 'a' || key === 's' || key === 'x')) ||
|
|
(isCtrl && isShift && (key === 'a' || key === 'p' || key === 's' || key === 'i' || key === 'c' || key === 'x')) ||
|
|
(isCmd && isShift && (key === 'a' || key === 'p' || key === 's' || key === 'x' || key === '4' || key === '3')) ||
|
|
(isCtrl && isAlt) ||
|
|
(key === 'f12' || code === 'f12' || key === 'f11' || code === 'f11');
|
|
|
|
if (isAiHotkey) {
|
|
logViolation('external_ai_detected', `candidate might use external ai: Intercepted AI assistant shortcut hotkey (${e.code || e.key})`);
|
|
captureAndUploadTabScreenshot('external_ai_detected');
|
|
}
|
|
}, true);
|
|
|
|
// 6. Injected Extension DOM Observer
|
|
try {
|
|
const aiMutationObserver = new MutationObserver(mutations => {
|
|
for (const mutation of mutations) {
|
|
for (const node of mutation.addedNodes) {
|
|
if (node.nodeType === 1) {
|
|
const nodeStr = ((node.id || '') + ' ' + (node.className || '') + ' ' + (node.getAttribute('src') || '')).toLowerCase();
|
|
if (nodeStr.includes('parakeet') || nodeStr.includes('finalround') || nodeStr.includes('copilot') || nodeStr.includes('ai-assistant') || nodeStr.includes('otter')) {
|
|
logViolation('external_ai_detected', 'candidate might use external ai: Injected AI extension overlay element detected in DOM (' + node.tagName + ')');
|
|
captureAndUploadTabScreenshot('external_ai_detected');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
aiMutationObserver.observe(document.documentElement, { childList: true, subtree: true });
|
|
} catch(e) {}
|
|
|
|
// 7. Picture-in-Picture Floating AI Window Detector
|
|
setInterval(function() {
|
|
if (document.pictureInPictureElement) {
|
|
logViolation('external_ai_detected', 'candidate might use external ai: External Picture-in-Picture floating AI window active');
|
|
}
|
|
}, 6000);
|
|
|
|
// 8. Fullscreen Exit Listener (Only enforced when screen sharing is active)
|
|
document.addEventListener('fullscreenchange', function() {
|
|
if (isFocusSuppressed() || !isScreenSharingActive()) return;
|
|
if (!document.fullscreenElement) {
|
|
logViolation('tab_switch', 'Candidate exited full-screen proctoring mode while screen sharing');
|
|
captureAndUploadTabScreenshot();
|
|
showFullscreenRequiredModal();
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- MediaPipe Proctoring Classes ---
|
|
|
|
class WarningPresenter {
|
|
constructor() {
|
|
this.element = null;
|
|
}
|
|
|
|
show(message) {
|
|
// Candidate-side violation notifications removed per directive.
|
|
// All violations are silently logged and sent to the server.
|
|
}
|
|
}
|
|
|
|
class SnapshotService {
|
|
constructor(video, canvas) {
|
|
this.video = video;
|
|
this.canvas = canvas;
|
|
}
|
|
|
|
capture() {
|
|
if (!this.video || this.video.readyState < 2 || !this.video.videoWidth) return null;
|
|
const width = Math.min(640, this.video.videoWidth);
|
|
const height = Math.round(width * this.video.videoHeight / this.video.videoWidth);
|
|
this.canvas.width = width;
|
|
this.canvas.height = height;
|
|
this.canvas.getContext('2d').drawImage(this.video, 0, 0, width, height);
|
|
return this.canvas.toDataURL('image/jpeg', SNAPSHOT_QUALITY);
|
|
}
|
|
}
|
|
|
|
class FacePoseDetector {
|
|
constructor(landmarker) {
|
|
this.landmarker = landmarker;
|
|
}
|
|
|
|
detect(video, timestamp) {
|
|
const result = this.landmarker.detectForVideo(video, timestamp);
|
|
const faces = result.faceLandmarks || [];
|
|
return {
|
|
count: faces.length,
|
|
pose: faces[0] ? this.estimatePose(faces[0]) : null,
|
|
gaze: faces[0] ? this.estimateGaze(faces[0]) : null,
|
|
};
|
|
}
|
|
|
|
estimatePose(landmarks) {
|
|
const point = (index) => landmarks[index];
|
|
const leftEye = point(33);
|
|
const rightEye = point(263);
|
|
const nose = point(1);
|
|
const mouth = point(13);
|
|
const eyeMidX = (leftEye.x + rightEye.x) / 2;
|
|
const eyeMidY = (leftEye.y + rightEye.y) / 2;
|
|
const eyeDistance = Math.max(0.001, Math.abs(rightEye.x - leftEye.x));
|
|
const yaw = (nose.x - eyeMidX) / eyeDistance;
|
|
const pitch = (nose.y - eyeMidY) / Math.max(0.001, Math.abs(mouth.y - eyeMidY));
|
|
return { yaw, pitch, confidence: Math.min(1, 0.5 + Math.abs(yaw) * 0.4 + Math.abs(pitch - 0.5) * 0.2) };
|
|
}
|
|
|
|
estimateGaze(landmarks) {
|
|
const leftEye = this.estimateEyeGaze(landmarks, {
|
|
corners: [33, 133],
|
|
top: [159, 158],
|
|
bottom: [145, 153],
|
|
iris: [468, 469, 470, 471, 472],
|
|
});
|
|
const rightEye = this.estimateEyeGaze(landmarks, {
|
|
corners: [362, 263],
|
|
top: [386, 385],
|
|
bottom: [374, 380],
|
|
iris: [473, 474, 475, 476, 477],
|
|
});
|
|
const eyes = [leftEye, rightEye].filter(Boolean);
|
|
if (!eyes.length) return null;
|
|
|
|
const horizontal = eyes.reduce((total, eye) => total + eye.horizontal, 0) / eyes.length;
|
|
const vertical = eyes.reduce((total, eye) => total + eye.vertical, 0) / eyes.length;
|
|
const confidence = Math.min(1, 0.45 + Math.max(Math.abs(horizontal - 0.5), Math.abs(vertical - 0.5)) * 1.4);
|
|
|
|
return { horizontal, vertical, confidence };
|
|
}
|
|
|
|
estimateEyeGaze(landmarks, config) {
|
|
const points = [...config.corners, ...config.top, ...config.bottom, ...config.iris].map((index) => landmarks[index]);
|
|
if (points.some((point) => !point)) return null;
|
|
|
|
const cornerA = landmarks[config.corners[0]];
|
|
const cornerB = landmarks[config.corners[1]];
|
|
const topY = this.averageCoordinate(landmarks, config.top, 'y');
|
|
const bottomY = this.averageCoordinate(landmarks, config.bottom, 'y');
|
|
const irisX = this.averageCoordinate(landmarks, config.iris, 'x');
|
|
const irisY = this.averageCoordinate(landmarks, config.iris, 'y');
|
|
const leftX = Math.min(cornerA.x, cornerB.x);
|
|
const rightX = Math.max(cornerA.x, cornerB.x);
|
|
|
|
return {
|
|
horizontal: this.clamp01((irisX - leftX) / Math.max(0.001, rightX - leftX)),
|
|
vertical: this.clamp01((irisY - topY) / Math.max(0.001, bottomY - topY)),
|
|
};
|
|
}
|
|
|
|
averageCoordinate(landmarks, indexes, coordinate) {
|
|
return indexes.reduce((total, index) => total + landmarks[index][coordinate], 0) / indexes.length;
|
|
}
|
|
|
|
clamp01(value) {
|
|
return Math.max(0, Math.min(1, value));
|
|
}
|
|
}
|
|
|
|
class LookingAwayClassifier {
|
|
classify(face) {
|
|
const reasons = [];
|
|
const pose = face.pose;
|
|
const gaze = face.gaze;
|
|
|
|
if (pose && Math.abs(pose.yaw) > HEAD_YAW_THRESHOLD) reasons.push('head_yaw');
|
|
if (pose && pose.pitch < HEAD_PITCH_UP_THRESHOLD) reasons.push('head_up');
|
|
if (pose && pose.pitch > HEAD_PITCH_DOWN_THRESHOLD) reasons.push('head_down');
|
|
if (gaze && gaze.horizontal < GAZE_HORIZONTAL_LEFT_THRESHOLD) reasons.push('eye_gaze_left');
|
|
if (gaze && gaze.horizontal > GAZE_HORIZONTAL_RIGHT_THRESHOLD) reasons.push('eye_gaze_right');
|
|
if (gaze && gaze.vertical > GAZE_DOWN_THRESHOLD) reasons.push('eye_gaze_down');
|
|
|
|
return {
|
|
active: reasons.length > 0,
|
|
confidence: this.confidence(reasons, pose, gaze),
|
|
details: {
|
|
reasons,
|
|
yaw: pose?.yaw ?? null,
|
|
pitch: pose?.pitch ?? null,
|
|
gaze_horizontal: gaze?.horizontal ?? null,
|
|
gaze_vertical: gaze?.vertical ?? null,
|
|
},
|
|
};
|
|
}
|
|
|
|
confidence(reasons, pose, gaze) {
|
|
if (!reasons.length) return 0;
|
|
return Math.max(
|
|
reasons.some((reason) => reason.startsWith('head_')) ? pose?.confidence || 0 : 0,
|
|
reasons.some((reason) => reason.startsWith('eye_')) ? gaze?.confidence || 0 : 0,
|
|
);
|
|
}
|
|
}
|
|
|
|
class PhoneDetector {
|
|
constructor(detector) {
|
|
this.detector = detector;
|
|
}
|
|
|
|
detect(video, timestamp) {
|
|
if (!this.detector || !video || video.readyState < 2 || !video.videoWidth) return null;
|
|
try {
|
|
const ts = Math.round(timestamp);
|
|
const result = this.detector.detectForVideo(video, ts);
|
|
const detections = result ? (result.detections || []) : [];
|
|
for (const detection of detections) {
|
|
const categories = detection.categories || [];
|
|
for (const category of categories) {
|
|
const name = (category.categoryName || category.displayName || '').toLowerCase();
|
|
const score = category.score || 0;
|
|
if (/cell phone|mobile phone|phone|remote|handheld|device|calculator|tablet/i.test(name) && score >= 0.15) {
|
|
console.log('[proctoring:phone_detected_raw]', { name, score });
|
|
return { confidence: score, label: category.categoryName || category.displayName || 'cell phone' };
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('[proctoring:phone_detect_error]', e.message);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
class DurationTracker {
|
|
constructor() {
|
|
this.activeSince = { lookingAway: null, noFace: null, multipleFaces: null };
|
|
this.total = { lookingAwayMs: 0, noFaceMs: 0, multipleFacesMs: 0 };
|
|
}
|
|
|
|
update(name, active, now) {
|
|
const startedAt = this.activeSince[name];
|
|
if (active && startedAt === null) this.activeSince[name] = now;
|
|
if (!active && startedAt !== null) {
|
|
this.total[`${name}Ms`] += now - startedAt;
|
|
this.activeSince[name] = null;
|
|
}
|
|
}
|
|
|
|
finish(now) {
|
|
Object.keys(this.activeSince).forEach((name) => this.update(name, false, now));
|
|
return { ...this.total };
|
|
}
|
|
}
|
|
|
|
class ProlongedLookingAwayTracker {
|
|
constructor({ thresholdMs, recheckMs }) {
|
|
this.thresholdMs = thresholdMs;
|
|
this.recheckMs = recheckMs;
|
|
this.startedAt = null;
|
|
this.nextCheckAt = null;
|
|
}
|
|
|
|
update(active, now) {
|
|
if (!active) {
|
|
this.reset();
|
|
return null;
|
|
}
|
|
|
|
if (this.startedAt === null) this.startedAt = now;
|
|
|
|
const durationMs = now - this.startedAt;
|
|
if (durationMs < this.thresholdMs || (this.nextCheckAt !== null && now < this.nextCheckAt)) return null;
|
|
|
|
const recheck = this.nextCheckAt !== null;
|
|
this.nextCheckAt = now + this.recheckMs;
|
|
|
|
return {
|
|
duration_ms: Math.round(durationMs),
|
|
threshold_ms: this.thresholdMs,
|
|
recheck,
|
|
recheck_interval_ms: this.recheckMs,
|
|
next_check_at: new Date(Date.now() + this.recheckMs).toISOString(),
|
|
};
|
|
}
|
|
|
|
reset() {
|
|
this.startedAt = null;
|
|
this.nextCheckAt = null;
|
|
}
|
|
}
|
|
|
|
class CornerGazeClassifier {
|
|
classify(face) {
|
|
const pose = face.pose;
|
|
const gaze = face.gaze;
|
|
if (!pose || !gaze) return null;
|
|
|
|
const horiz = gaze.horizontal;
|
|
const vert = gaze.vertical;
|
|
const yaw = pose.yaw;
|
|
const pitch = pose.pitch;
|
|
|
|
const isLeft = (horiz <= 0.42 || yaw <= -0.16);
|
|
const isRight = (horiz >= 0.58 || yaw >= 0.16);
|
|
const isTop = (vert <= 0.44 || pitch <= 0.38);
|
|
const isBottom = (vert >= 0.56 || pitch >= 0.62);
|
|
|
|
let corner = null;
|
|
let cornerLabel = null;
|
|
|
|
if (isLeft && isTop) {
|
|
corner = 'top_left';
|
|
cornerLabel = 'Top-Left Screen Corner';
|
|
} else if (isRight && isTop) {
|
|
corner = 'top_right';
|
|
cornerLabel = 'Top-Right Screen Corner';
|
|
} else if (isLeft && isBottom) {
|
|
corner = 'bottom_left';
|
|
cornerLabel = 'Bottom-Left Screen Corner';
|
|
} else if (isRight && isBottom) {
|
|
corner = 'bottom_right';
|
|
cornerLabel = 'Bottom-Right Screen Corner';
|
|
} else if (isBottom) {
|
|
corner = 'bottom_center';
|
|
cornerLabel = 'Screen Bottom / Downside';
|
|
} else if (isLeft) {
|
|
corner = 'screen_left';
|
|
cornerLabel = 'Screen Left Side';
|
|
} else if (isRight) {
|
|
corner = 'screen_right';
|
|
cornerLabel = 'Screen Right Side';
|
|
}
|
|
|
|
if (!corner) return null;
|
|
|
|
const confidence = Math.min(1, 0.5 + Math.max(Math.abs(horiz - 0.5), Math.abs(vert - 0.5)) * 1.2);
|
|
|
|
return {
|
|
corner,
|
|
corner_label: cornerLabel,
|
|
confidence,
|
|
details: {
|
|
corner,
|
|
corner_label: cornerLabel,
|
|
yaw,
|
|
pitch,
|
|
gaze_horizontal: horiz,
|
|
gaze_vertical: vert,
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
class CornerGazeTracker {
|
|
constructor({ thresholdMs, recheckMs, graceMs }) {
|
|
this.thresholdMs = thresholdMs;
|
|
this.recheckMs = recheckMs;
|
|
this.graceMs = graceMs;
|
|
|
|
this.currentCorner = null;
|
|
this.startedAt = null;
|
|
this.lastActiveAt = null;
|
|
this.nextCheckAt = null;
|
|
}
|
|
|
|
update(cornerResult, now) {
|
|
if (!cornerResult) {
|
|
if (this.lastActiveAt && (now - this.lastActiveAt) > this.graceMs) {
|
|
this.reset();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const activeCorner = cornerResult.corner;
|
|
|
|
const isRelatedZone = (this.currentCorner && (
|
|
(this.currentCorner.startsWith('bottom_') && activeCorner.startsWith('bottom_')) ||
|
|
(this.currentCorner.startsWith('top_') && activeCorner.startsWith('top_')) ||
|
|
(this.currentCorner === activeCorner)
|
|
));
|
|
|
|
if (!this.currentCorner || !isRelatedZone) {
|
|
this.currentCorner = activeCorner;
|
|
this.startedAt = now;
|
|
this.nextCheckAt = null;
|
|
} else {
|
|
this.currentCorner = activeCorner;
|
|
}
|
|
|
|
this.lastActiveAt = now;
|
|
|
|
const durationMs = now - this.startedAt;
|
|
if (durationMs < this.thresholdMs || (this.nextCheckAt !== null && now < this.nextCheckAt)) {
|
|
return null;
|
|
}
|
|
|
|
this.nextCheckAt = now + this.recheckMs;
|
|
|
|
return {
|
|
corner: activeCorner,
|
|
corner_label: cornerResult.corner_label,
|
|
duration_ms: Math.round(durationMs),
|
|
duration_s: Math.round(durationMs / 1000),
|
|
confidence: cornerResult.confidence,
|
|
details: {
|
|
...cornerResult.details,
|
|
duration_ms: Math.round(durationMs),
|
|
duration_s: Math.round(durationMs / 1000),
|
|
}
|
|
};
|
|
}
|
|
|
|
reset() {
|
|
this.currentCorner = null;
|
|
this.startedAt = null;
|
|
this.lastActiveAt = null;
|
|
this.nextCheckAt = null;
|
|
}
|
|
}
|
|
|
|
class CandidateProctoring {
|
|
constructor({ sessionId, video, canvas }) {
|
|
this.sessionId = sessionId;
|
|
this.video = video;
|
|
this.canvas = canvas;
|
|
this.logger = new StructuredEventLogger(sessionId);
|
|
this.warning = new WarningPresenter();
|
|
this.snapshot = new SnapshotService(video, canvas);
|
|
this.durations = new DurationTracker();
|
|
this.lookingAwayClassifier = new LookingAwayClassifier();
|
|
this.prolongedLookingAway = new ProlongedLookingAwayTracker({
|
|
thresholdMs: PROLONGED_LOOKING_AWAY_MS,
|
|
recheckMs: PROLONGED_LOOKING_AWAY_RECHECK_MS,
|
|
});
|
|
this.cornerGazeClassifier = new CornerGazeClassifier();
|
|
this.cornerGazeTracker = new CornerGazeTracker({
|
|
thresholdMs: CORNER_FIXATION_MS,
|
|
recheckMs: CORNER_RECHECK_MS,
|
|
graceMs: CORNER_JITTER_GRACE_MS,
|
|
});
|
|
this.startedAt = new Date().toISOString();
|
|
this.lastSampleAt = 0;
|
|
this.lastPhoneEventAt = 0;
|
|
this.lastStates = { lookingAway: false, noFace: false, multipleFaces: false };
|
|
this.running = false;
|
|
this.modelsReady = false;
|
|
this.reported = false;
|
|
}
|
|
|
|
async start() {
|
|
if (this.running) return;
|
|
try {
|
|
const vision = await this.importVisionModule();
|
|
const fileset = await vision.FilesetResolver.forVisionTasks(WASM_ROOT);
|
|
|
|
const faceLandmarker = await this.withAmdLoaderDisabled(() =>
|
|
this.createModel(vision.FaceLandmarker, fileset, {
|
|
modelAssetPath: FACE_MODEL_URL,
|
|
runningMode: 'VIDEO', numFaces: 3, minFaceDetectionConfidence: 0.5,
|
|
minFacePresenceConfidence: 0.5, minTrackingConfidence: 0.5,
|
|
})
|
|
);
|
|
this.faceDetector = new FacePoseDetector(faceLandmarker);
|
|
|
|
try {
|
|
const objectDetector = await this.withAmdLoaderDisabled(() =>
|
|
this.createModel(vision.ObjectDetector, fileset, {
|
|
modelAssetPath: OBJECT_MODEL_URL,
|
|
runningMode: 'VIDEO', maxResults: 10, scoreThreshold: 0.15,
|
|
})
|
|
);
|
|
this.phoneDetector = new PhoneDetector(objectDetector);
|
|
console.log('[proctoring:object_detector_ready]');
|
|
} catch (objErr) {
|
|
console.error('[proctoring:object_detector_failed]', objErr);
|
|
}
|
|
|
|
this.modelsReady = true;
|
|
this.running = true;
|
|
this.scheduleNextSample();
|
|
console.log('[proctoring:ready]', { schema: 'candidate-proctoring/v1', session_id: this.sessionId });
|
|
} catch (error) {
|
|
console.error('[proctoring:error]', { schema: 'candidate-proctoring/v1', session_id: this.sessionId, code: 'model_initialization_failed', message: error.message });
|
|
}
|
|
}
|
|
|
|
async importVisionModule() {
|
|
try {
|
|
return await import(/* @vite-ignore */ VISION_MODULE_URL);
|
|
} catch (primaryError) {
|
|
console.warn('[proctoring:warning]', {
|
|
schema: 'candidate-proctoring/v1', session_id: this.sessionId,
|
|
code: 'vision_cdn_fallback', message: primaryError.message,
|
|
});
|
|
return import(/* @vite-ignore */ VISION_MODULE_FALLBACK_URL);
|
|
}
|
|
}
|
|
|
|
async withAmdLoaderDisabled(callback) {
|
|
const amdGlobals = ['define', 'require', 'requirejs'];
|
|
const saved = amdGlobals.map((name) => ({ name, value: globalThis[name] }));
|
|
amdGlobals.forEach((name) => {
|
|
try {
|
|
delete globalThis[name];
|
|
} catch (error) {
|
|
globalThis[name] = undefined;
|
|
}
|
|
});
|
|
|
|
try {
|
|
return await callback();
|
|
} finally {
|
|
saved.forEach(({ name, value }) => {
|
|
if (value === undefined) delete globalThis[name];
|
|
else globalThis[name] = value;
|
|
});
|
|
}
|
|
}
|
|
|
|
async createModel(Model, fileset, options) {
|
|
try {
|
|
return await Model.createFromOptions(fileset, { ...options, baseOptions: { modelAssetPath: options.modelAssetPath, delegate: 'GPU' } });
|
|
} catch (gpuError) {
|
|
console.warn('[proctoring:warning]', { schema: 'candidate-proctoring/v1', session_id: this.sessionId, code: 'gpu_delegate_unavailable', message: gpuError.message });
|
|
return Model.createFromOptions(fileset, { ...options, baseOptions: { modelAssetPath: options.modelAssetPath, delegate: 'CPU' } });
|
|
}
|
|
}
|
|
|
|
scheduleNextSample() {
|
|
if (!this.running) return;
|
|
setTimeout(() => this.sample(), SAMPLE_INTERVAL_MS);
|
|
}
|
|
|
|
sample() {
|
|
if (!this.running) return;
|
|
if (this.video.readyState < 2 || !this.video.videoWidth) return this.scheduleNextSample();
|
|
const timestamp = performance.now();
|
|
try {
|
|
const face = this.faceDetector.detect(this.video, timestamp);
|
|
const phone = this.phoneDetector ? this.phoneDetector.detect(this.video, timestamp) : null;
|
|
this.processFace(face, timestamp);
|
|
this.processPhone(phone, timestamp);
|
|
} catch (error) {
|
|
console.error('[proctoring:error]', { schema: 'candidate-proctoring/v1', session_id: this.sessionId, code: 'frame_processing_failed', message: error.message });
|
|
}
|
|
this.lastSampleAt = timestamp;
|
|
this.scheduleNextSample();
|
|
}
|
|
|
|
processFace(result, now) {
|
|
const noFace = result.count === 0;
|
|
const multipleFaces = result.count > 1;
|
|
const lookingAwayResult = this.lookingAwayClassifier.classify(result);
|
|
const cornerGazeResult = this.cornerGazeClassifier.classify(result);
|
|
|
|
this.durations.update('noFace', noFace, now);
|
|
this.durations.update('multipleFaces', multipleFaces, now);
|
|
this.durations.update('lookingAway', lookingAwayResult.active, now);
|
|
|
|
this.transition('noFace', noFace, noFace ? 0.95 : 0, { face_count: result.count }, NO_FACE_GRACE_MS, 'Candidate face is not visible');
|
|
this.transition('multipleFaces', multipleFaces, multipleFaces ? 0.95 : 0, { face_count: result.count }, 0, 'Multiple faces detected');
|
|
this.transition('lookingAway', lookingAwayResult.active, lookingAwayResult.confidence, lookingAwayResult.details, LOOKING_AWAY_GRACE_MS, 'Please look toward the camera');
|
|
this.trackProlongedLookingAway(lookingAwayResult, now);
|
|
this.trackCornerGaze(cornerGazeResult, now);
|
|
}
|
|
|
|
transition(name, active, confidence, details, graceMs, message) {
|
|
if (active === this.lastStates[name]) return;
|
|
if (active && graceMs > 0 && this.durations.activeSince[name] && performance.now() - this.durations.activeSince[name] < graceMs) return;
|
|
this.lastStates[name] = active;
|
|
if (active) {
|
|
this.warning.show(message);
|
|
const eventName = name === 'lookingAway' ? 'looking_away' : name === 'noFace' ? 'face_missing' : 'multiple_faces';
|
|
this.logger.record(eventName, confidence, details, this.snapshot.capture());
|
|
}
|
|
}
|
|
|
|
trackProlongedLookingAway(lookingAwayResult, now) {
|
|
const prolongedEvent = this.prolongedLookingAway.update(lookingAwayResult.active, now);
|
|
if (!prolongedEvent) return;
|
|
|
|
this.warning.show(`Candidate has been looking away for over ${Math.round(PROLONGED_LOOKING_AWAY_MS / 1000)} seconds`);
|
|
this.logger.record('prolonged_looking_away', lookingAwayResult.confidence, {
|
|
...prolongedEvent,
|
|
...lookingAwayResult.details,
|
|
}, this.snapshot.capture());
|
|
}
|
|
|
|
trackCornerGaze(cornerResult, now) {
|
|
const cornerEvent = this.cornerGazeTracker.update(cornerResult, now);
|
|
if (!cornerEvent) return;
|
|
|
|
this.logger.record('prolonged_corner_gaze', cornerEvent.confidence, {
|
|
...cornerEvent.details,
|
|
}, this.snapshot.capture());
|
|
}
|
|
|
|
processPhone(phone, now) {
|
|
if (!phone || now - this.lastPhoneEventAt < PHONE_EVENT_COOLDOWN_MS) return;
|
|
this.lastPhoneEventAt = now;
|
|
this.warning.show('Possible phone detected in camera frame');
|
|
this.logger.record('phone_detected', phone.confidence, { label: phone.label }, this.snapshot.capture());
|
|
}
|
|
|
|
stop() {
|
|
if (this.reported) return null;
|
|
this.running = false;
|
|
this.reported = true;
|
|
return this.logger.report(this.startedAt, new Date().toISOString(), this.durations.finish(performance.now()));
|
|
}
|
|
}
|
|
|
|
// --- Candidate Proctoring Auto-Boot & Listener Registration ---
|
|
function bootCandidateProctoring() {
|
|
const video = document.getElementById('webcam');
|
|
const canvas = document.getElementById('proctor-canvas') || document.createElement('canvas');
|
|
const sessionId = document.body.dataset.interviewId;
|
|
if (!sessionId) return;
|
|
|
|
// Boot MediaPipe proctoring if webcam element exists
|
|
if (video && navigator.mediaDevices) {
|
|
const proctor = new CandidateProctoring({ sessionId, video, canvas });
|
|
window.candidateProctoring = proctor;
|
|
const startWhenReady = () => proctor.start();
|
|
if (video.readyState >= 2) startWhenReady();
|
|
video.addEventListener('loadeddata', startWhenReady, { once: true });
|
|
const callStatus = document.getElementById('call-status-badge');
|
|
if (callStatus) {
|
|
const statusObserver = new MutationObserver(() => {
|
|
if (/ended/i.test(callStatus.textContent || '')) proctor.stop();
|
|
});
|
|
statusObserver.observe(callStatus, { childList: true, characterData: true, subtree: true });
|
|
}
|
|
window.addEventListener('pagehide', () => proctor.stop(), { once: true });
|
|
}
|
|
|
|
// Initialize all candidate cheat detection event listeners & speech analysis
|
|
initCandidateProctoringListeners();
|
|
initQuestionRepeatDetection();
|
|
}
|
|
|
|
// --- Window Bindings for Proctoring ---
|
|
if (typeof window !== 'undefined') {
|
|
window.logViolation = logViolation;
|
|
window.captureAndUploadTabScreenshot = captureAndUploadTabScreenshot;
|
|
window.fallbackCompositeSnapshot = fallbackCompositeSnapshot;
|
|
window.uploadTabScreenshotBlob = uploadTabScreenshotBlob;
|
|
window.beginNativePrompt = beginNativePrompt;
|
|
window.endNativePrompt = endNativePrompt;
|
|
window.armTrailingBuffer = armTrailingBuffer;
|
|
window.isFocusSuppressed = isFocusSuppressed;
|
|
window.isScreenSharingActive = isScreenSharingActive;
|
|
window.startScreenShare = startScreenShare;
|
|
window.stopScreenShare = stopScreenShare;
|
|
window.toggleScreenShare = toggleScreenShare;
|
|
window.updateScreenShareButton = updateScreenShareButton;
|
|
window.initLiveOverlayDetector = initLiveOverlayDetector;
|
|
window.initQuestionRepeatDetection = initQuestionRepeatDetection;
|
|
window.initCandidateProctoringListeners = initCandidateProctoringListeners;
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', bootCandidateProctoring, { once: true });
|
|
} else {
|
|
bootCandidateProctoring();
|
|
}
|
|
}
|