UNPKG

proctor-sdk

Version:

A light-weight proctoring library for some of the commonly used proctoring events

609 lines (600 loc) 23.5 kB
const WARNING_TYPES = { NO_FACE: 'NO_FACE', MULTIPLE_FACES: 'MULTIPLE_FACES', FULLSCREEN_EXIT: 'FULLSCREEN_EXIT', TAB_SWITCH: 'TAB_SWITCH', COPY_PASTE_ATTEMPT: 'COPY_PASTE_ATTEMPT', MULTIPLE_SCREENS: 'MULTIPLE_SCREENS' }; const STATUS_TYPES = { INITIALIZING: 'INITIALIZING', MODEL_LOADING: 'MODEL_LOADING', MODEL_LOADED: 'MODEL_LOADED', WEBCAM_REQUESTING: 'WEBCAM_REQUESTING', WEBCAM_READY: 'WEBCAM_READY', STARTING: 'STARTING', STARTED: 'STARTED', STOPPING: 'STOPPING', STOPPED: 'STOPPED', ERROR: 'ERROR', DESTROYED: 'DESTROYED' }; const DEFAULT_TFJS_PATH = 'https://cdn.jsdelivr.net/npm/@tensorflow/tfjs'; const DEFAULT_BLAZEFACE_PATH = 'https://cdn.jsdelivr.net/npm/@tensorflow-models/blazeface'; const DEFAULT_THROTTLE_DURATIONS = { [WARNING_TYPES.NO_FACE]: 3000, [WARNING_TYPES.MULTIPLE_FACES]: 3000, [WARNING_TYPES.FULLSCREEN_EXIT]: 1000, [WARNING_TYPES.TAB_SWITCH]: 1000, [WARNING_TYPES.COPY_PASTE_ATTEMPT]: 1000, [WARNING_TYPES.MULTIPLE_SCREENS]: 10000 }; class ConfigManager { constructor(userConfig) { if (!userConfig || !userConfig.containerElement) { throw new Error('ProctorSDK Config: Valid containerElement (ID string or HTMLElement) is required.'); } this.effectiveConfig = this._mergeConfig(userConfig); } _mergeConfig(userConfig) { const defaultConfig = { enabledChecks: { faceDetection: true, fullscreen: true, tabSwitch: true, copyPaste: true, multipleScreens: true }, tfjsModelPaths: { tfjs: DEFAULT_TFJS_PATH, blazeface: DEFAULT_BLAZEFACE_PATH }, callbacks: { onViolation: () => {}, onStatusChange: () => {}, onWebcamStreamReady: () => {}, onFacePredictions: () => {} }, violationThrottleDurations: {} }; const config = { ...defaultConfig, ...userConfig }; config.callbacks = { ...defaultConfig.callbacks, ...(userConfig.callbacks || {}) }; config.enabledChecks = { ...defaultConfig.enabledChecks, ...(userConfig.enabledChecks || {}) }; config.tfjsModelPaths = { ...defaultConfig.tfjsModelPaths, ...(userConfig.tfjsModelPaths || {}) }; config.effectiveThrottleDurations = { ...DEFAULT_THROTTLE_DURATIONS }; if (userConfig.violationThrottleDurations) { for (const key in userConfig.violationThrottleDurations) { if (DEFAULT_THROTTLE_DURATIONS.hasOwnProperty(key)) { config.effectiveThrottleDurations[key] = userConfig.violationThrottleDurations[key]; } } } return config; } getConfig() { return this.effectiveConfig; } } class StreamManager { constructor(containerElementConfig) { this.videoElement = null; this.canvasElement = null; this.canvasCtx = null; this.webcamStream = null; this.containerElement = this._resolveContainerElement(containerElementConfig); this._initializeDOMElements(); } _resolveContainerElement(containerElementConfig) { let container; if (typeof containerElementConfig === 'string') { container = document.getElementById(containerElementConfig); } else if (containerElementConfig instanceof HTMLElement) { container = containerElementConfig; } if (!container) { throw new Error(`StreamManager: Container element "${containerElementConfig}" not found or invalid.`); } return container; } _initializeDOMElements() { const containerPosition = window.getComputedStyle(this.containerElement).position; if (containerPosition === 'static') { console.warn('ProctorSDK StreamManager: Host container element has static positioning. For best results, set position to relative, absolute, or fixed.'); } this.containerElement.innerHTML = ''; this.videoElement = document.createElement('video'); this.videoElement.setAttribute('autoplay', ''); this.videoElement.setAttribute('playsinline', ''); this.videoElement.style.width = '100%'; this.videoElement.style.height = '100%'; this.videoElement.style.objectFit = 'cover'; this.videoElement.style.transform = 'scaleX(-1)'; this.canvasElement = document.createElement('canvas'); this.canvasElement.style.position = 'absolute'; this.canvasElement.style.top = '0'; this.canvasElement.style.left = '0'; this.canvasElement.style.width = '100%'; this.canvasElement.style.height = '100%'; this.canvasElement.style.transform = 'scaleX(-1)'; this.containerElement.appendChild(this.videoElement); this.containerElement.appendChild(this.canvasElement); this.canvasCtx = this.canvasElement.getContext('2d'); } async acquireStream() { if (this.webcamStream) { this.webcamStream.getTracks().forEach(track => track.stop()); } this.webcamStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } } }); this.videoElement.srcObject = this.webcamStream; await new Promise((resolve, reject) => { this.videoElement.onloadedmetadata = resolve; this.videoElement.onerror = e => reject(new Error("StreamManager: Failed to load video metadata: " + (e.message || "Unknown video error"))); }); this.canvasElement.width = this.videoElement.videoWidth; this.canvasElement.height = this.videoElement.videoHeight; return this.webcamStream; } releaseStream() { if (this.webcamStream) { this.webcamStream.getTracks().forEach(track => track.stop()); this.webcamStream = null; } if (this.videoElement) this.videoElement.srcObject = null; if (this.canvasCtx && this.canvasElement) { this.canvasCtx.clearRect(0, 0, this.canvasElement.width, this.canvasElement.height); } } getVideoElement() { return this.videoElement; } getCanvasElement() { return this.canvasElement; } getCanvasContext() { return this.canvasCtx; } getWebcamStream() { return this.webcamStream; } destroy() { this.releaseStream(); if (this.containerElement) this.containerElement.innerHTML = ''; this.videoElement = null; this.canvasElement = null; this.canvasCtx = null; this.containerElement = null; } } function loadScript(src) { return new Promise((resolve, reject) => { if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; } const script = document.createElement('script'); script.src = src; script.async = true; script.onload = resolve; script.onerror = () => reject(new Error(`Failed to load script: ${src}`)); document.head.appendChild(script); }); } class ModelManager { constructor(tfjsPath, blazefacePath) { this.tfjsPath = tfjsPath; this.blazefacePath = blazefacePath; this.model = null; } async loadModels() { if (this.model) return this.model; if (typeof tf === 'undefined') { await loadScript(this.tfjsPath); if (typeof tf === 'undefined') throw new Error('ModelManager: TensorFlow.js (tf) not available after loading script.'); } if (typeof blazeface === 'undefined') { await loadScript(this.blazefacePath); if (typeof blazeface === 'undefined') throw new Error('ModelManager: BlazeFace (blazeface) not available after loading script.'); } if (typeof blazeface.load !== 'function') { throw new Error('ModelManager: BlazeFace library or "load" function not available.'); } this.model = await blazeface.load(); return this.model; } getModel() { return this.model; } destroy() { this.model = null; } } class FaceDetector { constructor(model, videoElement, canvasCtx, dispatchViolationCallback, onFacePredictionsCallback, isRunningGetter) { this.model = model; this.videoElement = videoElement; this.canvasCtx = canvasCtx; this.dispatchViolation = dispatchViolationCallback; this.onFacePredictions = onFacePredictionsCallback; this.isRunning = isRunningGetter; this.animationFrameId = null; } startDetectionLoop() { if (!this.model) { console.error("FaceDetector: Model not available for starting detection loop."); return; } this._detectFacesLoop(); } stopDetectionLoop() { if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; } } async _detectFacesLoop() { if (!this.isRunning() || !this.model || !this.videoElement || this.videoElement.readyState < 4) { if (this.isRunning()) { this.animationFrameId = requestAnimationFrame(this._detectFacesLoop.bind(this)); } return; } try { const predictions = await this.model.estimateFaces(this.videoElement, false); try { this.onFacePredictions(predictions); } catch (e) { console.error("FaceDetector: Error in user's onFacePredictions callback:", e); } if (this.canvasCtx && this.videoElement) { this.canvasCtx.clearRect(0, 0, this.canvasCtx.canvas.width, this.canvasCtx.canvas.height); } if (predictions.length === 0) { this.dispatchViolation(WARNING_TYPES.NO_FACE, true); this.dispatchViolation(WARNING_TYPES.MULTIPLE_FACES, false); } else if (predictions.length === 1) { this.dispatchViolation(WARNING_TYPES.NO_FACE, false); this.dispatchViolation(WARNING_TYPES.MULTIPLE_FACES, false); } else { this.dispatchViolation(WARNING_TYPES.NO_FACE, false); this.dispatchViolation(WARNING_TYPES.MULTIPLE_FACES, true, { count: predictions.length }); } this._drawFaceBoxes(predictions); } catch (error) { console.error('FaceDetector: Error during face detection:', error); } if (this.isRunning()) { this.animationFrameId = requestAnimationFrame(this._detectFacesLoop.bind(this)); } } _drawFaceBoxes(predictions) { const ctx = this.canvasCtx; if (!ctx || !ctx.canvas || !ctx.canvas.width || !ctx.canvas.height) return; let strokeStyle = '#4CAF50'; ctx.strokeStyle = strokeStyle; ctx.lineWidth = 3; ctx.font = '14px Arial'; predictions.forEach((prediction, index) => { const topLeft = prediction.topLeft; const bottomRight = prediction.bottomRight; const startX = Number(topLeft[0]); const startY = Number(topLeft[1]); const endX = Number(bottomRight[0]); const endY = Number(bottomRight[1]); if (isNaN(startX) || isNaN(startY) || isNaN(endX) || isNaN(endY)) return; const width = endX - startX; const height = endY - startY; ctx.strokeRect(startX, startY, width, height); const label = `Face ${index + 1}`; const textMetrics = ctx.measureText(label); ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; ctx.fillRect(startX, startY - 20, textMetrics.width + 10, 20); ctx.fillStyle = 'white'; ctx.fillText(label, startX + 5, startY - 5); }); } } class EnvironmentMonitor { constructor(dispatchViolationCallback, isRunningGetter, addManagedEventListener, removeAllManagedEventListenersForContext) { this.dispatchViolation = dispatchViolationCallback; this.isRunning = isRunningGetter; this.addManagedEventListener = addManagedEventListener; this.removeAllManagedEventListenersForContext = removeAllManagedEventListenersForContext; this.listenerContext = 'environmentMonitor'; this.boundListeners = {}; } startMonitoring() { this._checkFullscreen(); this.boundListeners.checkFullscreen = this._checkFullscreen.bind(this); this.addManagedEventListener(document, 'fullscreenchange', this.boundListeners.checkFullscreen, this.listenerContext); this.addManagedEventListener(document, 'webkitfullscreenchange', this.boundListeners.checkFullscreen, this.listenerContext); this.addManagedEventListener(document, 'mozfullscreenchange', this.boundListeners.checkFullscreen, this.listenerContext); this.addManagedEventListener(document, 'MSFullscreenChange', this.boundListeners.checkFullscreen, this.listenerContext); this._handleVisibilityChange(); this.boundListeners.handleVisibilityChange = this._handleVisibilityChange.bind(this); this.addManagedEventListener(document, 'visibilitychange', this.boundListeners.handleVisibilityChange, this.listenerContext); this.boundListeners.handleCopy = e => this._handleCopyPasteAttempt(e, 'copy'); this.boundListeners.handlePaste = e => this._handleCopyPasteAttempt(e, 'paste'); this.boundListeners.handleCut = e => this._handleCopyPasteAttempt(e, 'cut'); this.addManagedEventListener(document, 'copy', this.boundListeners.handleCopy, this.listenerContext); this.addManagedEventListener(document, 'paste', this.boundListeners.handlePaste, this.listenerContext); this.addManagedEventListener(document, 'cut', this.boundListeners.handleCut, this.listenerContext); this._checkMultipleScreens(); } stopMonitoring() { this.removeAllManagedEventListenersForContext(this.listenerContext); } _checkFullscreen() { if (!this.isRunning()) return; const isFullScreen = !!(document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement); this.dispatchViolation(WARNING_TYPES.FULLSCREEN_EXIT, !isFullScreen); } _handleVisibilityChange() { if (!this.isRunning()) return; if (document.visibilityState === 'hidden') { this.dispatchViolation(WARNING_TYPES.TAB_SWITCH, true); } else { this.dispatchViolation(WARNING_TYPES.TAB_SWITCH, false); } } _handleCopyPasteAttempt(event, eventType) { if (!this.isRunning()) return; this.dispatchViolation(WARNING_TYPES.COPY_PASTE_ATTEMPT, true, { eventType: event.type }); } _checkMultipleScreens() { if (!this.isRunning()) return; if (window.screen && typeof window.screen.isExtended !== 'undefined') { this.dispatchViolation(WARNING_TYPES.MULTIPLE_SCREENS, window.screen.isExtended); } else { console.info('EnvironmentMonitor: Multiple screen detection (window.screen.isExtended) not supported. Assuming single screen.'); this.dispatchViolation(WARNING_TYPES.MULTIPLE_SCREENS, false); } } } class ProctorSDK { constructor(userConfig) { try { this.configManager = new ConfigManager(userConfig); } catch (e) { console.error("ProctorSDK Fatal Error: Initial configuration failed.", e); throw e; } this.config = this.configManager.getConfig(); this.internalState = { isRunning: false, activeWarningFlags: this._getInitialWarningFlags(), multipleFaceCount: 0, lastViolationCallTimestamps: this._getInitialViolationTimestamps(), eventListeners: [] }; try { this.streamManager = new StreamManager(this.config.containerElement); this.modelManager = new ModelManager(this.config.tfjsModelPaths.tfjs, this.config.tfjsModelPaths.blazeface); } catch (e) { this._setStatus(STATUS_TYPES.ERROR, "SDK Initialization failed (Stream/Model Manager).", e); throw e; } this.faceDetector = null; this.environmentMonitor = null; this._setStatus(STATUS_TYPES.INITIALIZING, 'SDK initialized.'); } _getInitialWarningFlags() { const flags = {}; for (const key in WARNING_TYPES) flags[WARNING_TYPES[key]] = false; return flags; } _getInitialViolationTimestamps() { const timestamps = {}; for (const key in WARNING_TYPES) timestamps[WARNING_TYPES[key]] = 0; return timestamps; } _setStatus(statusType) { let message = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ''; let error = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : null; const statusData = { status: statusType, message, ...(error && { error }) }; try { if (this.config && this.config.callbacks && this.config.callbacks.onStatusChange) { this.config.callbacks.onStatusChange(statusData); } } catch (e) { console.error("ProctorSDK: Error in onStatusChange callback:", e); } if (error) console.error(`ProctorSDK Error: ${message}`, error);else console.log(`ProctorSDK Status: ${statusType} - ${message}`); } _dispatchViolation(type, isActive) { let details = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; if (!this.config || !this.config.callbacks || !this.config.callbacks.onViolation) return; const wasActive = this.internalState.activeWarningFlags[type]; this.internalState.activeWarningFlags[type] = isActive; if (type === WARNING_TYPES.MULTIPLE_FACES && isActive) this.internalState.multipleFaceCount = details.count || 0; const violationMessage = this._getViolationMessage(type, details); const violationData = { type, active: isActive, message: violationMessage, timestamp: new Date(), details }; const now = Date.now(); const lastCallTime = this.internalState.lastViolationCallTimestamps[type] || 0; const throttleDuration = this.config.effectiveThrottleDurations[type] || 0; let shouldCallCallback = false; if (isActive) { if (now - lastCallTime > throttleDuration) { shouldCallCallback = true; this.internalState.lastViolationCallTimestamps[type] = now; } } else if (wasActive && !isActive) { shouldCallCallback = true; this.internalState.lastViolationCallTimestamps[type] = 0; } if (shouldCallCallback) { try { this.config.callbacks.onViolation(violationData); } catch (e) { console.error("ProctorSDK: Error in onViolation callback:", e); } } } _getViolationMessage(type, details) { switch (type) { case WARNING_TYPES.NO_FACE: return 'No face detected!'; case WARNING_TYPES.MULTIPLE_FACES: return `Multiple faces detected: ${details.count || 'N/A'}`; case WARNING_TYPES.FULLSCREEN_EXIT: return 'User is not in fullscreen mode!'; case WARNING_TYPES.TAB_SWITCH: return 'User switched tabs or minimized window!'; case WARNING_TYPES.COPY_PASTE_ATTEMPT: return `User action: ${details.eventType || 'copy/paste/cut'} attempt detected!`; case WARNING_TYPES.MULTIPLE_SCREENS: return 'Multiple screens detected (extended display)!'; default: return 'Unknown violation.'; } } _addManagedEventListener(target, type, listener) { let context = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 'global'; target.addEventListener(type, listener); this.internalState.eventListeners.push({ target, type, listener, context }); } _removeAllManagedEventListeners() { let context = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null; const listenersToRemove = context ? this.internalState.eventListeners.filter(l => l.context === context) : this.internalState.eventListeners; const remainingListeners = context ? this.internalState.eventListeners.filter(l => l.context !== context) : []; listenersToRemove.forEach(_ref => { let { target, type, listener } = _ref; target.removeEventListener(type, listener); }); this.internalState.eventListeners = remainingListeners; } async start() { if (this.internalState.isRunning) { console.warn('ProctorSDK: Already running.'); return; } this._setStatus(STATUS_TYPES.STARTING, 'Attempting to start...'); try { if (this.config.enabledChecks.faceDetection) { this._setStatus(STATUS_TYPES.MODEL_LOADING); await this.modelManager.loadModels(); this._setStatus(STATUS_TYPES.MODEL_LOADED, 'Face detection model ready.'); } this._setStatus(STATUS_TYPES.WEBCAM_REQUESTING); const stream = await this.streamManager.acquireStream(); this._setStatus(STATUS_TYPES.WEBCAM_READY, 'Webcam stream acquired.'); try { this.config.callbacks.onWebcamStreamReady(stream); } catch (e) { console.error("ProctorSDK: Error in onWebcamStreamReady callback", e); } this.internalState.isRunning = true; if (this.config.enabledChecks.faceDetection && this.modelManager.getModel()) { this.faceDetector = new FaceDetector(this.modelManager.getModel(), this.streamManager.getVideoElement(), this.streamManager.getCanvasContext(), this._dispatchViolation.bind(this), this.config.callbacks.onFacePredictions, () => this.internalState.isRunning); this.faceDetector.startDetectionLoop(); } if (this.config.enabledChecks.fullscreen || this.config.enabledChecks.tabSwitch || this.config.enabledChecks.copyPaste || this.config.enabledChecks.multipleScreens) { this.environmentMonitor = new EnvironmentMonitor(this._dispatchViolation.bind(this), () => this.internalState.isRunning, this._addManagedEventListener.bind(this), context => this._removeAllManagedEventListeners(context)); this.environmentMonitor.startMonitoring(); } this._setStatus(STATUS_TYPES.STARTED, 'Proctoring started successfully.'); } catch (error) { this._setStatus(STATUS_TYPES.ERROR, 'Failed to start proctoring.', error); this.stop(); } } stop() { this._setStatus(STATUS_TYPES.STOPPING, 'Stopping proctoring...'); this.internalState.isRunning = false; if (this.faceDetector) { this.faceDetector.stopDetectionLoop(); this.faceDetector = null; } if (this.environmentMonitor) { this.environmentMonitor = null; } this.streamManager.releaseStream(); this._removeAllManagedEventListeners(); for (const type in this.internalState.activeWarningFlags) { if (this.internalState.activeWarningFlags[type]) { this._dispatchViolation(type, false); } } this.internalState.lastViolationCallTimestamps = this._getInitialViolationTimestamps(); this._setStatus(STATUS_TYPES.STOPPED, 'Proctoring stopped.'); } isProctoringActive() { return this.internalState.isRunning; } requestFullscreen() { const elem = this.streamManager.getVideoElement()?.parentElement || document.documentElement; if (!elem) return Promise.reject(new Error('Container element not available for fullscreen request.')); if (elem.requestFullscreen) return elem.requestFullscreen(); if (elem.webkitRequestFullscreen) return elem.webkitRequestFullscreen(); if (elem.mozRequestFullScreen) return elem.mozRequestFullScreen(); if (elem.msRequestFullscreen) return elem.msRequestFullscreen(); return Promise.reject(new Error('Fullscreen API not supported.')); } destroy() { this.stop(); if (this.streamManager) this.streamManager.destroy(); if (this.modelManager) this.modelManager.destroy(); this.streamManager = null; this.modelManager = null; this.configManager = null; this.config = { callbacks: {}, enabledChecks: {}, tfjsModelPaths: {}, effectiveThrottleDurations: {} }; this._setStatus(STATUS_TYPES.DESTROYED, 'SDK instance destroyed.'); } } ProctorSDK.WARNING_TYPES = WARNING_TYPES; ProctorSDK.STATUS_TYPES = STATUS_TYPES; export { STATUS_TYPES, WARNING_TYPES, ProctorSDK as default }; //# sourceMappingURL=index.esm.js.map