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: [fix] 사후 레포트 UI 수정 #91

Merged
merged 7 commits into from
Dec 5, 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
4 changes: 2 additions & 2 deletions src/frontend/eyesee-admin/src/components/addExam/Step1.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ const Step1 = ({ examData, setExamData }: Step1Props) => {
<label className="text-black text-[24px] mb-3 block">강의실</label>
<input
type="text"
value={examData.examLocatoin}
onChange={(e) => handleChange("examLocatoin", e.target.value)}
value={examData.examLocation}
onChange={(e) => handleChange("examLocation", e.target.value)}
placeholder="입력해주세요"
className={inputClassName}
/>
Expand Down
20 changes: 11 additions & 9 deletions src/frontend/eyesee-admin/src/components/report/ReportSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const ReportSection = ({ reportData }: ReportSectionType) => {
<ExcelIcon />
<p className="text-[20px]">엑셀 다운로드</p>
</div>
<div className="fixed bottom-5 text-2xl text-red-500 font-bold z-50 shadow-lg">
<div className="fixed bottom-5 text-2xl text-red-500 font-bold z-[100] shadow-lg">
종료된 시험입니다.
</div>
<div className="w-[50%] h-full text-[2.5rem] font-bold text-white">
Expand Down Expand Up @@ -57,15 +57,17 @@ const ReportSection = ({ reportData }: ReportSectionType) => {
<div>부정행위 탐지율</div>
<div>{reportData.cheatingRate}%</div>
</div>
<div className="w-full flex gap-20 py-3 items-center justify-between text-[20px] text-bold border-b border-white">
<div className="w-full flex gap-20 py-3 items-start justify-between text-[20px] text-bold border-b border-white">
<div>부정행위 유형별 통계</div>
{Object.entries(reportData.cheatingTypeStatistics).map(
([type, count]) => (
<span key={type}>
{type}: {count}건{", "}
</span>
)
)}
<div className="flex flex-col gap-2">
{Object.entries(reportData.cheatingTypeStatistics).map(
([type, count]) => (
<span key={type} className="border-b border-gray-500">
{type}: {count}건{", "}
</span>
)
)}
</div>
</div>
</div>
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/frontend/eyesee-admin/src/types/exam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export type ExamRequest = {
examName: string;
examSemester: string;
examStudentNumber: number;
examLocatoin: string;
examLocation: string;
examDate: string;
examStartTime: string;
examDuration: number;
Expand All @@ -18,7 +18,7 @@ export const initialExamData: ExamRequest = {
examName: "",
examSemester: "",
examStudentNumber: 0,
examLocatoin: "",
examLocation: "",
examDate: "",
examStartTime: "",
examDuration: 0,
Expand Down
72 changes: 56 additions & 16 deletions src/frontend/eyesee-user/src/app/exam-room/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,42 @@

import React, { useEffect, useRef, useState } from "react";
import NextButton from "@/components/common/NextButton";
import { useUserIdStore } from "@/store/useUserIdStore";
import { useUserStore } from "@/store/useUserStore";
import { useStore } from "@/store/useStore";

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 [isProcessing, setIsProcessing] = useState(false);

const userId = useStore(useUserStore, (state) => state.userId);
const examId = useStore(useUserStore, (state) => state.examId);

const userId = useStore(useUserIdStore, (state) => state.userId);
const examId = 1;
const setupWebSocket = () => {
console.log(process.env.NEXT_PUBLIC_WEBSOCKET_KEY);
console.log(userId);
console.log(userId, examId);

if (!userId) {
console.error("userId가 설정되지 않았습니다.");
console.log("userId가 설정되지 않았습니다.");
return;
}

Expand Down Expand Up @@ -83,21 +95,27 @@ const RealTimeVideoPage = () => {
// Canvas를 사용해 비디오 프레임을 WebSocket으로 전송
const startRecording = (stream: MediaStream) => {
mediaRecorderRef.current = new MediaRecorder(stream, {
mimeType: "video/webm; codecs=vp8",
mimeType: "video/webm; codecs=vp9",
});

mediaRecorderRef.current.ondataavailable = (event) => {
if (event.data.size > 0) {
const timestamp = Date.now();

// 새로 들어온 데이터 추가
recordedChunksRef.current.push({
timestamp: timestamp,
data: event.data,
});

// if (isProcessing) return; // 부정행위 비디오 처리 및 저장 중에는 비디오 프레임 업데이트 정지
// 슬라이딩 윈도우 방식으로 오래된 데이터 삭제
const currentTime = Date.now();
recordedChunksRef.current = recordedChunksRef.current.filter(
(chunk) => chunk.timestamp >= currentTime - BUFFER_DURATION
(chunk) => chunk.timestamp >= timestamp - BUFFER_DURATION
);

console.log(
`현재 청크 수: ${recordedChunksRef.current.length}, 유지 시간: ${BUFFER_DURATION}ms`
);
}
};
Expand All @@ -117,16 +135,16 @@ const RealTimeVideoPage = () => {
}
};

// 부정행위 비디오 처리 및 저장
const sendCheatingVideo = async (
cheatingTimestamp: string | number | Date
) => {
// ===== AI단에서 웹소켓 메시지를 수신하면 부정행위 비디오 처리 및 저장 =====
const sendCheatingVideo = async (cheatingTimestamp: string) => {
try {
setIsProcessing(true); // 부정행위 감지 시작

console.log("부정행위 발생 타임스탬프:", cheatingTimestamp);

const cheatingDate = new Date(cheatingTimestamp);
console.log("cheatingDate", cheatingDate);

if (isNaN(cheatingDate.getTime())) {
throw new Error("Invalid cheatingTimestamp: " + cheatingTimestamp);
}
Expand All @@ -142,34 +160,55 @@ const RealTimeVideoPage = () => {

// MediaRecorder가 멈춘 후 데이터를 처리
mediaRecorderRef.current!.onstop = () => {
console.log("recordedChunksRef.current :", recordedChunksRef.current);

const previousChunks = recordedChunksRef.current.filter(
(chunk) =>
chunk.timestamp >= startTime && chunk.timestamp <= cheatingTime
);
console.log(`탐지 이전 데이터: ${previousChunks.length} 청크`);
console.log(`탐지 이전 데이터: ${previousChunks}`);

const postCheatingChunks = recordedChunksRef.current.filter(
(chunk) =>
chunk.timestamp > cheatingTime && chunk.timestamp <= endTime
);
console.log(`탐지 이후 데이터: ${postCheatingChunks.length} 청크`);
console.log(`탐지 이후 데이터: ${postCheatingChunks}`);

const allChunks = [...previousChunks, ...postCheatingChunks];
console.log(`allChunks: ${allChunks.length} 청크`);
console.log(`allChunks: ${allChunks}`);
allChunks.forEach((chunk, index) => {
console.log(`손실 탐지 Chunk ${index}:`, chunk.timestamp, chunk.data);
});

const blob = new Blob(
allChunks.map((chunk) => chunk.data),
{
type: "video/webm",
type: "video/webm; codecs=vp9",
}
);

// 디버깅
console.log("blob: ", blob);
console.log(`최종 Blob 크기: ${blob.size / 1024} KB`);
// --

const url = URL.createObjectURL(blob);
// 디버깅
console.log("Generated Blob URL:", url);

// 비디오 태그 생성으로 디버깅
const video = document.createElement("video");
video.src = url;
video.controls = true;
document.body.appendChild(video);
// --

const a = document.createElement("a");
a.href = url;
a.download = `cheating_${new Date().toISOString()}.webm`;
a.click();
URL.revokeObjectURL(url);

console.log("부정행위 비디오 저장 완료");
// 이 부분은 녹화가 중지된 후, 데이터가 완전히 처리된 후에 실행되어야 합니다.
Expand All @@ -183,6 +222,7 @@ const RealTimeVideoPage = () => {
}
};

// AI 단으로 실시간 영상 송신
const captureAndSendFrame = () => {
if (
canvasRef.current &&
Expand Down
10 changes: 8 additions & 2 deletions src/frontend/eyesee-user/src/app/information/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import NextButton from "@/components/common/NextButton";
import SubHeader from "@/components/common/SubHeader";
import InformationSection from "@/components/information/InformationSection";
import { useExamStore } from "@/store/useExamStore";
import { useUserIdStore } from "@/store/useUserIdStore";
import { useUserStore } from "@/store/useUserStore";
import { UserInfoRequest } from "@/types/exam";
import { setAccessToken, setRefreshToken } from "@/utils/auth";
import { informationValidation } from "@/utils/validation";
Expand All @@ -14,8 +14,11 @@ import { useEffect, useState } from "react";

const InformationPage = () => {
const router = useRouter();
const { setUserId } = useUserIdStore();

const { setUserId } = useUserStore();
const { setExamId } = useUserStore();
const { exam } = useExamStore();

const [isAvailable, setIsAvailable] = useState(false);
const [information, setInformation] = useState<UserInfoRequest>({
// TODO: 서버 데이터 타입 통일 필요
Expand All @@ -34,6 +37,9 @@ const InformationPage = () => {
setAccessToken(response.data.access_token);
setRefreshToken(response.data.refresh_token);
setUserId(response.data.userId);
setExamId(response.data.examId);
console.log(response.data.userId, response.data.examId);

router.push("/camera");
} catch (error) {
console.error("수험 정보 입력 실패:", error); // 실패 시 에러 처리
Expand Down
19 changes: 0 additions & 19 deletions src/frontend/eyesee-user/src/store/useUserIdStore.ts

This file was deleted.

23 changes: 23 additions & 0 deletions src/frontend/eyesee-user/src/store/useUserStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";

type UserStore = {
userId: number | null;
examId: number | null;
setUserId: (newUserId: number) => void;
setExamId: (newExamId: number) => void;
};

export const useUserStore = create(
persist<UserStore>(
(set) => ({
userId: null,
examId: null,
setUserId: (newUserId) => set({ userId: newUserId }),
setExamId: (newExamId) => set({ examId: newExamId }),
}),
{
name: "user-storage", // localStorage에 저장될 키 이름
}
)
);
1 change: 1 addition & 0 deletions src/frontend/eyesee-user/src/types/exam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export type UserInfoRequest = {

export type UserInfoResponse = {
userId: number;
examId: number;
access_token: string;
refresh_token: string;
};