diff --git a/README.md b/README.md index 9bfe056..9c9b841 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +#### !NOTICE: This is majorly Ansh Saini's work. I only added the cam detection bit. Major credit goes to him. I'm only publishing this for ease of use + # Overview This is a headless library which only gives you some flags. What you do with that information is totally upto you. The UI for handling various use cases is completely in your hands. diff --git a/package-old.json b/package-old.json new file mode 100644 index 0000000..1c1485b --- /dev/null +++ b/package-old.json @@ -0,0 +1,62 @@ +{ + "name": "react-proctoring", + "private": false, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "build:example": "tsc && vite build -c example.vite.config.ts" + }, + "files": [ + "dist" + ], + "exports": { + ".": { + "import": "./dist/react-proctoring.es.js", + "require": "./dist/react-proctoring.umd.js" + } + }, + "main": "./dist/react-proctoring.umd.js", + "module": "./dist/react-proctoring.es.js", + "types": "./dist/index.d.ts", + "dependencies": { + "@mediapipe/tasks-vision": "^0.10.18", + "react-device-detect": "^2.2.3" + }, + "description": "A headless proctoring system that is built for React", + "peerDependencies": { + "react": "16.8.0 || >=17.x", + "react-dom": "16.8.0 || >=17.x" + }, + "repository": { + "type": "git", + "url": "https://github.com/ansh-saini/react-proctoring" + }, + "author": "Ansh Saini", + "bugs": { + "url": "https://github.com/ansh-saini/react-proctoring/issues" + }, + "homepage": "https://github.com/ansh-saini/react-proctoring#readme", + "license": "MIT", + "devDependencies": { + "@types/node": "^18.13.0", + "@types/react": "^18.0.27", + "@types/react-dom": "^18.0.10", + "@typescript-eslint/eslint-plugin": "^5.0.0", + "@vitejs/plugin-react": "^3.1.0", + "eslint": "^8.0.1", + "eslint-config-standard-with-typescript": "^34.0.0", + "eslint-plugin-import": "^2.25.2", + "eslint-plugin-n": "^15.0.0", + "eslint-plugin-promise": "^6.0.0", + "eslint-plugin-react": "^7.32.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "typescript": "*", + "vite": "^4.1.0", + "vite-plugin-dts": "^1.7.2", + "vite-plugin-linter": "^2.0.2", + "vite-tsconfig-paths": "^4.0.5" + } +} diff --git a/package.json b/package.json index 463de8a..0d68a21 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "react-proctoring", + "name": "react-proctoring-1", "private": false, - "version": "0.0.0", + "version": "1.0.3", "type": "module", "scripts": { "dev": "vite", @@ -21,22 +21,30 @@ "module": "./dist/react-proctoring.es.js", "types": "./dist/index.d.ts", "dependencies": { + "@mediapipe/tasks-vision": "^0.10.18", "react-device-detect": "^2.2.3" }, - "description": "A headless proctoring system for built for React", + "description": "A headless proctoring system that is built for React", "peerDependencies": { "react": "16.8.0 || >=17.x", "react-dom": "16.8.0 || >=17.x" }, "repository": { "type": "git", - "url": "https://github.com/ansh-saini/react-proctoring" + "url": "git+https://github.com/4x3l3r8/react-proctoring.git" }, "author": "Ansh Saini", + "contributors": [ + { + "name": "Daniel Adesanya", + "email": "danieladesanya25@gmail.com", + "url": "https://github.com/4x3l3r8" + } + ], "bugs": { - "url": "https://github.com/ansh-saini/react-proctoring/issues" + "url": "https://github.com/4x3l3r8/react-proctoring/issues" }, - "homepage": "https://github.com/ansh-saini/react-proctoring#readme", + "homepage": "https://github.com/4x3l3r8/react-proctoring#readme", "license": "MIT", "devDependencies": { "@types/node": "^18.13.0", diff --git a/src/example/App.tsx b/src/example/App.tsx index 9170260..911e691 100644 --- a/src/example/App.tsx +++ b/src/example/App.tsx @@ -9,12 +9,13 @@ import Exam from './components/Exam' function App() { const [examHasStarted, setExamHasStarted] = useState(false) - const { fullScreen, tabFocus } = useProctoring({ + const { fullScreen, tabFocus, camDetection } = useProctoring({ forceFullScreen: true, preventTabSwitch: true, preventContextMenu: true, preventUserSelection: true, preventCopy: true, + monitorCam: true, }) if (!examHasStarted) { @@ -32,18 +33,20 @@ function App() { } const getContent = () => { + debugger if (fullScreen.status === 'off') return if (tabFocus.status === false) return - return + return } return ( <> {/* For debugging purpose */} {/*
{JSON.stringify({ fullScreen, tabFocus }, null, 2)}
*/} - +
{getContent()}
+ ) diff --git a/src/example/components/Exam/index.tsx b/src/example/components/Exam/index.tsx index d961e63..ab5f481 100644 --- a/src/example/components/Exam/index.tsx +++ b/src/example/components/Exam/index.tsx @@ -1,38 +1,55 @@ -import React from 'react' +import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react' import questions from './questions' +import ProctorService from '../../../hooks/ProctorService' +import ExamPaused from './ExamPaused' + +type Props = { + violationStatus: { + facesDetected: number; + objectDetected: string[]; + } +} + +const Exam = ({ violationStatus }: Props) => { + -type Props = {} -const Exam = (props: Props) => { return ( -
-

Exam in progress!

- - {questions.map((q, i) => ( -
-

Question {i + 1}

-

{q.text}

- {q.options.map((option) => ( - <> - - -
- + <> + {/* */} + {/* {violationStatus.facesDetected > 1 && {violationStatus.facesDetected} faces detected} */} + + {violationStatus.facesDetected < 1 || violationStatus.facesDetected > 1 || violationStatus.objectDetected.filter((object) => object !== "person").length > 0 ? : +
+

Exam in progress!

+ + {questions.map((q, i) => ( +
+

Question {i + 1}

+

{q.text}

+ {q.options.map((option) => ( + <> + + +
+ + ))} +
))}
- ))} -
+ } + ) } diff --git a/src/hooks/ProctorService.ts b/src/hooks/ProctorService.ts new file mode 100644 index 0000000..2957a2f --- /dev/null +++ b/src/hooks/ProctorService.ts @@ -0,0 +1,102 @@ +import { + FaceDetector, + FaceLandmarker, + FilesetResolver, + ObjectDetector, +} from '@mediapipe/tasks-vision' + +/** + * The `ProctorService` class in TypeScript initializes and manages instances of face detection, face + * landmarking, and object detection classes from the `@mediapipe/tasks-vision` library. + */ +class ProctorService { + /** + * The `faceDetector` property in the `ProctorService` class is used to store an instance of the + * FaceDetector class from the `@mediapipe/tasks-vision` library. This instance is created during the + * initialization of the `ProctorService` class and is used to detect faces in images or video + * streams. The `faceDetector` property allows the `ProctorService` class to perform face detection + * tasks using the functionalities provided by the FaceDetector class. + */ + faceDetector?: FaceDetector + + /** + * The `faceLandmarker?: FaceLandmarker` line in the `ProctorService` class is declaring a property + * named `faceLandmarker` that can hold an instance of the `FaceLandmarker` class from the + * `@mediapipe/tasks-vision` library. + */ + faceLandmarker?: FaceLandmarker + + /** + * The line `objectDetector?: ObjectDetector` in the `ProctorService` class is declaring a property + * named `objectDetector` that can hold an instance of the `ObjectDetector` class from the + * `@mediapipe/tasks-vision` library. This property is used to + * store an instance of the `ObjectDetector` class, which is responsible for detecting objects in + * images or video streams using the functionalities provided by the `ObjectDetector` class. + */ + objectDetector?: ObjectDetector + + private eyeTracker: any + private vision: any + + /** + * The `ProctorService` class in TypeScript initializes and manages instances of face detection, face + * landmarking, and object detection classes from the `@mediapipe/tasks-vision` library. + */ + static instance: ProctorService + + violations = { + facesDetected: 0, + } + + constructor() { + this.initVision() + } + + private async initVision() { + this.vision = await FilesetResolver.forVisionTasks( + 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm', + ) + + this.faceDetector = await FaceDetector.createFromOptions(this.vision, { + baseOptions: { + modelAssetPath: + 'https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/latest/blaze_face_short_range.tflite', + delegate: 'GPU', + }, + runningMode: 'VIDEO', + }) + + this.faceLandmarker = await FaceLandmarker.createFromOptions(this.vision, { + baseOptions: { + modelAssetPath: + 'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/latest/face_landmarker.task', + delegate: 'CPU', + }, + runningMode: 'VIDEO', + numFaces: 1, + }) + + this.objectDetector = await ObjectDetector.createFromOptions(this.vision, { + baseOptions: { + modelAssetPath: + 'https://storage.googleapis.com/mediapipe-tasks/object_detector/efficientdet_lite0_uint8.tflite', + delegate: 'CPU', + }, + runningMode: 'VIDEO', + scoreThreshold: 0.2, + }) + } + + /** + * Returns a singleton instance of the ProctorService class. + * @returns {ProctorService} Proctor - The singleton instance of ProctorService. + */ + static getInstance() { + if (!ProctorService.instance) { + ProctorService.instance = new ProctorService() + } + return ProctorService.instance + } +} + +export default ProctorService.getInstance diff --git a/src/hooks/useCam.ts b/src/hooks/useCam.ts new file mode 100644 index 0000000..94f8c24 --- /dev/null +++ b/src/hooks/useCam.ts @@ -0,0 +1,124 @@ +import { RefObject, useEffect, useMemo, useRef, useState } from 'react' +import ProctorService from './ProctorService' + +/** + * useCam is a hook that allows you to detect faces and objects in a video stream. + * It also tracks the user's eyes and detects if the user is looking at the camera. + * The hook returns an object with the following properties: + * - videoRef: a reference to the video element that the hook is controlling. + * - violationStatus: an object with the following properties: + * - facesDetected: the number of faces detected in the video stream. + * - objectDetected: an array of strings representing the objects detected in the video stream. + * - startWebcam: a function that starts the webcam and begins the face and object detection. + * + * @param {{ disabled?: boolean }} options - an object with the following properties: + * - disabled: a boolean indicating whether the webcam should be disabled. + * If set to true, the hook will not request access to the webcam and will not detect faces or objects. + * Defaults to false. + * + * @returns {{ videoRef: RefObject, violationStatus: { facesDetected: number, objectDetected: string[] }, startWebcam: () => void }} + */ +export const useCam = ({ disabled }: { disabled?: boolean }) => { + const videoRef = useRef(null) + const proctorRef = useRef(ProctorService) + + const [violationStatus, setViolationStatus] = useState<{ + facesDetected: number + objectDetected: string[] + }>({ + facesDetected: 0, + objectDetected: [], + }) + + const proctor = useMemo(() => proctorRef.current(), []) + + useEffect(() => { + if (!disabled) { + startWebcam() + } + + return () => { + if (videoRef.current && videoRef.current.srcObject) { + ;(videoRef.current.srcObject as MediaStream).getTracks().forEach((track) => track.stop()) + } + } + }, []) + + /** + * Detects faces in a video stream. + * @param videoRef a reference to the video element that the hook is controlling. + * @returns an array of face detections, or undefined if the video stream is not ready. + */ + const detectFaces = (videoRef: RefObject) => { + if (videoRef.current && videoRef.current.readyState >= 2 && proctor.faceDetector) { + const detections = proctor.faceDetector.detectForVideo(videoRef.current, performance.now()) + setViolationStatus((prev) => ({ ...prev, facesDetected: detections.detections.length })) + requestAnimationFrame(detectFaces.bind(this, videoRef)) + return detections.detections + } + window.requestAnimationFrame(detectFaces.bind(this, videoRef)) + } + + /** + * Tracks eye movements in a video stream using face landmarks. + * Continuously requests animation frames to keep detecting eye movements. + * If face landmarks are detected, it processes the detections for eye tracking. + * @param videoRef A reference to the video element being used for eye tracking. + */ + const eyesTracker = (videoRef: RefObject) => { + if (videoRef.current && videoRef.current.readyState >= 2 && proctor.faceLandmarker) { + const detections = proctor.faceLandmarker.detectForVideo(videoRef.current, performance.now()) + + // console.log(detections.faceBlendshapes) + // setViolationStatus((prev) => ({ ...prev, objectDetected: detections.detections.length })) + requestAnimationFrame(eyesTracker.bind(this, videoRef)) + // return detections.detections + } + requestAnimationFrame(eyesTracker.bind(this, videoRef)) + } + + /** + * Detects objects in a video stream using the object detector. + * Continuously requests animation frames to keep detecting objects. + * If object detections are detected, it processes the detections and updates the violation status. + * @param videoRef A reference to the video element being used for object detection. + * @returns an array of object detections, or undefined if the video stream is not ready. + */ + const objectDetection = (videoRef: RefObject) => { + if (videoRef.current && videoRef.current.readyState >= 2 && proctor.objectDetector) { + const detections = proctor.objectDetector.detectForVideo(videoRef.current, performance.now()) + // setViolationStatus((prev) => ({ ...prev, objectDetected: detections.detections.length })) + setViolationStatus((prev) => { + const categories = detections.detections.map( + (detection) => detection.categories[0].categoryName, + ) + return { ...prev, objectDetected: categories } + }) + requestAnimationFrame(objectDetection.bind(this, videoRef)) + return detections.detections + } + requestAnimationFrame(objectDetection.bind(this, videoRef)) + } + + /** + * Requests access to the user's webcam and starts the face detection, eye tracking and object detection. + * @throws {Error} If there is an error accessing the user's webcam. + */ + const startWebcam = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ video: true }) + if (videoRef.current) videoRef.current.srcObject = stream + detectFaces(videoRef) + // eyesTracker(videoRef) + objectDetection(videoRef) + } catch (error) { + console.error('Error accessing webcam:', error) + } + } + + return { + // startWebcam, + violationStatus, + videoRef, + } +} diff --git a/src/hooks/useProctoring.ts b/src/hooks/useProctoring.ts index 1348415..20d588c 100644 --- a/src/hooks/useProctoring.ts +++ b/src/hooks/useProctoring.ts @@ -9,6 +9,9 @@ import { useTabFocusDetection } from './useTabFocusDetection' import { useCopyDisable } from './useCopyDisable' import { useDisableContextMenu } from './useDisableContextMenu' import { useSelectionDisable } from './useSelectionDisable' +import ProctorService from './ProctorService' +import { RefObject } from 'react' +import { useCam } from './useCam' type Props = { preventContextMenu?: boolean @@ -16,6 +19,7 @@ type Props = { preventCopy?: boolean forceFullScreen?: boolean preventTabSwitch?: boolean + monitorCam?: boolean } export type ProctoringData = { @@ -23,13 +27,68 @@ export type ProctoringData = { tabFocus: { status: boolean } } +export type ProctorObject = { + fullScreen: { + status: FullScreenStatus + trigger: () => void + } + tabFocus: { + status: boolean + } + camDetection: { + violationStatus: { + facesDetected: number + objectDetected: string[] + } + videoRef: RefObject + } +} + +/** + * This hook provides a way to detect and prevent various kinds of user behaviors + * that are deemed malicious or unwanted. It is meant to be used in an exam + * context and will throw an error if the user tries to switch tabs, open the + * context menu, select text, copy text, or does not have camera permissions. + * + * @param {Object} props + * @param {boolean} [props.preventContextMenu=false] - Whether to disable the + * context menu (right click menu) + * @param {boolean} [props.preventUserSelection=false] - Whether to disable text + * selection + * @param {boolean} [props.preventCopy=false] - Whether to prevent text copying + * @param {boolean} [props.forceFullScreen=false] - Whether to force the browser + * into full screen mode + * @param {boolean} [props.preventTabSwitch=false] - Whether to prevent the user + * from switching tabs + * @param {boolean} [props.monitorCam=false] - Whether to monitor the user's + * camera for faces and objects + * + * @returns {ProctorObject} An object containing the following properties: + * - fullScreen: An object with two properties: `status` and `trigger`. The + * `status` property is a string indicating whether the browser is in full + * screen mode or not, and the `trigger` property is a function that can be + * called to trigger full screen mode. + * - tabFocus: An object with one property: `status`. The `status` property is + * a boolean indicating whether the user is currently focused on the current + * tab or not. + * - camDetection: An object with two properties: `violationStatus` and + * `videoRef`. The `violationStatus` property is an object containing two + * properties: `facesDetected` and `objectDetected`. The `facesDetected` + * property is a number indicating how many faces were detected in the + * camera, and the `objectDetected` property is an array of strings + * indicating which objects were detected in the camera. The `videoRef` + * property is a reference to the video element that is used for camera + * monitoring. + */ + export function useProctoring({ preventTabSwitch = false, forceFullScreen = false, preventContextMenu = false, preventUserSelection = false, preventCopy = false, -}: Props) { + monitorCam = false, +}: Props): ProctorObject { useDisableContextMenu({ disabled: preventContextMenu === false }) useCopyDisable({ disabled: preventCopy === false }) @@ -49,9 +108,11 @@ export function useProctoring({ const { fullScreenStatus } = useFullScreenDetection({ disabled: forceFullScreen === false, }) + const { videoRef, violationStatus } = useCam({ disabled: monitorCam === false }) return { fullScreen: { status: fullScreenStatus, trigger: triggerFullscreen }, tabFocus: { status: tabFocusStatus }, - } as const + camDetection: { violationStatus, videoRef }, + } } diff --git a/src/index.ts b/src/index.ts index b3fad48..7cb5acf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ // import { useDisableContextMenu } from './hooks/useDisableContextMenu' // import { useFullScreenDetection } from './hooks/useFullScreenDetection' // import { useIntersectionObserver } from './hooks/useIntersectionObserver' -import { useProctoring } from './hooks/useProctoring' +import { useProctoring, ProctorObject } from './hooks/useProctoring' // import { useSelectionDisable } from './hooks/useSelectionDisable' // import { useTabFocusDetection } from './hooks/useTabFocusDetection' @@ -14,6 +14,7 @@ export { // useFullScreenDetection, // useIntersectionObserver, useProctoring, + type ProctorObject, // useSelectionDisable, // useTabFocusDetection, } diff --git a/yarn.lock b/yarn.lock index 775c7f9..f277f2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -393,6 +393,11 @@ "@jridgewell/resolve-uri" "3.1.0" "@jridgewell/sourcemap-codec" "1.4.14" +"@mediapipe/tasks-vision@^0.10.18": + version "0.10.18" + resolved "https://registry.yarnpkg.com/@mediapipe/tasks-vision/-/tasks-vision-0.10.18.tgz#9ea0f0bf7506378c55ee661fa70aa0910f21f9b5" + integrity sha512-NRIlyqhGUz1Jdgcs6YybwPRhLK6dgeGAqAMXepIczEQ7FmA/0ouFtgMO1g9SPf/HaDSO8pNVdP54dAb9s9wj/Q== + "@microsoft/api-extractor-model@7.26.4": version "7.26.4" resolved "https://registry.yarnpkg.com/@microsoft/api-extractor-model/-/api-extractor-model-7.26.4.tgz#77f2c17140249b846a61eea41e565289cc77181f"