Skip to content

Commit a336eee

Browse files
authored
Merge pull request #88 from CSID-DGU/frontend/feature/add-exam
FE: [feat] 수험자 부정행위 탐지 비디오 저장
2 parents 9ca9f26 + 05fe91b commit a336eee

File tree

2 files changed

+150
-27
lines changed

2 files changed

+150
-27
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
export const enum MonitoringCondition {
2-
NOT_LOOKING_AROUND = "NOT_LOOKING_AROUND", // 주변을 5초 이상 응시
3-
REPEATED_GAZE = "REPEATED_GAZE", // 동일한 곳을 3초 이상 5번 응시
4-
DEVICE_DETECTION = "DEVICE_DETECTION", // 스마트폰, 작은 종이 포착
5-
OFF_SCREEN = "OFF_SCREEN", // 화면에서 5초 이상 이탈
6-
FREQUENT_OFF_SCREEN = "FREQUENT_OFF_SCREEN", // 화면에서 3초 이상 5번 이탈
7-
REPEATED_HAND_GESTURE = "REPEATED_HAND_GESTURE", // 특정 손동작 반복
8-
TURNING_AWAY = "TURNING_AWAY", // 고개를 돌리고 5초 이상 유지
9-
SPECIFIC_POSITION_BEHAVIOR = "SPECIFIC_POSITION_BEHAVIOR", // 특정 위치로 고개를 돌리는 행동
2+
NOT_LOOKING_AROUND = "look_around", // 주변을 5초 이상 응시
3+
REPEATED_GAZE = "repeated_gaze", // 동일한 곳을 3초 이상 5번 응시
4+
DEVICE_DETECTION = "object", // 스마트폰, 작은 종이 포착
5+
OFF_SCREEN = "face_absence_long", // 화면에서 5초 이상 이탈
6+
FREQUENT_OFF_SCREEN = "face_absence_repeat", // 화면에서 3초 이상 5번 이탈
7+
REPEATED_HAND_GESTURE = "hand_gesture", // 특정 손동작 반복
8+
TURNING_AWAY = "head_turn_long", // 고개를 돌리고 5초 이상 유지
9+
SPECIFIC_POSITION_BEHAVIOR = "head_turn_repeat", // 특정 위치로 고개를 돌리는 행동
1010
}

src/frontend/eyesee-user/src/app/exam-room/page.tsx

+142-19
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import React, { useEffect, useRef } from "react";
3+
import React, { useEffect, useRef, useState } from "react";
44
import NextButton from "@/components/common/NextButton";
55
import { useUserIdStore } from "@/store/useUserIdStore";
66
import { useStore } from "@/store/useStore";
@@ -9,8 +9,17 @@ const RealTimeVideoPage = () => {
99
const videoRef = useRef<HTMLVideoElement>(null);
1010
const canvasRef = useRef<HTMLCanvasElement>(null);
1111
const socketRef = useRef<WebSocket | null>(null);
12+
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
13+
const recordedChunksRef = useRef<{ timestamp: number; data: Blob }[]>([]); // 청크와 타임스탬프 저장
14+
const captureIntervalRef = useRef<number | null>(null);
15+
16+
const CHUNK_SIZE = 1000; // 1초마다 녹화 데이터를 저장
17+
const BUFFER_DURATION = 20 * 1000; // 20초 간의 데이터를 저장
18+
19+
const [isProcessing, setIsProcessing] = useState(false); // 부정행위 감지 처리 중 여부 상태
1220

1321
const userId = useStore(useUserIdStore, (state) => state.userId);
22+
const examId = 1;
1423
const setupWebSocket = () => {
1524
console.log(process.env.NEXT_PUBLIC_WEBSOCKET_KEY);
1625
console.log(userId);
@@ -21,11 +30,11 @@ const RealTimeVideoPage = () => {
2130
}
2231

2332
const socket = new WebSocket(
24-
`${process.env.NEXT_PUBLIC_WEBSOCKET_KEY}/${userId}`
33+
`${process.env.NEXT_PUBLIC_WEBSOCKET_KEY}/${userId}/${examId}`
2534
);
2635

2736
socket.onopen = () => {
28-
console.log(`WebSocket 연결 성공: ${userId}`);
37+
console.log(`WebSocket 연결 성공: ${userId}, ${examId}`);
2938
};
3039

3140
socket.onerror = (error) => {
@@ -37,25 +46,143 @@ const RealTimeVideoPage = () => {
3746
setTimeout(setupWebSocket, 3000); // 3초 후 재시도
3847
};
3948

49+
// 부정행위 감지 메시지 처리
50+
socket.onmessage = (event) => {
51+
const message = JSON.parse(event.data);
52+
console.log("WebSocket 메시지 수신:", message);
53+
54+
// 부정행위 감지 메시지가 있을 경우
55+
if (message) {
56+
console.log("부정행위 감지:", message.timestamp);
57+
58+
// 부정행위 비디오 저장 호출
59+
sendCheatingVideo(message.timestamp);
60+
}
61+
};
62+
4063
socketRef.current = socket;
4164
};
4265

43-
// 비디오 스트림 가져오기
4466
const startStreaming = async () => {
4567
try {
4668
const constraints = { video: true };
4769
const stream = await navigator.mediaDevices.getUserMedia(constraints);
70+
4871
if (videoRef.current) {
4972
videoRef.current.srcObject = stream;
5073
console.log("비디오 스트림 시작");
5174
}
75+
76+
startRecording(stream); // 비디오 스트림과 함께 녹화 시작
5277
} catch (error) {
5378
console.error("비디오 스트림 가져오기 오류:", error);
5479
alert("카메라 권한을 허용해주세요.");
5580
}
5681
};
5782

5883
// Canvas를 사용해 비디오 프레임을 WebSocket으로 전송
84+
const startRecording = (stream: MediaStream) => {
85+
mediaRecorderRef.current = new MediaRecorder(stream, {
86+
mimeType: "video/webm; codecs=vp8",
87+
});
88+
89+
mediaRecorderRef.current.ondataavailable = (event) => {
90+
if (event.data.size > 0) {
91+
const timestamp = Date.now();
92+
recordedChunksRef.current.push({
93+
timestamp: timestamp,
94+
data: event.data,
95+
});
96+
97+
// 슬라이딩 윈도우 방식으로 오래된 데이터 삭제
98+
const currentTime = Date.now();
99+
recordedChunksRef.current = recordedChunksRef.current.filter(
100+
(chunk) => chunk.timestamp >= currentTime - BUFFER_DURATION
101+
);
102+
}
103+
};
104+
105+
mediaRecorderRef.current.onerror = (e) => {
106+
console.error("MediaRecorder 오류 발생:", e);
107+
};
108+
109+
mediaRecorderRef.current.start(CHUNK_SIZE); // **녹화 시작**
110+
console.log("MediaRecorder 시작");
111+
};
112+
113+
const stopRecording = () => {
114+
if (mediaRecorderRef.current) {
115+
mediaRecorderRef.current.stop();
116+
console.log("MediaRecorder 중지");
117+
}
118+
};
119+
120+
// 부정행위 비디오 처리 및 저장
121+
const sendCheatingVideo = async (
122+
cheatingTimestamp: string | number | Date
123+
) => {
124+
try {
125+
setIsProcessing(true); // 부정행위 감지 시작
126+
127+
console.log("부정행위 발생 타임스탬프:", cheatingTimestamp);
128+
129+
const cheatingDate = new Date(cheatingTimestamp);
130+
if (isNaN(cheatingDate.getTime())) {
131+
throw new Error("Invalid cheatingTimestamp: " + cheatingTimestamp);
132+
}
133+
134+
const cheatingTime = cheatingDate.getTime();
135+
const startTime = cheatingTime - 5000; // 부정행위 5초 전
136+
const endTime = cheatingTime + 5000; // 부정행위 5초 후
137+
138+
console.log("탐지 이후 5초 데이터를 수집 중...");
139+
setTimeout(() => {
140+
mediaRecorderRef.current?.stop();
141+
}, 5000);
142+
143+
// MediaRecorder가 멈춘 후 데이터를 처리
144+
mediaRecorderRef.current!.onstop = () => {
145+
const previousChunks = recordedChunksRef.current.filter(
146+
(chunk) =>
147+
chunk.timestamp >= startTime && chunk.timestamp <= cheatingTime
148+
);
149+
console.log(`탐지 이전 데이터: ${previousChunks.length} 청크`);
150+
151+
const postCheatingChunks = recordedChunksRef.current.filter(
152+
(chunk) =>
153+
chunk.timestamp > cheatingTime && chunk.timestamp <= endTime
154+
);
155+
console.log(`탐지 이후 데이터: ${postCheatingChunks.length} 청크`);
156+
157+
const allChunks = [...previousChunks, ...postCheatingChunks];
158+
const blob = new Blob(
159+
allChunks.map((chunk) => chunk.data),
160+
{
161+
type: "video/webm",
162+
}
163+
);
164+
165+
console.log(`최종 Blob 크기: ${blob.size / 1024} KB`);
166+
167+
const url = URL.createObjectURL(blob);
168+
const a = document.createElement("a");
169+
a.href = url;
170+
a.download = `cheating_${new Date().toISOString()}.webm`;
171+
a.click();
172+
URL.revokeObjectURL(url);
173+
174+
console.log("부정행위 비디오 저장 완료");
175+
// 이 부분은 녹화가 중지된 후, 데이터가 완전히 처리된 후에 실행되어야 합니다.
176+
startRecording(videoRef.current?.srcObject as MediaStream);
177+
};
178+
} catch (error) {
179+
console.error("부정행위 이벤트 처리 중 오류:", error);
180+
} finally {
181+
setIsProcessing(false); // 부정행위 처리 끝
182+
console.log(isProcessing);
183+
}
184+
};
185+
59186
const captureAndSendFrame = () => {
60187
if (
61188
canvasRef.current &&
@@ -65,40 +192,36 @@ const RealTimeVideoPage = () => {
65192
const canvas = canvasRef.current;
66193
const video = videoRef.current;
67194

68-
// Canvas 크기를 비디오와 동일하게 설정
69195
canvas.width = video.videoWidth;
70196
canvas.height = video.videoHeight;
71197

72-
// Canvas에 현재 비디오 프레임 그리기
73198
const context = canvas.getContext("2d");
74199
if (context) {
75200
context.drawImage(video, 0, 0, canvas.width, canvas.height);
76201

77-
// 캡처된 Canvas를 Base64로 변환
78-
const base64Data = canvas.toDataURL("image/jpeg", 0.7); // 품질 70%
79-
const base64String = base64Data.split(",")[1]; // "data:image/jpeg;base64," 부분 제거
202+
const base64Data = canvas.toDataURL("image/jpeg", 0.3);
203+
const base64String = base64Data.split(",")[1];
80204

81-
if (socketRef.current) {
82-
socketRef.current.send(base64String);
83-
console.log("Base64 이미지 전송");
84-
}
205+
socketRef.current.send(base64String);
206+
console.log("WebSocket으로 프레임 전송");
85207
}
86208
}
87209
};
88210

89-
// 초기화 작업: WebSocket 연결, 비디오 스트리밍 시작, 시작 API 호출
90211
useEffect(() => {
91212
const initialize = async () => {
92213
setupWebSocket();
93214
await startStreaming();
94215

95-
// 0.5초에 한 번씩 프레임 캡처 및 전송
96-
const captureInterval = setInterval(captureAndSendFrame, 500);
216+
// 0.5초에 한 번씩 프레임 캡처 및 전송
217+
captureIntervalRef.current = window.setInterval(captureAndSendFrame, 500);
97218

98219
return () => {
99-
clearInterval(captureInterval);
220+
if (captureIntervalRef.current) {
221+
clearInterval(captureIntervalRef.current);
222+
}
100223

101-
// WebSocket 연결 종료
224+
stopRecording();
102225
if (socketRef.current) {
103226
socketRef.current.close();
104227
}
@@ -115,7 +238,7 @@ const RealTimeVideoPage = () => {
115238
autoPlay
116239
playsInline
117240
className="w-screen h-screen object-cover border border-gray-300"
118-
style={{ transform: "scaleX(-1)" }} // 좌우 반전
241+
style={{ transform: "scaleX(-1)" }}
119242
/>
120243
<canvas ref={canvasRef} style={{ display: "none" }} />
121244
<div className="fixed bottom-6 right-6">

0 commit comments

Comments
 (0)