diff --git a/package-lock.json b/package-lock.json index 43065e5..600e17a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@aws-sdk/client-s3": "^3.685.0", "@aws-sdk/s3-request-presigner": "^3.685.0", + "@mediapipe/camera_utils": "^0.3.1675466862", + "@mediapipe/face_detection": "^0.4.1646425229", "axios": "^1.7.7", "chart.js": "^4.4.6", "firebase": "^9.10.0", @@ -2136,6 +2138,16 @@ "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==" }, + "node_modules/@mediapipe/camera_utils": { + "version": "0.3.1675466862", + "resolved": "https://registry.npmjs.org/@mediapipe/camera_utils/-/camera_utils-0.3.1675466862.tgz", + "integrity": "sha512-siuXBoUxWo9WL0MeAxIxvxY04bvbtdNl7uCxoJxiAiRtNnCYrurr7Vl5VYQ94P7Sq0gVq6PxIDhWWeZ/pLnSzw==" + }, + "node_modules/@mediapipe/face_detection": { + "version": "0.4.1646425229", + "resolved": "https://registry.npmjs.org/@mediapipe/face_detection/-/face_detection-0.4.1646425229.tgz", + "integrity": "sha512-aeCN+fRAojv9ch3NXorP6r5tcGVLR3/gC1HmtqB0WEZBRXrdP6/3W/sGR0dHr1iT6ueiK95G9PVjbzFosf/hrg==" + }, "node_modules/@next/env": { "version": "15.0.1", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.0.1.tgz", diff --git a/package.json b/package.json index 97b0c94..1eb90ce 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "dependencies": { "@aws-sdk/client-s3": "^3.685.0", "@aws-sdk/s3-request-presigner": "^3.685.0", + "@mediapipe/camera_utils": "^0.3.1675466862", + "@mediapipe/face_detection": "^0.4.1646425229", "axios": "^1.7.7", "chart.js": "^4.4.6", "firebase": "^9.10.0", diff --git a/src/app/interview/ongoing/prepare/page.tsx b/src/app/interview/ongoing/prepare/page.tsx index 1d4c2be..fa1160f 100644 --- a/src/app/interview/ongoing/prepare/page.tsx +++ b/src/app/interview/ongoing/prepare/page.tsx @@ -10,6 +10,7 @@ import { getMessaging, isSupported, onMessage } from "firebase/messaging"; import { firebaseApp } from "@/utils/firebaseConfig"; import useInterviewStore from "@/stores/useInterviewStore"; import MicTest from "@/components/mic-test"; +import VideoTest from "@/components/video-test-2"; const apiUrl = `${setUrl}`; @@ -25,6 +26,7 @@ const InterviewOngoingPreparePage = () => { let hasFetched = false; const requestQuestionList = async () => { + console.log("questionRequest: ", questionRequest); if (!hasFetched) { hasFetched = true; await axios.post(`${apiUrl}/question`, questionRequest, { @@ -140,13 +142,21 @@ const InterviewOngoingPreparePage = () => { return (
- {interview.interviewMethod === "CHAT" ? ( + {interview.interviewMethod === "CHAT" && ( - ) : isReady ? ( + )} + {interview.interviewMethod === "VOICE" && isReady && ( - ) : ( + )} + {interview.interviewMethod === "VOICE" && !isReady && ( )} + {interview.interviewMethod === "VIDEO" && isReady && ( + + )} + {interview.interviewMethod === "VIDEO" && !isReady && ( + + )}
); }; diff --git a/src/components/RecordingIndicator.tsx b/src/components/RecordingIndicator.tsx index 9da0731..90fde8f 100644 --- a/src/components/RecordingIndicator.tsx +++ b/src/components/RecordingIndicator.tsx @@ -1,13 +1,111 @@ +// "use client"; + +// import { motion } from "framer-motion"; +// import { BsMicFill } from "react-icons/bs"; + +// const RecordingIndicator = ({ isRecording }: { isRecording: boolean }) => { +// const ringVariants = { +// active: { +// scale: [1, 1.3, 1], +// opacity: [0.5, 0.2, 0.5], +// borderColor: ["#60a5fa", "#c084fc"], +// transition: { +// duration: 1.2, +// repeat: Infinity, +// ease: "easeInOut", +// }, +// }, +// inactive: { +// scale: 1, +// opacity: 0.3, +// borderColor: "#cbd5e1", +// }, +// }; + +// const highlightVariants = { +// active: { +// rotate: [0, 360], +// transition: { +// duration: 2, +// repeat: Infinity, +// ease: "linear", +// }, +// }, +// inactive: { rotate: 0 }, +// }; + +// return ( +//
+// +// + +// + +// +// +//

+// {isRecording ? "답변 중" : "답변을 준비해주세요"} +//

+//
+// ); +// }; + +// export default RecordingIndicator; + "use client"; import { motion } from "framer-motion"; +import { useEffect, useRef, useState } from "react"; import { BsMicFill } from "react-icons/bs"; -const RecordingIndicator = ({ isRecording }: { isRecording: boolean }) => { - const ringVariants = { +const CameraIndicator = ({ isRecording }: { isRecording: boolean }) => { + const videoRef = useRef(null); + const [isCameraOn, setIsCameraOn] = useState(false); + + useEffect(() => { + console.log("isRecording", isRecording); + const enableCamera = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: true, + }); + if (videoRef.current) { + videoRef.current.srcObject = stream; + setIsCameraOn(true); + } + } catch (error) { + console.error("Error accessing camera: ", error); + setIsCameraOn(false); + } + }; + + enableCamera(); + console.log("isCameraOn", isCameraOn); + // else { + // if (videoRef.current && videoRef.current.srcObject) { + // const tracks = (videoRef.current.srcObject as MediaStream).getTracks(); + // tracks.forEach((track) => track.stop()); + // videoRef.current.srcObject = null; + // setIsCameraOn(false); + // } + // } + }, []); + + const borderVariants = { active: { - scale: [1, 1.3, 1], - opacity: [0.5, 0.2, 0.5], borderColor: ["#60a5fa", "#c084fc"], transition: { duration: 1.2, @@ -16,46 +114,27 @@ const RecordingIndicator = ({ isRecording }: { isRecording: boolean }) => { }, }, inactive: { - scale: 1, - opacity: 0.3, borderColor: "#cbd5e1", }, }; - const highlightVariants = { - active: { - rotate: [0, 360], - transition: { - duration: 2, - repeat: Infinity, - ease: "linear", - }, - }, - inactive: { rotate: 0 }, - }; - return (
- - - - - - + + +

{isRecording ? "답변 중" : "답변을 준비해주세요"}

@@ -63,4 +142,4 @@ const RecordingIndicator = ({ isRecording }: { isRecording: boolean }) => { ); }; -export default RecordingIndicator; +export default CameraIndicator; diff --git a/src/components/interview/step/check-info-step.tsx b/src/components/interview/step/check-info-step.tsx index 5cfdf78..f8d9458 100644 --- a/src/components/interview/step/check-info-step.tsx +++ b/src/components/interview/step/check-info-step.tsx @@ -115,7 +115,8 @@ const CheckInfoStep = ({ onPrev, onNext, onSubmit }: StepSubmitProps) => { { interviewTitle: getInterviewTitle(interview), interviewType: interview.interviewType, - interviewMethod: interview.interviewMethod, + interviewMethod: + interview.interviewMethod === "CHAT" ? "CHAT" : "VOICE", interviewMode: interview.interviewMode, jobId: interview.jobId, questionCount: selectedQuestionCount, @@ -186,7 +187,8 @@ const CheckInfoStep = ({ onPrev, onNext, onSubmit }: StepSubmitProps) => { interviewTitle: data.data.interviewTitle, interviewStatus: data.data.interviewStatus, interviewType: data.data.interviewType, - interviewMethod: data.data.interviewMethod, + interviewMethod: + data.data.interviewMethod === "CHAT" ? "CHAT" : "VOICE", interviewMode: data.data.interviewMode, questionCount: data.data.questionCount, files: diff --git a/src/components/interview/step/method-step.tsx b/src/components/interview/step/method-step.tsx index 179a6ec..eafeece 100644 --- a/src/components/interview/step/method-step.tsx +++ b/src/components/interview/step/method-step.tsx @@ -104,10 +104,9 @@ const MethodStep = ({ onPrev, onNext }: StepProps) => { /> setSelectedMethod("VIDEO")} - disabled={true} />
diff --git a/src/components/video-test-2.tsx b/src/components/video-test-2.tsx new file mode 100644 index 0000000..aa6ddca --- /dev/null +++ b/src/components/video-test-2.tsx @@ -0,0 +1,320 @@ +import React, { useEffect, useRef, useState } from "react"; + +interface VideoTestProps { + handleSetReady: (isVideoChecked: boolean) => void; +} + +const VideoTest = ({ handleSetReady }: VideoTestProps) => { + const [microphonePermission, setMicrophonePermission] = useState(false); + const [volumeLevel, setVolumeLevel] = useState(0); + const [isMicrophoneChecked, setIsMicrophoneChecked] = useState(false); + const [audioProgress, setAudioProgress] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + const [isAudioChecked, setIsAudioChecked] = useState(false); + const [intervalId, setIntervalId] = useState(null); + const [audioInstance, setAudioInstance] = useState( + null + ); + + const videoRef = useRef(null); + const [hasWebcam, setHasWebcam] = useState(false); + const [isVideoChecked, setIsVideoChecked] = useState(false); + const [isVideoActive, setIsVideoActive] = useState(false); + + useEffect(() => { + if (!hasWebcam) return; + + const checkInterval = setInterval(checkVideoActive, 1000); + return () => clearInterval(checkInterval); + }, [hasWebcam]); + + useEffect(() => { + const requestMicrophoneAccess = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + }); + setMicrophonePermission(true); + + const audioContext = new (window.AudioContext || + (window as unknown as { webkitAudioContext: typeof AudioContext }) + .webkitAudioContext)(); + const source = audioContext.createMediaStreamSource(stream); + const analyser = audioContext.createAnalyser(); + + analyser.fftSize = 256; + const dataArray = new Uint8Array(analyser.frequencyBinCount); + + const updateVolume = () => { + analyser.getByteFrequencyData(dataArray); + const volume = + dataArray.reduce((a, b) => a + b, 0) / dataArray.length; + + const scaledVolume = Math.min(100, volume * 1.5); + setVolumeLevel(Math.floor(scaledVolume)); + + // if (scaledVolume > 20) { + // setIsMicrophoneChecked(true); + // } + + requestAnimationFrame(updateVolume); + }; + + source.connect(analyser); + updateVolume(); + } catch (error) { + console.error("마이크 접근 오류:", error); + setMicrophonePermission(false); + } + }; + + requestMicrophoneAccess(); + }, []); + + useEffect(() => { + const setupWebcam = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: true, + }); + + if (videoRef.current) { + videoRef.current.srcObject = stream; + setHasWebcam(true); + console.log("turn on the camera"); + + // 비디오 스트림이 실제로 재생되는지 확인 + videoRef.current.onplaying = () => { + setIsVideoActive(true); + }; + } + } catch (error) { + console.error("웹캠 접근 오류:", error); + setHasWebcam(false); + } + }; + + setupWebcam(); + + return () => { + if (videoRef.current?.srcObject) { + const tracks = (videoRef.current.srcObject as MediaStream).getTracks(); + tracks.forEach((track) => track.stop()); + } + }; + }, [handleSetReady]); + + const togglePlayAudio = () => { + if (isPlaying && audioInstance) { + audioInstance.pause(); + setAudioProgress(0); + setIsPlaying(false); + setAudioInstance(null); + if (intervalId) { + clearInterval(intervalId); + } + return; + } + + const audio = new Audio("/audiotest.mp3"); + setAudioInstance(audio); + setIsPlaying(true); + setAudioProgress(0); + + audio.play(); + const newIntervalId = setInterval(() => { + if (audio.ended) { + clearInterval(newIntervalId); + setAudioProgress(100); + setIsPlaying(false); + } else { + setAudioProgress((audio.currentTime / audio.duration) * 100); + } + }, 100); + + setIntervalId(newIntervalId); + + audio.onended = () => { + clearInterval(newIntervalId); + setAudioProgress(100); + setIsPlaying(false); + setAudioInstance(null); + }; + + audio.onerror = () => { + setIsPlaying(false); + setAudioProgress(0); + alert("오디오 파일 재생에 실패했습니다."); + }; + }; + + const handleClickStartButton = () => { + if (audioInstance) { + audioInstance.pause(); + audioInstance.currentTime = 0; + setAudioInstance(null); + } + + handleSetReady(true); + }; + + // 비디오 프레임이 어두운지 확인하는 함수 + const checkVideoActive = () => { + if (!videoRef.current) return; + + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (!context) return; + + canvas.width = videoRef.current.videoWidth; + canvas.height = videoRef.current.videoHeight; + + context.drawImage(videoRef.current, 0, 0); + const imageData = context.getImageData(0, 0, canvas.width, canvas.height); + const pixels = imageData.data; + + // 프레임의 평균 밝기 계산 + let totalBrightness = 0; + for (let i = 0; i < pixels.length; i += 4) { + const r = pixels[i]; + const g = pixels[i + 1]; + const b = pixels[i + 2]; + totalBrightness += (r + g + b) / 3; + } + const averageBrightness = totalBrightness / (pixels.length / 4); + + // 평균 밝기가 특정 임계값보다 높으면 활성 상태로 간주 + setIsVideoActive(averageBrightness > 30); + }; + + return ( +
+
+
+

음성 테스트

+ {!microphonePermission ? ( +

+ 마이크 접근 권한이 필요합니다. 설정을 확인해주세요. +

+ ) : ( +
+
+ {[...Array(10)].map((_, i) => ( +
= (i + 1) * 10 + ? "bg-blue-500" + : "bg-gray-300" + }`} + style={{ width: `${20 + i * 5}px` }} + >
+ ))} +
+ {!isMicrophoneChecked ? ( +

음성 인식 중...

+ ) : ( +

+ )} + +

+ )} +
+ +
+
+

영상 테스트

+
+
+
+
+
+ +
+

오디오 테스트

+
+ + {!isAudioChecked ? ( +

오디오 확인 중...

+ ) : ( +

+ )} + +

+
+
+ + +
+ ); +}; + +export default VideoTest; diff --git a/src/components/video-test.tsx b/src/components/video-test.tsx new file mode 100644 index 0000000..6576aa7 --- /dev/null +++ b/src/components/video-test.tsx @@ -0,0 +1,148 @@ +import React, { useEffect, useRef, useState } from "react"; + +interface VideoTestProps { + handleSetReady: (isReady: boolean) => void; +} + +const VideoTest = ({ handleSetReady }: VideoTestProps) => { + const videoRef = useRef(null); + const [hasWebcam, setHasWebcam] = useState(false); + const [isActive, setIsActive] = useState(false); + const [isReady, setIsReady] = useState(false); + const activeTimerRef = useRef(null); + + useEffect(() => { + const setupWebcam = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: true, + }); + + if (videoRef.current) { + videoRef.current.srcObject = stream; + setHasWebcam(true); + console.log("turn on the camera"); + + // 비디오 스트림이 실제로 재생되는지 확인 + videoRef.current.onplaying = () => { + setIsActive(true); + // 2초 동안 스트림이 활성화되어 있으면 준비 완료로 간주 + activeTimerRef.current = setTimeout(() => { + setIsReady(true); + // handleSetReady(true); + }, 2000); + }; + } + } catch (error) { + console.error("웹캠 접근 오류:", error); + setHasWebcam(false); + } + }; + + setupWebcam(); + + return () => { + if (activeTimerRef.current) { + clearTimeout(activeTimerRef.current); + } + if (videoRef.current?.srcObject) { + const tracks = (videoRef.current.srcObject as MediaStream).getTracks(); + tracks.forEach((track) => track.stop()); + } + }; + }, [handleSetReady]); + + // 비디오 프레임이 어두운지 확인하는 함수 + const checkVideoActive = () => { + if (!videoRef.current) return; + + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (!context) return; + + canvas.width = videoRef.current.videoWidth; + canvas.height = videoRef.current.videoHeight; + + context.drawImage(videoRef.current, 0, 0); + const imageData = context.getImageData(0, 0, canvas.width, canvas.height); + const pixels = imageData.data; + + // 프레임의 평균 밝기 계산 + let totalBrightness = 0; + for (let i = 0; i < pixels.length; i += 4) { + const r = pixels[i]; + const g = pixels[i + 1]; + const b = pixels[i + 2]; + totalBrightness += (r + g + b) / 3; + } + const averageBrightness = totalBrightness / (pixels.length / 4); + + // 평균 밝기가 특정 임계값보다 높으면 활성 상태로 간주 + setIsActive(averageBrightness > 30); + }; + + // 주기적으로 비디오 활성 상태 체크 + useEffect(() => { + if (!hasWebcam) return; + + const checkInterval = setInterval(checkVideoActive, 1000); + return () => clearInterval(checkInterval); + }, [hasWebcam]); + + return ( +
+
+
+

영상 테스트

+ {/* {!hasWebcam ? ( +

+ 카메라 접근 권한이 필요합니다. 설정을 확인해주세요. +

+ ) : ( */} +
+
+
+ {/* )} */} +
+ +
+

오디오 테스트

+
+
+
+
+ +
+
+ ); +}; + +export default VideoTest;