From 05fe91bc08ae52f23afe5cf9bdfd8ebb55418462 Mon Sep 17 00:00:00 2001 From: Hyeona01 Date: Tue, 3 Dec 2024 12:33:05 +0900 Subject: [PATCH] =?UTF-8?q?FE:=20[feat]=20=EC=88=98=ED=97=98=EC=9E=90=20?= =?UTF-8?q?=EB=B6=80=EC=A0=95=ED=96=89=EC=9C=84=20=ED=83=90=EC=A7=80=20?= =?UTF-8?q?=EB=B9=84=EB=94=94=EC=98=A4=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eyesee-admin/src/constant/monitoring.ts | 16 +- .../eyesee-user/src/app/exam-room/page.tsx | 161 +++++++++++++++--- 2 files changed, 150 insertions(+), 27 deletions(-) diff --git a/src/frontend/eyesee-admin/src/constant/monitoring.ts b/src/frontend/eyesee-admin/src/constant/monitoring.ts index 09559fb..022d6ed 100644 --- a/src/frontend/eyesee-admin/src/constant/monitoring.ts +++ b/src/frontend/eyesee-admin/src/constant/monitoring.ts @@ -1,10 +1,10 @@ export const enum MonitoringCondition { - NOT_LOOKING_AROUND = "NOT_LOOKING_AROUND", // 주변을 5초 이상 응시 - REPEATED_GAZE = "REPEATED_GAZE", // 동일한 곳을 3초 이상 5번 응시 - DEVICE_DETECTION = "DEVICE_DETECTION", // 스마트폰, 작은 종이 포착 - OFF_SCREEN = "OFF_SCREEN", // 화면에서 5초 이상 이탈 - FREQUENT_OFF_SCREEN = "FREQUENT_OFF_SCREEN", // 화면에서 3초 이상 5번 이탈 - REPEATED_HAND_GESTURE = "REPEATED_HAND_GESTURE", // 특정 손동작 반복 - TURNING_AWAY = "TURNING_AWAY", // 고개를 돌리고 5초 이상 유지 - SPECIFIC_POSITION_BEHAVIOR = "SPECIFIC_POSITION_BEHAVIOR", // 특정 위치로 고개를 돌리는 행동 + NOT_LOOKING_AROUND = "look_around", // 주변을 5초 이상 응시 + REPEATED_GAZE = "repeated_gaze", // 동일한 곳을 3초 이상 5번 응시 + DEVICE_DETECTION = "object", // 스마트폰, 작은 종이 포착 + OFF_SCREEN = "face_absence_long", // 화면에서 5초 이상 이탈 + FREQUENT_OFF_SCREEN = "face_absence_repeat", // 화면에서 3초 이상 5번 이탈 + REPEATED_HAND_GESTURE = "hand_gesture", // 특정 손동작 반복 + TURNING_AWAY = "head_turn_long", // 고개를 돌리고 5초 이상 유지 + SPECIFIC_POSITION_BEHAVIOR = "head_turn_repeat", // 특정 위치로 고개를 돌리는 행동 } diff --git a/src/frontend/eyesee-user/src/app/exam-room/page.tsx b/src/frontend/eyesee-user/src/app/exam-room/page.tsx index e8b7c9a..f9aa53b 100644 --- a/src/frontend/eyesee-user/src/app/exam-room/page.tsx +++ b/src/frontend/eyesee-user/src/app/exam-room/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useRef, useState } from "react"; import NextButton from "@/components/common/NextButton"; import { useUserIdStore } from "@/store/useUserIdStore"; import { useStore } from "@/store/useStore"; @@ -9,8 +9,17 @@ const RealTimeVideoPage = () => { const videoRef = useRef(null); const canvasRef = useRef(null); const socketRef = useRef(null); + const mediaRecorderRef = useRef(null); + const recordedChunksRef = useRef<{ timestamp: number; data: Blob }[]>([]); // 청크와 타임스탬프 저장 + const captureIntervalRef = useRef(null); + + const CHUNK_SIZE = 1000; // 1초마다 녹화 데이터를 저장 + const BUFFER_DURATION = 20 * 1000; // 20초 간의 데이터를 저장 + + const [isProcessing, setIsProcessing] = useState(false); // 부정행위 감지 처리 중 여부 상태 const userId = useStore(useUserIdStore, (state) => state.userId); + const examId = 1; const setupWebSocket = () => { console.log(process.env.NEXT_PUBLIC_WEBSOCKET_KEY); console.log(userId); @@ -21,11 +30,11 @@ const RealTimeVideoPage = () => { } const socket = new WebSocket( - `${process.env.NEXT_PUBLIC_WEBSOCKET_KEY}/${userId}` + `${process.env.NEXT_PUBLIC_WEBSOCKET_KEY}/${userId}/${examId}` ); socket.onopen = () => { - console.log(`WebSocket 연결 성공: ${userId}`); + console.log(`WebSocket 연결 성공: ${userId}, ${examId}`); }; socket.onerror = (error) => { @@ -37,18 +46,34 @@ const RealTimeVideoPage = () => { setTimeout(setupWebSocket, 3000); // 3초 후 재시도 }; + // 부정행위 감지 메시지 처리 + socket.onmessage = (event) => { + const message = JSON.parse(event.data); + console.log("WebSocket 메시지 수신:", message); + + // 부정행위 감지 메시지가 있을 경우 + if (message) { + console.log("부정행위 감지:", message.timestamp); + + // 부정행위 비디오 저장 호출 + sendCheatingVideo(message.timestamp); + } + }; + socketRef.current = socket; }; - // 비디오 스트림 가져오기 const startStreaming = async () => { try { const constraints = { video: true }; const stream = await navigator.mediaDevices.getUserMedia(constraints); + if (videoRef.current) { videoRef.current.srcObject = stream; console.log("비디오 스트림 시작"); } + + startRecording(stream); // 비디오 스트림과 함께 녹화 시작 } catch (error) { console.error("비디오 스트림 가져오기 오류:", error); alert("카메라 권한을 허용해주세요."); @@ -56,6 +81,108 @@ const RealTimeVideoPage = () => { }; // Canvas를 사용해 비디오 프레임을 WebSocket으로 전송 + const startRecording = (stream: MediaStream) => { + mediaRecorderRef.current = new MediaRecorder(stream, { + mimeType: "video/webm; codecs=vp8", + }); + + mediaRecorderRef.current.ondataavailable = (event) => { + if (event.data.size > 0) { + const timestamp = Date.now(); + recordedChunksRef.current.push({ + timestamp: timestamp, + data: event.data, + }); + + // 슬라이딩 윈도우 방식으로 오래된 데이터 삭제 + const currentTime = Date.now(); + recordedChunksRef.current = recordedChunksRef.current.filter( + (chunk) => chunk.timestamp >= currentTime - BUFFER_DURATION + ); + } + }; + + mediaRecorderRef.current.onerror = (e) => { + console.error("MediaRecorder 오류 발생:", e); + }; + + mediaRecorderRef.current.start(CHUNK_SIZE); // **녹화 시작** + console.log("MediaRecorder 시작"); + }; + + const stopRecording = () => { + if (mediaRecorderRef.current) { + mediaRecorderRef.current.stop(); + console.log("MediaRecorder 중지"); + } + }; + + // 부정행위 비디오 처리 및 저장 + const sendCheatingVideo = async ( + cheatingTimestamp: string | number | Date + ) => { + try { + setIsProcessing(true); // 부정행위 감지 시작 + + console.log("부정행위 발생 타임스탬프:", cheatingTimestamp); + + const cheatingDate = new Date(cheatingTimestamp); + if (isNaN(cheatingDate.getTime())) { + throw new Error("Invalid cheatingTimestamp: " + cheatingTimestamp); + } + + const cheatingTime = cheatingDate.getTime(); + const startTime = cheatingTime - 5000; // 부정행위 5초 전 + const endTime = cheatingTime + 5000; // 부정행위 5초 후 + + console.log("탐지 이후 5초 데이터를 수집 중..."); + setTimeout(() => { + mediaRecorderRef.current?.stop(); + }, 5000); + + // MediaRecorder가 멈춘 후 데이터를 처리 + mediaRecorderRef.current!.onstop = () => { + const previousChunks = recordedChunksRef.current.filter( + (chunk) => + chunk.timestamp >= startTime && chunk.timestamp <= cheatingTime + ); + console.log(`탐지 이전 데이터: ${previousChunks.length} 청크`); + + const postCheatingChunks = recordedChunksRef.current.filter( + (chunk) => + chunk.timestamp > cheatingTime && chunk.timestamp <= endTime + ); + console.log(`탐지 이후 데이터: ${postCheatingChunks.length} 청크`); + + const allChunks = [...previousChunks, ...postCheatingChunks]; + const blob = new Blob( + allChunks.map((chunk) => chunk.data), + { + type: "video/webm", + } + ); + + console.log(`최종 Blob 크기: ${blob.size / 1024} KB`); + + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `cheating_${new Date().toISOString()}.webm`; + a.click(); + URL.revokeObjectURL(url); + + console.log("부정행위 비디오 저장 완료"); + // 이 부분은 녹화가 중지된 후, 데이터가 완전히 처리된 후에 실행되어야 합니다. + startRecording(videoRef.current?.srcObject as MediaStream); + }; + } catch (error) { + console.error("부정행위 이벤트 처리 중 오류:", error); + } finally { + setIsProcessing(false); // 부정행위 처리 끝 + console.log(isProcessing); + } + }; + const captureAndSendFrame = () => { if ( canvasRef.current && @@ -65,40 +192,36 @@ const RealTimeVideoPage = () => { const canvas = canvasRef.current; const video = videoRef.current; - // Canvas 크기를 비디오와 동일하게 설정 canvas.width = video.videoWidth; canvas.height = video.videoHeight; - // Canvas에 현재 비디오 프레임 그리기 const context = canvas.getContext("2d"); if (context) { context.drawImage(video, 0, 0, canvas.width, canvas.height); - // 캡처된 Canvas를 Base64로 변환 - const base64Data = canvas.toDataURL("image/jpeg", 0.7); // 품질 70% - const base64String = base64Data.split(",")[1]; // "data:image/jpeg;base64," 부분 제거 + const base64Data = canvas.toDataURL("image/jpeg", 0.3); + const base64String = base64Data.split(",")[1]; - if (socketRef.current) { - socketRef.current.send(base64String); - console.log("Base64 이미지 전송"); - } + socketRef.current.send(base64String); + console.log("WebSocket으로 프레임 전송"); } } }; - // 초기화 작업: WebSocket 연결, 비디오 스트리밍 시작, 시작 API 호출 useEffect(() => { const initialize = async () => { setupWebSocket(); await startStreaming(); - // 0.5초에 한 번씩 프레임 캡처 및 전송 - const captureInterval = setInterval(captureAndSendFrame, 500); + // 0.5초에 한 번씩 프레임 캡처 및 전송 + captureIntervalRef.current = window.setInterval(captureAndSendFrame, 500); return () => { - clearInterval(captureInterval); + if (captureIntervalRef.current) { + clearInterval(captureIntervalRef.current); + } - // WebSocket 연결 종료 + stopRecording(); if (socketRef.current) { socketRef.current.close(); } @@ -115,7 +238,7 @@ const RealTimeVideoPage = () => { autoPlay playsInline className="w-screen h-screen object-cover border border-gray-300" - style={{ transform: "scaleX(-1)" }} // 좌우 반전 + style={{ transform: "scaleX(-1)" }} />