Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FE: [feat] 수험자 부정행위 탐지 비디오 저장 #88

Merged
merged 1 commit into from
Dec 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions src/frontend/eyesee-admin/src/constant/monitoring.ts
Original file line number Diff line number Diff line change
@@ -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", // 특정 위치로 고개를 돌리는 행동
}
161 changes: 142 additions & 19 deletions src/frontend/eyesee-user/src/app/exam-room/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -9,8 +9,17 @@ const RealTimeVideoPage = () => {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const socketRef = useRef<WebSocket | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const recordedChunksRef = useRef<{ timestamp: number; data: Blob }[]>([]); // 청크와 타임스탬프 저장
const captureIntervalRef = useRef<number | null>(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);
Expand All @@ -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) => {
Expand All @@ -37,25 +46,143 @@ 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("카메라 권한을 허용해주세요.");
}
};

// 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 &&
Expand All @@ -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();
}
Expand All @@ -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)" }}
/>
<canvas ref={canvasRef} style={{ display: "none" }} />
<div className="fixed bottom-6 right-6">
Expand Down