proctor-sdk
Version:
A light-weight proctoring library for some of the commonly used proctoring events
609 lines (600 loc) • 23.5 kB
JavaScript
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