UNPKG

proctor-sdk

Version:

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

3 lines (2 loc) 16.3 kB
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).ProctorSDK={})}(this,(function(e){"use strict";const t={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"},n={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"},i={[t.NO_FACE]:3e3,[t.MULTIPLE_FACES]:3e3,[t.FULLSCREEN_EXIT]:1e3,[t.TAB_SWITCH]:1e3,[t.COPY_PASTE_ATTEMPT]:1e3,[t.MULTIPLE_SCREENS]:1e4};class s{constructor(e){if(!e||!e.containerElement)throw new Error("ProctorSDK Config: Valid containerElement (ID string or HTMLElement) is required.");this.effectiveConfig=this._mergeConfig(e)}_mergeConfig(e){const t={enabledChecks:{faceDetection:!0,fullscreen:!0,tabSwitch:!0,copyPaste:!0,multipleScreens:!0},tfjsModelPaths:{tfjs:"https://cdn.jsdelivr.net/npm/@tensorflow/tfjs",blazeface:"https://cdn.jsdelivr.net/npm/@tensorflow-models/blazeface"},callbacks:{onViolation:()=>{},onStatusChange:()=>{},onWebcamStreamReady:()=>{},onFacePredictions:()=>{}},violationThrottleDurations:{}},n={...t,...e};if(n.callbacks={...t.callbacks,...e.callbacks||{}},n.enabledChecks={...t.enabledChecks,...e.enabledChecks||{}},n.tfjsModelPaths={...t.tfjsModelPaths,...e.tfjsModelPaths||{}},n.effectiveThrottleDurations={...i},e.violationThrottleDurations)for(const t in e.violationThrottleDurations)i.hasOwnProperty(t)&&(n.effectiveThrottleDurations[t]=e.violationThrottleDurations[t]);return n}getConfig(){return this.effectiveConfig}}class a{constructor(e){this.videoElement=null,this.canvasElement=null,this.canvasCtx=null,this.webcamStream=null,this.containerElement=this._resolveContainerElement(e),this._initializeDOMElements()}_resolveContainerElement(e){let t;if("string"==typeof e?t=document.getElementById(e):e instanceof HTMLElement&&(t=e),!t)throw new Error(`StreamManager: Container element "${e}" not found or invalid.`);return t}_initializeDOMElements(){"static"===window.getComputedStyle(this.containerElement).position&&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(){return this.webcamStream&&this.webcamStream.getTracks().forEach((e=>e.stop())),this.webcamStream=await navigator.mediaDevices.getUserMedia({video:{facingMode:"user",width:{ideal:640},height:{ideal:480}}}),this.videoElement.srcObject=this.webcamStream,await new Promise(((e,t)=>{this.videoElement.onloadedmetadata=e,this.videoElement.onerror=e=>t(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,this.webcamStream}releaseStream(){this.webcamStream&&(this.webcamStream.getTracks().forEach((e=>e.stop())),this.webcamStream=null),this.videoElement&&(this.videoElement.srcObject=null),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(),this.containerElement&&(this.containerElement.innerHTML=""),this.videoElement=null,this.canvasElement=null,this.canvasCtx=null,this.containerElement=null}}function o(e){return new Promise(((t,n)=>{if(document.querySelector(`script[src="${e}"]`))return void t();const i=document.createElement("script");i.src=e,i.async=!0,i.onload=t,i.onerror=()=>n(new Error(`Failed to load script: ${e}`)),document.head.appendChild(i)}))}class r{constructor(e,t){this.tfjsPath=e,this.blazefacePath=t,this.model=null}async loadModels(){if(this.model)return this.model;if("undefined"==typeof tf&&(await o(this.tfjsPath),"undefined"==typeof tf))throw new Error("ModelManager: TensorFlow.js (tf) not available after loading script.");if("undefined"==typeof blazeface&&(await o(this.blazefacePath),"undefined"==typeof blazeface))throw new Error("ModelManager: BlazeFace (blazeface) not available after loading script.");if("function"!=typeof blazeface.load)throw new Error('ModelManager: BlazeFace library or "load" function not available.');return this.model=await blazeface.load(),this.model}getModel(){return this.model}destroy(){this.model=null}}class l{constructor(e,t,n,i,s,a){this.model=e,this.videoElement=t,this.canvasCtx=n,this.dispatchViolation=i,this.onFacePredictions=s,this.isRunning=a,this.animationFrameId=null}startDetectionLoop(){this.model?this._detectFacesLoop():console.error("FaceDetector: Model not available for starting detection loop.")}stopDetectionLoop(){this.animationFrameId&&(cancelAnimationFrame(this.animationFrameId),this.animationFrameId=null)}async _detectFacesLoop(){if(this.isRunning()&&this.model&&this.videoElement&&!(this.videoElement.readyState<4)){try{const e=await this.model.estimateFaces(this.videoElement,!1);try{this.onFacePredictions(e)}catch(e){console.error("FaceDetector: Error in user's onFacePredictions callback:",e)}this.canvasCtx&&this.videoElement&&this.canvasCtx.clearRect(0,0,this.canvasCtx.canvas.width,this.canvasCtx.canvas.height),0===e.length?(this.dispatchViolation(t.NO_FACE,!0),this.dispatchViolation(t.MULTIPLE_FACES,!1)):1===e.length?(this.dispatchViolation(t.NO_FACE,!1),this.dispatchViolation(t.MULTIPLE_FACES,!1)):(this.dispatchViolation(t.NO_FACE,!1),this.dispatchViolation(t.MULTIPLE_FACES,!0,{count:e.length})),this._drawFaceBoxes(e)}catch(e){console.error("FaceDetector: Error during face detection:",e)}this.isRunning()&&(this.animationFrameId=requestAnimationFrame(this._detectFacesLoop.bind(this)))}else this.isRunning()&&(this.animationFrameId=requestAnimationFrame(this._detectFacesLoop.bind(this)))}_drawFaceBoxes(e){const t=this.canvasCtx;if(!(t&&t.canvas&&t.canvas.width&&t.canvas.height))return;t.strokeStyle="#4CAF50",t.lineWidth=3,t.font="14px Arial",e.forEach(((e,n)=>{const i=e.topLeft,s=e.bottomRight,a=Number(i[0]),o=Number(i[1]),r=Number(s[0]),l=Number(s[1]);if(isNaN(a)||isNaN(o)||isNaN(r)||isNaN(l))return;const c=r-a,h=l-o;t.strokeRect(a,o,c,h);const d=`Face ${n+1}`,m=t.measureText(d);t.fillStyle="rgba(0, 0, 0, 0.7)",t.fillRect(a,o-20,m.width+10,20),t.fillStyle="white",t.fillText(d,a+5,o-5)}))}}class c{constructor(e,t,n,i){this.dispatchViolation=e,this.isRunning=t,this.addManagedEventListener=n,this.removeAllManagedEventListenersForContext=i,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 e=!!(document.fullscreenElement||document.webkitFullscreenElement||document.mozFullScreenElement||document.msFullscreenElement);this.dispatchViolation(t.FULLSCREEN_EXIT,!e)}_handleVisibilityChange(){this.isRunning()&&("hidden"===document.visibilityState?this.dispatchViolation(t.TAB_SWITCH,!0):this.dispatchViolation(t.TAB_SWITCH,!1))}_handleCopyPasteAttempt(e,n){this.isRunning()&&this.dispatchViolation(t.COPY_PASTE_ATTEMPT,!0,{eventType:e.type})}_checkMultipleScreens(){this.isRunning()&&(window.screen&&void 0!==window.screen.isExtended?this.dispatchViolation(t.MULTIPLE_SCREENS,window.screen.isExtended):(console.info("EnvironmentMonitor: Multiple screen detection (window.screen.isExtended) not supported. Assuming single screen."),this.dispatchViolation(t.MULTIPLE_SCREENS,!1)))}}class h{constructor(e){try{this.configManager=new s(e)}catch(e){throw console.error("ProctorSDK Fatal Error: Initial configuration failed.",e),e}this.config=this.configManager.getConfig(),this.internalState={isRunning:!1,activeWarningFlags:this._getInitialWarningFlags(),multipleFaceCount:0,lastViolationCallTimestamps:this._getInitialViolationTimestamps(),eventListeners:[]};try{this.streamManager=new a(this.config.containerElement),this.modelManager=new r(this.config.tfjsModelPaths.tfjs,this.config.tfjsModelPaths.blazeface)}catch(e){throw this._setStatus(n.ERROR,"SDK Initialization failed (Stream/Model Manager).",e),e}this.faceDetector=null,this.environmentMonitor=null,this._setStatus(n.INITIALIZING,"SDK initialized.")}_getInitialWarningFlags(){const e={};for(const n in t)e[t[n]]=!1;return e}_getInitialViolationTimestamps(){const e={};for(const n in t)e[t[n]]=0;return e}_setStatus(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null;const i={status:e,message:t,...n&&{error:n}};try{this.config&&this.config.callbacks&&this.config.callbacks.onStatusChange&&this.config.callbacks.onStatusChange(i)}catch(e){console.error("ProctorSDK: Error in onStatusChange callback:",e)}n?console.error(`ProctorSDK Error: ${t}`,n):console.log(`ProctorSDK Status: ${e} - ${t}`)}_dispatchViolation(e,n){let i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};if(!this.config||!this.config.callbacks||!this.config.callbacks.onViolation)return;const s=this.internalState.activeWarningFlags[e];this.internalState.activeWarningFlags[e]=n,e===t.MULTIPLE_FACES&&n&&(this.internalState.multipleFaceCount=i.count||0);const a={type:e,active:n,message:this._getViolationMessage(e,i),timestamp:new Date,details:i},o=Date.now(),r=this.internalState.lastViolationCallTimestamps[e]||0,l=this.config.effectiveThrottleDurations[e]||0;let c=!1;if(n?o-r>l&&(c=!0,this.internalState.lastViolationCallTimestamps[e]=o):s&&!n&&(c=!0,this.internalState.lastViolationCallTimestamps[e]=0),c)try{this.config.callbacks.onViolation(a)}catch(e){console.error("ProctorSDK: Error in onViolation callback:",e)}}_getViolationMessage(e,n){switch(e){case t.NO_FACE:return"No face detected!";case t.MULTIPLE_FACES:return`Multiple faces detected: ${n.count||"N/A"}`;case t.FULLSCREEN_EXIT:return"User is not in fullscreen mode!";case t.TAB_SWITCH:return"User switched tabs or minimized window!";case t.COPY_PASTE_ATTEMPT:return`User action: ${n.eventType||"copy/paste/cut"} attempt detected!`;case t.MULTIPLE_SCREENS:return"Multiple screens detected (extended display)!";default:return"Unknown violation."}}_addManagedEventListener(e,t,n){let i=arguments.length>3&&void 0!==arguments[3]?arguments[3]:"global";e.addEventListener(t,n),this.internalState.eventListeners.push({target:e,type:t,listener:n,context:i})}_removeAllManagedEventListeners(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:null;const t=e?this.internalState.eventListeners.filter((t=>t.context===e)):this.internalState.eventListeners,n=e?this.internalState.eventListeners.filter((t=>t.context!==e)):[];t.forEach((e=>{let{target:t,type:n,listener:i}=e;t.removeEventListener(n,i)})),this.internalState.eventListeners=n}async start(){if(this.internalState.isRunning)console.warn("ProctorSDK: Already running.");else{this._setStatus(n.STARTING,"Attempting to start...");try{this.config.enabledChecks.faceDetection&&(this._setStatus(n.MODEL_LOADING),await this.modelManager.loadModels(),this._setStatus(n.MODEL_LOADED,"Face detection model ready.")),this._setStatus(n.WEBCAM_REQUESTING);const e=await this.streamManager.acquireStream();this._setStatus(n.WEBCAM_READY,"Webcam stream acquired.");try{this.config.callbacks.onWebcamStreamReady(e)}catch(e){console.error("ProctorSDK: Error in onWebcamStreamReady callback",e)}this.internalState.isRunning=!0,this.config.enabledChecks.faceDetection&&this.modelManager.getModel()&&(this.faceDetector=new l(this.modelManager.getModel(),this.streamManager.getVideoElement(),this.streamManager.getCanvasContext(),this._dispatchViolation.bind(this),this.config.callbacks.onFacePredictions,(()=>this.internalState.isRunning)),this.faceDetector.startDetectionLoop()),(this.config.enabledChecks.fullscreen||this.config.enabledChecks.tabSwitch||this.config.enabledChecks.copyPaste||this.config.enabledChecks.multipleScreens)&&(this.environmentMonitor=new c(this._dispatchViolation.bind(this),(()=>this.internalState.isRunning),this._addManagedEventListener.bind(this),(e=>this._removeAllManagedEventListeners(e))),this.environmentMonitor.startMonitoring()),this._setStatus(n.STARTED,"Proctoring started successfully.")}catch(e){this._setStatus(n.ERROR,"Failed to start proctoring.",e),this.stop()}}}stop(){this._setStatus(n.STOPPING,"Stopping proctoring..."),this.internalState.isRunning=!1,this.faceDetector&&(this.faceDetector.stopDetectionLoop(),this.faceDetector=null),this.environmentMonitor&&(this.environmentMonitor=null),this.streamManager.releaseStream(),this._removeAllManagedEventListeners();for(const e in this.internalState.activeWarningFlags)this.internalState.activeWarningFlags[e]&&this._dispatchViolation(e,!1);this.internalState.lastViolationCallTimestamps=this._getInitialViolationTimestamps(),this._setStatus(n.STOPPED,"Proctoring stopped.")}isProctoringActive(){return this.internalState.isRunning}requestFullscreen(){const e=this.streamManager.getVideoElement()?.parentElement||document.documentElement;return e?e.requestFullscreen?e.requestFullscreen():e.webkitRequestFullscreen?e.webkitRequestFullscreen():e.mozRequestFullScreen?e.mozRequestFullScreen():e.msRequestFullscreen?e.msRequestFullscreen():Promise.reject(new Error("Fullscreen API not supported.")):Promise.reject(new Error("Container element not available for fullscreen request."))}destroy(){this.stop(),this.streamManager&&this.streamManager.destroy(),this.modelManager&&this.modelManager.destroy(),this.streamManager=null,this.modelManager=null,this.configManager=null,this.config={callbacks:{},enabledChecks:{},tfjsModelPaths:{},effectiveThrottleDurations:{}},this._setStatus(n.DESTROYED,"SDK instance destroyed.")}}h.WARNING_TYPES=t,h.STATUS_TYPES=n,e.STATUS_TYPES=n,e.WARNING_TYPES=t,e.default=h,Object.defineProperty(e,"__esModule",{value:!0})})); //# sourceMappingURL=index.js.map