background
Face-api.js10분

Face-api.js + TensorFlow 이마 판별 테스트

2025년 4월 25일

0425

좌측 판별하는 테스트 코드 React 로 이식 중 생긴 문제점

javascript
import { useRef, useState, useEffect, useCallback, RefObject } from "react";

interface Point {
  x: number;
  y: number;
  z: number;
}

interface Landmark {
  x: number;
  y: number;
}

interface FaceApiReturnType {
  startFaceDetection: () => Promise<void>;
  stopFaceDetection: () => void;
  isCapturing: boolean;
  capturedImage: string | null;
  resetCapture: () => void;
  faceAngle: number;
  isModelLoaded: boolean;
  isTargetAngleReached: boolean;
  opencvLoaded: boolean;
}

interface FaceApiProps {
  videoRef?: RefObject<HTMLVideoElement | null>;
  canvasRef?: RefObject<HTMLCanvasElement | null>;
  targetAngle?: number;
  onCapture?: (image: string) => void;
}

const useFaceApi = ({ videoRef: externalVideoRef, canvasRef: externalCanvasRef, targetAngle = -45, onCapture }: FaceApiProps = {}): FaceApiReturnType => {
  // OpenCV 관련 변수들
  const faceCascadeRef = useRef<any>(null);
  const capRef = useRef<any>(null);
  const frameRef = useRef<any>(null);
  const grayRef = useRef<any>(null);
  const internalVideoRef = useRef<HTMLVideoElement | null>(null);
  const internalCanvasRef = useRef<HTMLCanvasElement | null>(null);
  const streamRef = useRef<MediaStream | null>(null);

  // 외부에서 전달된 ref 또는 내부 ref 사용
  const videoRef = externalVideoRef || internalVideoRef;
  const canvasRef = externalCanvasRef || internalCanvasRef;

  // 상태 관리
  const [isCapturing, setIsCapturing] = useState<boolean>(true);
  const [isModelLoaded, setIsModelLoaded] = useState<boolean>(false);
  const [capturedImage, setCapturedImage] = useState<string | null>(null);
  const [faceAngle, setFaceAngle] = useState<number>(0);
  const [lastCaptureTime, setLastCaptureTime] = useState<number>(0);
  const [isTargetAngleReached, setIsTargetAngleReached] = useState<boolean>(false);
  const [opencvLoaded, setOpencvLoaded] = useState<boolean>(false);

  // 상수 정의
  const captureDelay = 2000; // 연속 촬영 방지를 위한 딜레이 (ms)
  const angleThreshold = 8; // ±8도 오차 허용

  // 3D 모델 포인트 - 표준 얼굴의 주요 랜드마크 위치
  const modelPoints = [
    { x: 0.0, y: 0.0, z: 0.0 }, // 코끝
    { x: -150.0, y: -150.0, z: -125.0 }, // 왼쪽 입 끝
    { x: 150.0, y: -150.0, z: -125.0 }, // 오른쪽 입 끝
    { x: -200.0, y: 170.0, z: -135.0 }, // 왼쪽 눈
    { x: 200.0, y: 170.0, z: -135.0 }, // 오른쪽 눈
    { x: 0.0, y: 330.0, z: -65.0 }, // 턱
  ];

  // OpenCV.js가 로드되면 실행
  const onFaceApiReady = useCallback(() => {
    console.log("FaceApi 초기화 시작");
    setOpencvLoaded(true);
    loadFaceCascade();
  }, []);

  // 얼굴 감지 모델 로드
  const loadFaceCascade = useCallback(() => {
    if (!window.cv) {
      console.error("OpenCV.js is not loaded");
      return;
    }

    console.log("얼굴 감지 모델 로드 시작");

    // 얼굴 감지용 Haar 캐스케이드 로드
    faceCascadeRef.current = new window.cv.CascadeClassifier();

    // XML 파일을 로드
    const faceCascadeFile = "https://cdnjs.cloudflare.com/ajax/libs/opencv.js/4.7.0/haarcascade_frontalface_default.xml";

    // Utils 클래스 정의 (원본 코드에서 가져옴)
    class Utils {
      createFileFromUrl(path: string, url: string, callback: () => void) {
        const request = new XMLHttpRequest();
        request.open("GET", url, true);
        request.responseType = "arraybuffer";
        request.onload = function () {
          if (request.readyState === 4) {
            if (request.status === 200) {
              const data = new Uint8Array(request.response);
              window.cv.FS_createDataFile("/", path, data, true, false, false);
              callback();
            } else {
              console.error("Failed to load " + url + " status: " + request.status);
            }
          }
        };
        request.send();
      }
    }

    const utils = new Utils();
    utils.createFileFromUrl("haarcascade_frontalface_default.xml", faceCascadeFile, () => {
      faceCascadeRef.current.load("haarcascade_frontalface_default.xml");
      console.log("얼굴 감지 모델 로드 완료");
      loadLandmarksModel();
    });
  }, []);

  // 랜드마크 모델 로드 함수
  const loadLandmarksModel = useCallback(() => {
    // 실제 앱에서는 추가적인 DNN 모델 로드가 필요할 수 있음
    // 여기서는 단순화를 위해 즉시 다음 단계로 진행
    console.log("랜드마크 모델 로드 완료");
    setIsModelLoaded(true);
  }, []);

  // 랜드마크 추정 함수 (단순화된 구현)
  const estimateLandmarks = useCallback((face: { x: number; y: number; width: number; height: number }): Landmark[] => {
    const centerX = face.x + face.width / 2;
    const centerY = face.y + face.height / 2;
    const eyeOffsetY = face.height * 0.25;
    const eyeOffsetX = face.width * 0.15;
    const mouthOffsetY = face.height * 0.25;
    const mouthOffsetX = face.width * 0.15;

    // 단순화된 랜드마크 좌표
    return [
      { x: centerX, y: centerY }, // 코
      { x: centerX - mouthOffsetX, y: centerY + mouthOffsetY }, // 왼쪽 입 끝
      { x: centerX + mouthOffsetX, y: centerY + mouthOffsetY }, // 오른쪽 입 끝
      { x: centerX - eyeOffsetX, y: centerY - eyeOffsetY }, // 왼쪽 눈
      { x: centerX + eyeOffsetX, y: centerY - eyeOffsetY }, // 오른쪽 눈
      { x: centerX, y: centerY + face.height * 0.3 }, // 턱
    ];
  }, []);

  // 헤드 포즈 추정 함수
  const estimateHeadPose = useCallback((landmarks: Landmark[]): number => {
    // 왼쪽 눈과 오른쪽 눈 사이의 거리
    const eyeDistance = Math.sqrt(Math.pow(landmarks[4].x - landmarks[3].x, 2) + Math.pow(landmarks[4].y - landmarks[3].y, 2));

    // 코와 눈 중심 사이의 관계 계산
    const eyeCenterX = (landmarks[3].x + landmarks[4].x) / 2;
    const eyeCenterY = (landmarks[3].y + landmarks[4].y) / 2;
    const noseX = landmarks[0].x;

    // 코가 눈 중심에서 얼마나 벗어났는지 계산
    const deviation = (eyeCenterX - noseX) / eyeDistance;

    // 정면: 0, 완전 측면: 약 90도로 매핑
    const yawAngle = deviation * 90;

    return yawAngle;
  }, []);

  // 프레임 캡처 함수
  const captureFrame = useCallback(() => {
    if (!canvasRef.current) return;

    setIsCapturing(false);

    // 현재 프레임 캡처
    const imageData = canvasRef.current.toDataURL("image/png");
    setCapturedImage(imageData);

    // 콜백 함수가 제공된 경우 호출
    if (onCapture) {
      onCapture(imageData);
    }

    // 리소스 해제
    if (frameRef.current) frameRef.current.delete();
    if (grayRef.current) grayRef.current.delete();
  }, [onCapture]);

  // 비디오 프레임 처리 함수
  const processVideo = useCallback(() => {
    if (!isCapturing || !window.cv) return;

    try {
      if (!capRef.current || !frameRef.current || !grayRef.current || !faceCascadeRef.current) return;

      // 비디오에서 프레임 캡처
      capRef.current.read(frameRef.current);

      // 그레이스케일 변환
      window.cv.cvtColor(frameRef.current, grayRef.current, window.cv.COLOR_RGBA2GRAY);

      // 얼굴 감지
      const faces = new window.cv.RectVector();
      faceCascadeRef.current.detectMultiScale(grayRef.current, faces, 1.1, 3, 0);

      // 인식된 얼굴이 있는 경우
      if (faces.size() > 0) {
        // 첫 번째 얼굴 선택
        const face = faces.get(0);

        // 얼굴 영역 표시
        const point1 = new window.cv.Point(face.x, face.y);
        const point2 = new window.cv.Point(face.x + face.width, face.y + face.height);
        window.cv.rectangle(frameRef.current, point1, point2, [0, 255, 0, 255], 2);

        // 간단한 랜드마크 추정
        const landmarks = estimateLandmarks(face);

        // 랜드마크 표시
        for (let i = 0; i < landmarks.length; i++) {
          window.cv.circle(frameRef.current, new window.cv.Point(landmarks[i].x, landmarks[i].y), 3, [255, 0, 0, 255], -1);
        }

        // 헤드 포즈 추정 (Yaw 각도 계산)
        const yawAngle = estimateHeadPose(landmarks);
        setFaceAngle(yawAngle);

        // 각도가 목표 범위 내에 있는지 확인
        const isInTargetRange = Math.abs(yawAngle - targetAngle) <= angleThreshold;
        setIsTargetAngleReached(isInTargetRange);

        // 목표 각도에 도달하면 자동 캡처
        if (isInTargetRange) {
          const currentTime = Date.now();
          if (currentTime - lastCaptureTime > captureDelay) {
            captureFrame();
            setLastCaptureTime(currentTime);
          }
        }
      }

      // 화면에 프레임 표시
      if (canvasRef.current) {
        window.cv.imshow(canvasRef.current, frameRef.current);
      }

      // 다음 프레임 처리
      requestAnimationFrame(processVideo);
    } catch (err) {
      console.error("Error in processVideo:", err);
    }
  }, [isCapturing, captureFrame, estimateHeadPose, estimateLandmarks, lastCaptureTime, targetAngle, angleThreshold]);

  // 얼굴 감지 시작 (기존 카메라 스트림 사용)
  const startFaceDetection = useCallback(async () => {
    if (!opencvLoaded) {
      console.error("OpenCV.js가 아직 로드되지 않았습니다");
      return;
    }

    console.log("얼굴 감지 시작");

    // 비디오 요소가 있고 스트림이 이미 연결되어 있는지 확인
    if (videoRef.current && videoRef.current.srcObject) {
      // 비디오 프레임 처리를 위한 Mat 객체 생성
      frameRef.current = new window.cv.Mat(videoRef.current.height, videoRef.current.width, window.cv.CV_8UC4);
      grayRef.current = new window.cv.Mat();
      capRef.current = new window.cv.VideoCapture(videoRef.current);

      // 캡처 상태 초기화
      setIsCapturing(true);
      setCapturedImage(null);
      setIsTargetAngleReached(false);

      // 프레임 처리 시작
      processVideo();
    } else {
      console.error("비디오 스트림이 없습니다");
    }
  }, [processVideo, videoRef, opencvLoaded]);

  // 얼굴 감지 중지
  const stopFaceDetection = useCallback(() => {
    setIsCapturing(false);

    // 리소스 해제
    if (capRef.current) capRef.current.delete();
    if (frameRef.current) frameRef.current.delete();
    if (grayRef.current) grayRef.current.delete();
  }, []);

  // 캡처 리셋
  const resetCapture = useCallback(() => {
    setIsCapturing(true);
    setCapturedImage(null);
    setIsTargetAngleReached(false);

    // 새 Mat 객체 생성
    if (window.cv && videoRef.current) {
      frameRef.current = new window.cv.Mat(videoRef.current.height, videoRef.current.width, window.cv.CV_8UC4);
      grayRef.current = new window.cv.Mat();

      // 프레임 처리 재시작
      processVideo();
    }
  }, [processVideo, videoRef]);

  // OpenCV.js 로드 확인
  useEffect(() => {
    // OpenCV.js가 이미 로드되어 있는지 확인
    if (window.cv && window.cv.CascadeClassifier) {
      console.log("OpenCV.js가 이미 로드되어 있습니다");
      onFaceApiReady();
      return;
    }

    // 전역 변수로 설정된 로딩 상태 확인
    if (window.isOpenCvReady) {
      console.log("OpenCV.js가 로드되었습니다 (전역 변수)");
      onFaceApiReady();
      return;
    }

    console.log("OpenCV.js 로딩 대기 중...");

    // OpenCV.js 로딩 이벤트 리스너 추가
    const handleOpenCvReady = () => {
      console.log("OpenCV.js 로딩 이벤트 감지");
      onFaceApiReady();
    };

    // 로딩 이벤트 등록
    window.addEventListener("opencv-ready", handleOpenCvReady);

    // 로딩 상태 정기 확인 (백업 방법)
    const checkInterval = setInterval(() => {
      if (window.cv || window.isOpenCvReady) {
        console.log("OpenCV.js 로딩 확인됨 (인터벌 체크)");
        clearInterval(checkInterval);
        onFaceApiReady();
      }
    }, 500);

    // 컴포넌트 언마운트 시 리소스 해제
    return () => {
      window.removeEventListener("opencv-ready", handleOpenCvReady);
      clearInterval(checkInterval);
      stopFaceDetection();
    };
  }, [onFaceApiReady, stopFaceDetection]);

  return {
    startFaceDetection,
    stopFaceDetection,
    isCapturing,
    capturedImage,
    resetCapture,
    faceAngle,
    isModelLoaded,
    isTargetAngleReached,
    opencvLoaded,
  };
};

// OpenCV 타입 선언
declare global {
  interface Window {
    cv: any; // OpenCV는 JavaScript 라이브러리이므로 any 타입 사용
    isOpenCvReady?: boolean; // OpenCV 로딩 상태 플래그
  }
}

export default useFaceApi;

image.png

image.png

OpenCV 로 테스트해서 생긴 문제.. Fuck


Face-api.js 로 변경

javascript
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React + TS</title>
    <!-- TensorFlow.js 코어 라이브러리 -->
    <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-core@2.4.0/dist/tf-core.js"></script>
    <!-- TensorFlow.js 백엔드 -->
    <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-cpu@2.4.0/dist/tf-backend-cpu.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-webgl@2.4.0/dist/tf-backend-webgl.js"></script>
    <!-- Face-API.js -->
    <script src="/face-api.min.js"></script>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
javascript
import { useRef, useState, useEffect, useCallback, RefObject } from "react";

// Face-API.js 타입 선언
declare global {
  interface Window {
    faceapi: any;
  }
}

// 전역 타입스크립트 타입 정의
declare const faceapi: any;

interface Point {
  x: number;
  y: number;
  z: number;
}

interface Landmark {
  x: number;
  y: number;
}

interface FaceApiReturnType {
  startFaceDetection: () => Promise<void>;
  stopFaceDetection: () => void;
  isCapturing: boolean;
  capturedImage: string | null;
  resetCapture: () => void;
  faceAngle: number;
  isModelLoaded: boolean;
  isTargetAngleReached: boolean;
  faceApiLoaded: boolean;
}

interface FaceApiProps {
  videoRef?: RefObject<HTMLVideoElement | null>;
  canvasRef?: RefObject<HTMLCanvasElement | null>;
  targetAngle?: number;
  onCapture?: (image: string) => void;
}

/**
 * Face-API.js 로딩 여부를 확인하는 함수
 */
const isFaceApiLoaded = (): boolean => {
  return typeof window !== "undefined" && typeof window.faceapi !== "undefined" && window.faceapi !== null;
};

/**
 * Face-API.js 로딩 완료 대기 함수
 */
const waitForFaceApi = async (): Promise<void> => {
  // 이미 로드되었는지 확인
  if (isFaceApiLoaded()) {
    return Promise.resolve();
  }

  // 로드될 때까지 대기
  return new Promise<void>((resolve) => {
    const checkInterval = setInterval(() => {
      if (isFaceApiLoaded()) {
        clearInterval(checkInterval);
        resolve();
      }
    }, 100);

    // 10초 후에도 로드되지 않으면 자동 해제
    setTimeout(() => {
      clearInterval(checkInterval);
    }, 10000);
  });
};

const useFaceApi = ({ videoRef: externalVideoRef, canvasRef: externalCanvasRef, targetAngle = -45, onCapture }: FaceApiProps = {}): FaceApiReturnType => {
  const internalVideoRef = useRef<HTMLVideoElement | null>(null);
  const internalCanvasRef = useRef<HTMLCanvasElement | null>(null);

  // 외부에서 전달된 ref 또는 내부 ref 사용
  const videoRef = externalVideoRef || internalVideoRef;
  const canvasRef = externalCanvasRef || internalCanvasRef;

  // 상태 관리
  const [isCapturing, setIsCapturing] = useState<boolean>(false);
  const [isModelLoaded, setIsModelLoaded] = useState<boolean>(false);
  const [capturedImage, setCapturedImage] = useState<string | null>(null);
  const [faceAngle, setFaceAngle] = useState<number>(0);
  const [lastCaptureTime, setLastCaptureTime] = useState<number>(0);
  const [isTargetAngleReached, setIsTargetAngleReached] = useState<boolean>(false);
  const [faceApiLoaded, setFaceApiLoaded] = useState<boolean>(isFaceApiLoaded());
  const [detectionStarted, setDetectionStarted] = useState<boolean>(false);

  // 상수 정의
  const captureDelay = 2000; // 연속 촬영 방지를 위한 딜레이 (ms)
  const angleThreshold = 10; // ±10도 오차 허용

  // 리소스 정리 함수
  const cleanupResources = useCallback(() => {
    // Face-API.js는 별도의 리소스 해제가 필요 없음
  }, []);

  // 얼굴 감지 중지
  const stopFaceDetection = useCallback(() => {
    setIsCapturing(false);
    setDetectionStarted(false);
    cleanupResources();
  }, [cleanupResources]);

  // 얼굴 감지 초기화
  const initFaceDetection = useCallback(async (): Promise<boolean> => {
    try {
      console.log("얼굴 감지 초기화 시작");

      if (!isFaceApiLoaded()) {
        console.error("Face-API.js가 로드되지 않았습니다");
        return false;
      }

      // 모델 경로 디버깅
      const modelUrl = "/models";
      console.log("모델 URL:", modelUrl);

      // Face-API.js 모델 로드
      console.log("모델 로드 시도...");

      // loadFromUri 메서드 사용 - 이 방법이 더 안정적입니다
      await faceapi.loadTinyFaceDetectorModel(modelUrl);
      await faceapi.loadFaceLandmarkModel(modelUrl);

      console.log("얼굴 감지 모델 로드 완료");
      setIsModelLoaded(true);
      return true;
    } catch (error) {
      console.error("얼굴 감지 초기화 실패:", error);
      return false;
    }
  }, []);

  // 각도 계산 함수
  const calculateYaw = useCallback((landmarks: any): number => {
    // 왼쪽과 오른쪽 눈 중심점
    const leftEye = landmarks.getLeftEye();
    const rightEye = landmarks.getRightEye();

    const leftEyeCenter = {
      x: leftEye.reduce((sum: number, pt: any) => sum + pt.x, 0) / leftEye.length,
      y: leftEye.reduce((sum: number, pt: any) => sum + pt.y, 0) / leftEye.length,
    };

    const rightEyeCenter = {
      x: rightEye.reduce((sum: number, pt: any) => sum + pt.x, 0) / rightEye.length,
      y: rightEye.reduce((sum: number, pt: any) => sum + pt.y, 0) / rightEye.length,
    };

    // 눈 사이 거리
    const eyeDistance = Math.sqrt(Math.pow(rightEyeCenter.x - leftEyeCenter.x, 2) + Math.pow(rightEyeCenter.y - leftEyeCenter.y, 2));

    // 코 위치 (코끝)
    const nose = landmarks.getNose()[3];

    // 눈 중심선과 코의 거리 계산
    const eyeMidX = (leftEyeCenter.x + rightEyeCenter.x) / 2;
    const deviation = (eyeMidX - nose.x) / eyeDistance;

    // 각도로 변환 (근사치) - 부호 반전하여 올바른 방향으로 표시
    // 좌측으로 회전 시 양수, 우측으로 회전 시 음수
    return deviation * -90; // 부호 반전
  }, []);

  // 프레임 캡처 함수
  const captureFrame = useCallback(() => {
    if (!canvasRef.current || !videoRef.current) return;

    setIsCapturing(false);

    const ctx = canvasRef.current.getContext("2d");
    if (ctx) {
      ctx.drawImage(videoRef.current, 0, 0, canvasRef.current.width, canvasRef.current.height);
    }

    // 현재 프레임 캡처
    const imageData = canvasRef.current.toDataURL("image/png");
    setCapturedImage(imageData);

    // 콜백 함수가 제공된 경우 호출
    if (onCapture) {
      onCapture(imageData);
    }

    // 리소스 해제
    cleanupResources();
  }, [onCapture, cleanupResources]);

  // 비디오 프레임 처리 함수
  const processVideo = useCallback(async () => {
    if (!isCapturing || !isFaceApiLoaded() || !detectionStarted) return;

    try {
      if (!videoRef.current || !canvasRef.current) return;

      // 캔버스 컨텍스트 가져오기
      const ctx = canvasRef.current.getContext("2d");
      if (!ctx) return;

      // 비디오 프레임을 캔버스에 그림
      ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);

      // 얼굴 감지 - 옵션 조정
      const detections = await faceapi
        .detectAllFaces(
          videoRef.current,
          new faceapi.TinyFaceDetectorOptions({
            inputSize: 320, // 입력 크기 증가
            scoreThreshold: 0.5, // 임계값 조정
          })
        )
        .withFaceLandmarks();

      // 감지된 얼굴이 있는 경우
      if (detections.length > 0) {
        // 첫 번째 얼굴 선택
        const face = detections[0];

        // 랜드마크 그리기
        faceapi.draw.drawFaceLandmarks(canvasRef.current, face);

        // 헤드 포즈 각도 계산
        const yawAngle = calculateYaw(face.landmarks);

        // 값이 유효한 경우에만 상태 업데이트
        if (!isNaN(yawAngle) && isFinite(yawAngle)) {
          setFaceAngle(yawAngle);

          // 콘솔에 현재 각도 정보 출력
          console.log("각도:", yawAngle.toFixed(1), "도, 타겟 각도:", targetAngle);

          // 각도가 목표 범위 내에 있는지 확인
          const isInTargetRange = Math.abs(yawAngle - targetAngle) <= angleThreshold;
          setIsTargetAngleReached(isInTargetRange);

          // 목표 각도에 도달하면 자동 캡처
          if (isInTargetRange) {
            const currentTime = Date.now();
            if (currentTime - lastCaptureTime > captureDelay) {
              // 여기서 바로 캡처하지 않고 시간을 두고 실행
              setTimeout(() => {
                if (isCapturing) {
                  // 캡처 중인지 다시 확인
                  captureFrame();
                  setLastCaptureTime(currentTime);
                }
              }, 300);
            }
          }
        }
      } else {
        setIsTargetAngleReached(false);
      }

      // 다음 프레임 처리 (requestAnimationFrame 사용)
      if (isCapturing) {
        requestAnimationFrame(() => processVideo());
      }
    } catch (err) {
      console.error("비디오 처리 오류:", err);
      if (isCapturing) {
        // 오류 발생 시 약간의 지연 후 다시 시도
        setTimeout(() => {
          requestAnimationFrame(() => processVideo());
        }, 500);
      }
    }
  }, [isCapturing, detectionStarted, videoRef, canvasRef, captureFrame, calculateYaw, lastCaptureTime, targetAngle, angleThreshold]);

  // 얼굴 감지 시작 (기존 카메라 스트림 사용)
  const startFaceDetection = useCallback(async () => {
    if (!faceApiLoaded) {
      console.error("Face-API.js가 아직 로드되지 않았습니다");
      return;
    }

    if (!isModelLoaded) {
      // 초기화
      const success = await initFaceDetection();
      if (!success) {
        console.error("얼굴 감지 초기화에 실패했습니다");
        return;
      }
    }

    // 이미 캡처 중이면 중지
    if (isCapturing) {
      stopFaceDetection();
    }

    console.log("얼굴 감지 시작");

    // 비디오 요소가 있고 스트림이 이미 연결되어 있는지 확인
    if (videoRef.current && videoRef.current.srcObject) {
      try {
        // 캔버스 크기 설정
        if (canvasRef.current) {
          canvasRef.current.width = videoRef.current.videoWidth || 640;
          canvasRef.current.height = videoRef.current.videoHeight || 480;
        }

        // 캡처 상태 초기화
        setIsCapturing(true);
        setCapturedImage(null);
        setIsTargetAngleReached(false);

        // 약간의 지연 후 감지 시작
        setTimeout(() => {
          setDetectionStarted(true);
          processVideo();
        }, 500);
      } catch (error) {
        console.error("얼굴 감지 시작 실패:", error);
      }
    } else {
      console.error("비디오 스트림이 없거나 접근할 수 없습니다");
    }
  }, [processVideo, videoRef, faceApiLoaded, isModelLoaded, isCapturing, initFaceDetection, stopFaceDetection]);

  // 캡처 리셋
  const resetCapture = useCallback(() => {
    if (isCapturing) {
      stopFaceDetection();
    }

    setIsCapturing(true);
    setCapturedImage(null);
    setIsTargetAngleReached(false);

    // 프레임 처리 재시작
    setDetectionStarted(true);
    processVideo();
  }, [isCapturing, processVideo, stopFaceDetection]);

  // Face-API.js 초기화 (최초 한 번)
  useEffect(() => {
    async function initFaceAPI() {
      try {
        // Face-API.js 로드 대기
        await waitForFaceApi();
        console.log("Face-API.js 로드 확인됨, 초기화 시작");
        setFaceApiLoaded(true);

        // 얼굴 감지 초기화
        await initFaceDetection();
      } catch (error) {
        console.error("Face-API.js 초기화 실패:", error);
      }
    }

    // 이미 로드되었는지 확인
    if (isFaceApiLoaded()) {
      console.log("Face-API.js가 이미 로드되어 있습니다");
      setFaceApiLoaded(true);
      initFaceDetection();
    } else {
      initFaceAPI();
    }

    // 클린업 함수
    return () => {
      stopFaceDetection();
    };
  }, [initFaceDetection, stopFaceDetection]);

  return {
    startFaceDetection,
    stopFaceDetection,
    isCapturing,
    capturedImage,
    resetCapture,
    faceAngle,
    isModelLoaded,
    isTargetAngleReached,
    faceApiLoaded,
  };
};

export default useFaceApi;
javascript
import { useState, useRef, useEffect } from "react";

export interface PhotoState {
  front: string | null;
  left: string | null;
  right: string | null;
}

export type ViewType = "front" | "left" | "right" | null;

export const useCamera = () => {
  const videoRef = useRef<HTMLVideoElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const streamRef = useRef<MediaStream | null>(null);
  const [currentView, setCurrentView] = useState<ViewType>("front");
  const [photos, setPhotos] = useState<PhotoState>({
    front: null,
    left: null,
    right: null,
  });
  const [isVideoReady, setIsVideoReady] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [isDebug, setIsDebug] = useState(false);
  const [isMirrored, setIsMirrored] = useState(true);
  const [isStartingCamera, setIsStartingCamera] = useState(false);

  // 디버그 로그 함수
  const debugLog = (...args: any[]) => {
    if (isDebug) {
      console.log("[카메라 디버그]", ...args);
    }
  };

  // 카메라 시작 - HTML5 기본 API만 사용
  const startCamera = async () => {
    if (isStartingCamera) {
      debugLog("카메라가 이미 시작 중입니다.");
      return;
    }

    setIsStartingCamera(true);
    setError(null);
    debugLog("카메라 시작 요청됨");

    try {
      // 기존 스트림이 있다면 정리
      if (streamRef.current) {
        streamRef.current.getTracks().forEach((track) => {
          debugLog("기존 트랙 정지:", track.kind, track.label);
          track.stop();
        });
        streamRef.current = null;
      }

      if (videoRef.current && videoRef.current.srcObject) {
        const oldStream = videoRef.current.srcObject as MediaStream;
        oldStream.getTracks().forEach((track) => {
          debugLog("기존 트랙 정지:", track.kind, track.label);
          track.stop();
        });
        videoRef.current.srcObject = null;
        setIsVideoReady(false);
      }

      debugLog("MediaDevices.getUserMedia 호출 중...");

      // HTML5 기본 카메라 API 사용
      const stream = await navigator.mediaDevices.getUserMedia({
        video: {
          facingMode: "user", // 전면 카메라 사용
          width: { ideal: 1280 },
          height: { ideal: 720 },
        },
        audio: false,
      });

      // 참조 저장
      streamRef.current = stream;

      debugLog("스트림 획득 성공:", stream.id, "트랙 수:", stream.getTracks().length);
      stream.getTracks().forEach((track) => {
        debugLog("트랙 정보:", track.kind, track.label, "활성:", track.enabled);
      });

      if (videoRef.current) {
        // 먼저 srcObject 설정
        videoRef.current.srcObject = stream;
        videoRef.current.muted = true;
        videoRef.current.playsInline = true; // iOS 지원 개선

        // 좌우반전 스타일 적용
        if (isMirrored) {
          videoRef.current.style.transform = "scaleX(-1)";
        } else {
          videoRef.current.style.transform = "scaleX(1)";
        }

        // 브라우저 호환성을 위한 자동 재생 시도
        try {
          debugLog("비디오 재생 시도");
          // play 프로미스를 기다림
          await videoRef.current.play();
          debugLog("비디오 재생 성공");
          setIsVideoReady(true);
        } catch (playError) {
          console.error("비디오 재생 실패:", playError);
          setError("비디오 재생 실패: " + (playError as Error).message);
        }
      } else {
        setError("비디오 요소를 찾을 수 없습니다");
        debugLog("비디오 요소 참조 없음");
      }
    } catch (err) {
      const errorMsg = "카메라 접근에 실패했습니다: " + (err as Error).message;
      setError(errorMsg);
      debugLog("카메라 접근 오류:", err);

      // DOMException: Permission denied 오류 처리
      if ((err as Error).name === "NotAllowedError") {
        setError("카메라 접근 권한이 거부되었습니다. 브라우저 설정에서 카메라 권한을 허용해주세요.");
      }
      // DOMException: Requested device not found 오류 처리
      else if ((err as Error).name === "NotFoundError") {
        setError("카메라 장치를 찾을 수 없습니다. 카메라가 연결되어 있는지 확인해주세요.");
      }
    } finally {
      setIsStartingCamera(false);
    }
  };

  // 좌우반전 토글
  const toggleMirror = () => {
    setIsMirrored((prev) => !prev);
    if (videoRef.current) {
      videoRef.current.style.transform = isMirrored ? "scaleX(1)" : "scaleX(-1)";
    }
    debugLog("좌우반전 상태 변경:", !isMirrored);
  };

  // useEffect로 isMirrored 변경 시 비디오 스타일 업데이트
  useEffect(() => {
    if (videoRef.current) {
      videoRef.current.style.transform = isMirrored ? "scaleX(-1)" : "scaleX(1)";
    }
  }, [isMirrored]);

  // 카메라 정지
  const stopCamera = () => {
    debugLog("카메라 정지 요청됨");
    if (streamRef.current) {
      streamRef.current.getTracks().forEach((track) => {
        debugLog("트랙 정지:", track.kind);
        track.stop();
      });
      streamRef.current = null;
    }

    if (videoRef.current && videoRef.current.srcObject) {
      const stream = videoRef.current.srcObject as MediaStream;
      stream.getTracks().forEach((track) => {
        debugLog("트랙 정지:", track.kind);
        track.stop();
      });
      videoRef.current.srcObject = null;
      setIsVideoReady(false);
      debugLog("카메라 정지 완료");
    } else {
      debugLog("정지할 스트림이 없음");
    }
  };

  // 비디오 요소 상태 디버깅
  const checkVideoStatus = () => {
    if (!videoRef.current) {
      debugLog("비디오 요소가 없음");
      return "비디오 요소가 없음";
    }

    const video = videoRef.current;
    const status = {
      readyState: video.readyState,
      paused: video.paused,
      ended: video.ended,
      srcObject: video.srcObject ? "있음" : "없음",
      videoWidth: video.videoWidth,
      videoHeight: video.videoHeight,
      currentTime: video.currentTime,
      duration: video.duration,
      volume: video.volume,
      muted: video.muted,
    };

    debugLog("비디오 상태:", status);
    return status;
  };

  // 사진 캡처 - HTML5 Canvas 사용
  const capturePhoto = () => {
    if (!videoRef.current || !currentView || !isVideoReady) {
      debugLog("캡처 조건 미충족:", {
        videoRef: !!videoRef.current,
        currentView,
        isVideoReady,
      });
      return;
    }

    try {
      debugLog("사진 캡처 시도", currentView);

      // HTML5 Canvas를 사용하여 비디오 프레임 캡처
      const canvas = canvasRef.current || document.createElement("canvas");
      const video = videoRef.current;

      // 캔버스 크기를 비디오 크기에 맞춤
      const videoWidth = video.videoWidth || 640;
      const videoHeight = video.videoHeight || 480;

      debugLog("비디오 크기:", videoWidth, "x", videoHeight);

      canvas.width = videoWidth;
      canvas.height = videoHeight;

      const ctx = canvas.getContext("2d");
      if (ctx) {
        ctx.clearRect(0, 0, canvas.width, canvas.height);

        // 좌우반전 상태에 따라 캡처 이미지 처리
        if (isMirrored) {
          // 좌우반전된 상태로 캡처하는 경우
          ctx.translate(canvas.width, 0);
          ctx.scale(-1, 1);
          ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
          ctx.setTransform(1, 0, 0, 1, 0, 0); // 변환 초기화
        } else {
          // 일반 상태로 캡처
          ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
        }

        debugLog("캔버스에 이미지 그리기 완료");

        // 캔버스의 이미지를 데이터 URL로 변환
        const photoData = canvas.toDataURL("image/jpeg", 0.9);
        debugLog("이미지 데이터 URL 생성 완료, 길이:", photoData.length);

        setPhotos((prev) => ({
          ...prev,
          [currentView]: photoData,
        }));

        // 다음 뷰로 전환
        if (currentView === "front") {
          setCurrentView("left");
          debugLog("다음 뷰로 전환: left");
        } else if (currentView === "left") {
          setCurrentView("right");
          debugLog("다음 뷰로 전환: right");
        } else if (currentView === "right") {
          setCurrentView(null);
          debugLog("모든 뷰 캡처 완료, 카메라 정지");
          stopCamera();
        }
      } else {
        setError("캔버스 컨텍스트를 가져올 수 없습니다");
        debugLog("캔버스 컨텍스트 없음");
      }
    } catch (error) {
      const errorMsg = "사진 캡처에 실패했습니다: " + (error as Error).message;
      setError(errorMsg);
      debugLog("캡처 오류:", error);
    }
  };

  // 특정 방향 촬영 시작
  const handlePhotoCapture = (view: ViewType) => {
    debugLog("특정 방향 촬영 시작:", view);
    setCurrentView(view);
    if (view) {
      setTimeout(() => {
        startCamera();
      }, 100);
    }
  };

  // 비디오 로드 완료 이벤트 핸들러
  const handleVideoLoad = () => {
    debugLog("비디오 로드 완료 이벤트 발생");
    checkVideoStatus();
    setIsVideoReady(true);
  };

  // 비디오 에러 이벤트 핸들러
  const handleVideoError = (event: any) => {
    debugLog("비디오 에러 발생:", event);
    setError("비디오 로드 중 오류가 발생했습니다: " + event.target.error?.message || "알 수 없는 오류");
  };

  // 디버그 모드 토글
  const toggleDebug = () => {
    setIsDebug((prev) => !prev);
    debugLog("디버그 모드 토글됨");
  };

  // 컴포넌트 마운트 시 카메라 시작
  useEffect(() => {
    debugLog("컴포넌트 마운트됨");

    const timer = setTimeout(() => {
      startCamera();
    }, 300);

    // 컴포넌트 언마운트 시 카메라 리소스 정리
    return () => {
      clearTimeout(timer);
      debugLog("컴포넌트 언마운트됨");
      stopCamera();
    };
  }, []);

  const allPhotosCaptures = photos.front && photos.left && photos.right;
  debugLog("모든 사진 캡처 완료 여부:", !!allPhotosCaptures);

  return {
    videoRef,
    canvasRef,
    currentView,
    isVideoReady,
    photos,
    error,
    startCamera,
    stopCamera,
    capturePhoto,
    handlePhotoCapture,
    handleVideoLoad,
    handleVideoError,
    allPhotosCaptures,
    checkVideoStatus,
    toggleDebug,
    isDebug,
    isMirrored,
    toggleMirror,
  };
};

export default useCamera;
javascript
import { useCamera } from "../hooks/useCamera";
import useFaceApi from "../hooks/useFaceApi";
import { useResponsive } from "../../shared/hooks/useResponsive";
import { useEffect, useState, useRef, useCallback, useMemo } from "react";
import { motion } from "framer-motion";
import Tutorial, { TutorialStep } from "./Tutorial"; // 튜토리얼 컴포넌트 import
import StepIndicator, { Step } from "./StepIndicator"; // 스텝 인디케이터 컴포넌트 import

const AnalyzeForm = () => {
  const { videoRef, currentView, isVideoReady, photos, capturePhoto, handleVideoLoad, allPhotosCaptures, handlePhotoCapture, error: cameraError } = useCamera();
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const { isMobile } = useResponsive();
  const [prevView, setPrevView] = useState(currentView);
  const [showAngleDetection, setShowAngleDetection] = useState(false);
  const [detectionCompleted, setDetectionCompleted] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // 오류 처리
  useEffect(() => {
    if (cameraError) {
      setError(cameraError);
      console.error("카메라 오류:", cameraError);
    }
  }, [cameraError]);

  // 촬영 성공 메시지 상태
  const [captureSuccessMessage, setCaptureSuccessMessage] = useState<string | null>(null);

  // 촬영 성공 메시지 표시 함수 - 메모이제이션 적용
  const showCaptureSuccess = useCallback((direction: string) => {
    setCaptureSuccessMessage(`${direction} 촬영 완료!`);
    const timerId = setTimeout(() => {
      setCaptureSuccessMessage(null);
    }, 1500);

    // 클린업 함수 - 컴포넌트 언마운트 시 타이머 제거
    return () => clearTimeout(timerId);
  }, []);

  // Face-API.js 훅 통합 - 각 방향별로 다른 각도값 전달
  const leftFaceApi = useFaceApi({
    videoRef,
    canvasRef,
    targetAngle: 45, // 왼쪽 45도 (좌측으로 고개를 돌리면 양수 각도)
    onCapture: useCallback(
      (imageData: string) => {
        if (currentView === "left") {
          // useCamera의 photos 상태에 추가
          setShowAngleDetection(false);
          // 왼쪽 촬영 완료 메시지 표시
          showCaptureSuccess("왼쪽");
          // 다음 뷰로 이동
          const timerId = setTimeout(() => {
            handlePhotoCapture("right");
          }, 1500);

          return () => clearTimeout(timerId);
        }
      },
      [currentView, showCaptureSuccess, handlePhotoCapture]
    ),
  });

  const rightFaceApi = useFaceApi({
    videoRef,
    canvasRef,
    targetAngle: -45, // 오른쪽 45도 (우측으로 고개를 돌리면 음수 각도)
    onCapture: useCallback(
      (imageData: string) => {
        if (currentView === "right") {
          setShowAngleDetection(false);
          // 우측 촬영 완료 메시지 표시
          showCaptureSuccess("오른쪽");
          // 모든 촬영 완료 후 감지 중지
          const timerId = setTimeout(() => {
            handlePhotoCapture(null);
            setDetectionCompleted(true);
          }, 1500);

          return () => clearTimeout(timerId);
        }
      },
      [currentView, showCaptureSuccess, handlePhotoCapture]
    ),
  });

  // 온보딩 튜토리얼 상태
  const [showTutorial, setShowTutorial] = useState(false);
  const [tutorialStep, setTutorialStep] = useState(0);

  // 튜토리얼 단계별 정보
  const tutorialSteps: TutorialStep[] = [
    {
      title: "이마가 보이게 해주세요",
      description: "머리카락이 이마를 가리지 않도록 해주세요",
      position: "top",
      focusArea: "forehead",
      icon: (
        <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 11.5V14m0-2.5v-6a2.5 2.5 0 015 0v6a2.5 2.5 0 01-5 0z" />
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a8 8 0 018 8m-8 0a3 3 0 01-3-3V4" />
        </svg>
      ),
    },
    {
      title: "정면을 바라봐주세요",
      description: "첫 번째 사진은 정면을 촬영합니다",
      position: "center",
      focusArea: "front",
      icon: (
        <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
          <path
            strokeLinecap="round"
            strokeLinejoin="round"
            strokeWidth={2}
            d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
          />
        </svg>
      ),
    },
    {
      title: "좌측을 바라봐주세요",
      description: "왼쪽으로 약 45도 각도로 고개를 돌려주세요",
      position: "left",
      focusArea: "left",
      icon: (
        <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
        </svg>
      ),
    },
    {
      title: "우측을 바라봐주세요",
      description: "오른쪽으로 약 45도 각도로 고개를 돌려주세요",
      position: "right",
      focusArea: "right",
      icon: (
        <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7-7 7" />
        </svg>
      ),
    },
  ];

  // 진행 상태를 표시하는 데이터
  const steps: Step[] = [
    { id: "front", label: "정면", description: "정면을 바라보세요", complete: !!photos.front || currentView === "front" },
    { id: "left", label: "좌측", description: "왼쪽으로 45°", complete: !!photos.left || currentView === "left" },
    { id: "right", label: "우측", description: "오른쪽으로 45°", complete: !!photos.right || currentView === "right" },
  ];

  // 현재 단계에 해당하는 step 객체
  const currentStep = steps.find((step) => step.id === currentView) || steps[0];

  // 뷰가 변경될 때 prevView 업데이트 및 얼굴 각도 감지 설정
  useEffect(() => {
    setPrevView(currentView);

    // 각도 자동 감지 초기화
    let timerId: NodeJS.Timeout | null = null;

    if (currentView === "left") {
      timerId = setTimeout(() => {
        setShowAngleDetection(true);
        leftFaceApi.startFaceDetection();
      }, 1000);
    } else if (currentView === "right") {
      timerId = setTimeout(() => {
        setShowAngleDetection(true);
        rightFaceApi.startFaceDetection();
      }, 1000);
    } else if (currentView === "front") {
      // 정면 촬영 시 안내 메시지
      setCaptureSuccessMessage("정면을 촬영해주세요");
      timerId = setTimeout(() => {
        setCaptureSuccessMessage(null);
      }, 1500);
    } else {
      // 모든 촬영이 완료되면 감지 중지
      setShowAngleDetection(false);
      leftFaceApi.stopFaceDetection();
      rightFaceApi.stopFaceDetection();
    }

    // 클린업 함수: 언마운트 또는 의존성 변경 시 타이머 제거
    return () => {
      if (timerId) clearTimeout(timerId);
    };
  }, [currentView]); // leftFaceApi와 rightFaceApi는 의존성에서 제거

  // 튜토리얼 시작
  const startTutorial = () => {
    setShowTutorial(true);
    setTutorialStep(0);
  };

  // 튜토리얼 다음 단계로 이동
  const nextTutorialStep = () => {
    if (tutorialStep < tutorialSteps.length - 1) {
      setTutorialStep(tutorialStep + 1);
    } else {
      setShowTutorial(false);
    }
  };

  // 튜토리얼 종료
  const closeTutorial = () => {
    setShowTutorial(false);
  };

  // 얼굴 각도 안내 메시지
  const getAngleGuidance = () => {
    if (!showAngleDetection) return "";

    const api = currentView === "left" ? leftFaceApi : rightFaceApi;
    const targetAngle = currentView === "left" ? 45 : -45;
    const angleDiff = Math.abs(api.faceAngle - targetAngle);

    if (api.isTargetAngleReached) {
      return "좋습니다! 이 각도를 유지해주세요";
    } else if (angleDiff > 20) {
      return `좀 더 ${currentView === "left" ? "왼쪽" : "오른쪽"}으로 돌려주세요`;
    } else if (angleDiff > 10) {
      return "거의 다 왔어요!";
    }
    return "각도를 조정 중입니다...";
  };

  // Face-API가 로드되었는지 확인
  useEffect(() => {
    const checkFaceApiLoaded = () => {
      if (leftFaceApi.faceApiLoaded && rightFaceApi.faceApiLoaded) {
        console.log("Face-API.js가 성공적으로 로드되었습니다.");
      }
    };

    checkFaceApiLoaded();
  }, [leftFaceApi.faceApiLoaded, rightFaceApi.faceApiLoaded]);

  // 수동 촬영 처리
  const handleManualCapture = () => {
    if (showAngleDetection) {
      // 각도 감지 중일 때는 무시
      return;
    }

    if (currentView) {
      showCaptureSuccess(currentView === "front" ? "정면" : currentView === "left" ? "왼쪽" : "오른쪽");
      capturePhoto();
    }
  };

  // 분석하기 버튼 클릭 핸들러
  const handleAnalyzeClick = () => {
    console.log("분석 시작");
    // TODO: 분석 로직 추가
  };

  return (
    <div className="w-full h-screen flex flex-col items-center justify-center py-2">
      {/* 메인 컨테이너 - 캔버스와 진행상태를 가로로 배치 */}
      <div className="w-full h-[90vh] max-w-7xl mx-auto flex flex-col md:flex-row gap-4">
        {/* 좌측 카메라 컨테이너 - 더 큰 비율로 변경 */}
        <div className="w-full h-full md:w-[90%] xl:w-[92%] bg-[#1c2536] rounded-lg overflow-hidden shadow-xl flex flex-col">
          {/* 상단 안내 텍스트 */}
          <div className="text-white text-center py-4 px-2 bg-[#0f1520]">
            {captureSuccessMessage ? (
              <motion.p className="text-lg font-medium text-green-400" initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }}>
                {captureSuccessMessage}
              </motion.p>
            ) : (
              <motion.p
                className="text-lg font-medium"
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                key={showTutorial ? tutorialSteps[tutorialStep].title : showAngleDetection ? getAngleGuidance() : currentStep?.description}
              >
                {showTutorial ? tutorialSteps[tutorialStep].description : showAngleDetection ? getAngleGuidance() : currentStep?.description || "촬영이 완료되었습니다!"}
              </motion.p>
            )}
            {showAngleDetection && (
              <motion.p className="text-sm mt-1 text-gray-300" initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
                현재 각도: {currentView === "left" ? leftFaceApi.faceAngle.toFixed(1) : rightFaceApi.faceAngle.toFixed(1)}°
              </motion.p>
            )}
          </div>

          {/* 카메라 영역 - 최대 높이 활용 */}
          <div className="flex-grow relative h-full overflow-hidden">
            {/* 비디오 */}
            <video ref={videoRef} autoPlay playsInline muted onLoadedMetadata={handleVideoLoad} className="w-full h-full object-cover" />

            {/* 얼굴 감지용 캔버스 - 비디오 위에 절대 위치로 배치 */}
            <canvas ref={canvasRef} className="absolute top-0 left-0 w-full h-full object-cover" />

            {/* 튜토리얼 모드가 아닐 때만 타원형 마스크 표시 */}
            {!showTutorial && !showAngleDetection && (
              <div className="absolute top-0 left-0 w-full h-full">
                <svg className="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
                  <defs>
                    <mask id="ellipseMask">
                      <rect width="100" height="100" fill="white" />
                      <ellipse cx="50" cy="50" rx="20" ry="30" fill="black" />
                    </mask>
                  </defs>
                  {/* 반투명 검은색 배경 */}
                  <rect width="100" height="100" fill="rgba(0, 0, 0, 0.7)" mask="url(#ellipseMask)" />

                  {/* 타원형 테두리 - SVG로 직접 그리기 */}
                  <ellipse cx="50" cy="50" rx="20" ry="30" fill="none" stroke="white" strokeWidth="0.5" />
                </svg>
              </div>
            )}

            {/* 튜토리얼 컴포넌트 */}
            <Tutorial show={showTutorial} currentStep={tutorialStep} tutorialSteps={tutorialSteps} onNext={nextTutorialStep} onClose={closeTutorial} />

            {/* 모든 촬영이 완료되었을 때 표시할 메시지 */}
            {detectionCompleted && !currentView && (
              <div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-60">
                <motion.div
                  className="bg-white rounded-lg p-6 max-w-md text-center shadow-xl"
                  initial={{ opacity: 0, scale: 0.8 }}
                  animate={{ opacity: 1, scale: 1 }}
                  transition={{ type: "spring", stiffness: 300, damping: 25 }}
                >
                  <svg className="w-16 h-16 mx-auto text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
                  </svg>
                  <h2 className="text-2xl font-bold mt-4 text-gray-800">촬영 완료!</h2>
                  <p className="mt-2 text-gray-600">모든 각도의 촬영이 완료되었습니다. 이제 분석을 시작할 수 있습니다.</p>
                  <motion.button
                    className="mt-4 bg-blue-600 text-white px-6 py-2 rounded-lg text-lg font-medium"
                    whileHover={{ scale: 1.05, boxShadow: "0 10px 25px -5px rgba(59, 130, 246, 0.5)" }}
                    whileTap={{ scale: 0.95 }}
                    onClick={handleAnalyzeClick}
                  >
                    분석하기
                  </motion.button>
                </motion.div>
              </div>
            )}

            {/* 촬영 버튼 */}
            {!detectionCompleted && currentView && (
              <div className="absolute bottom-6 left-1/2 transform -translate-x-1/2 z-10">
                <motion.button
                  onClick={handleManualCapture}
                  disabled={!isVideoReady || showTutorial || showAngleDetection}
                  className={`text-white px-10 py-3 rounded-full flex items-center space-x-2 transition-all ${
                    !isVideoReady || showTutorial || showAngleDetection ? "opacity-50 bg-gray-600" : "bg-[#0f1520] hover:bg-gray-800"
                  }`}
                  whileHover={!(!isVideoReady || showTutorial || showAngleDetection) ? { scale: 1.05 } : {}}
                  whileTap={!(!isVideoReady || showTutorial || showAngleDetection) ? { scale: 0.95 } : {}}
                  initial={{ y: 20, opacity: 0 }}
                  animate={{ y: 0, opacity: 1 }}
                  transition={{ type: "spring", stiffness: 300, damping: 20 }}
                >
                  <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                    <path
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      strokeWidth={2}
                      d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
                    />
                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
                  </svg>
                  <span>촬영하기</span>
                </motion.button>
              </div>
            )}

            {/* 가이드 버튼 */}
            {!detectionCompleted && (
              <div className="absolute top-4 right-4 z-10">
                <motion.button
                  onClick={startTutorial}
                  className="w-10 h-10 rounded-full bg-gray-800/50 flex items-center justify-center hover:bg-gray-800/80 transition-colors text-white"
                  whileHover={{ scale: 1.1, rotate: 5 }}
                  whileTap={{ scale: 0.9 }}
                  initial={{ y: -10, opacity: 0 }}
                  animate={{ y: 0, opacity: 1 }}
                  transition={{ delay: 0.2 }}
                >
                  <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
                  </svg>
                </motion.button>
              </div>
            )}
          </div>
        </div>

        {/* 우측 진행 상태 수직선 영역 */}
        <div className="hidden md:flex md:w-[10%] xl:w-[8%]">
          <StepIndicator steps={steps} currentView={currentView} />
        </div>

        {/* 모바일용 진행 상태 표시 (수평 방향) */}
        {isMobile && <StepIndicator steps={steps} currentView={currentView} isMobile={true} />}
      </div>

      {/* 모바일/태블릿에서 분석 버튼 영역 (기존 코드는 제거) */}
      {allPhotosCaptures && isMobile && !detectionCompleted && (
        <motion.div className="mt-4" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }}>
          <motion.button
            className="bg-blue-600 hover:bg-blue-700 text-white px-10 py-3 rounded-lg text-lg font-medium"
            whileHover={{ scale: 1.05, boxShadow: "0 10px 25px -5px rgba(59, 130, 246, 0.5)" }}
            whileTap={{ scale: 0.95 }}
            onClick={handleAnalyzeClick}
          >
            분석하기
          </motion.button>
        </motion.div>
      )}
    </div>
  );
};

export default AnalyzeForm;

→ 얼굴 각도 계산 및 촬영 가능 하지만 얼굴 각도 계산이 무한으로 호출되고 무한 렌더링 발생 useEffect 의존성 제거

javascript
import { useRef, useState, useEffect, useCallback, RefObject } from "react";

// Face-API.js 타입 선언
declare global {
  interface Window {
    faceapi: any;
  }
}

// 전역 타입스크립트 타입 정의
declare const faceapi: any;

interface Point {
  x: number;
  y: number;
  z: number;
}

interface Landmark {
  x: number;
  y: number;
}

interface FaceApiReturnType {
  startFaceDetection: () => Promise<void>;
  stopFaceDetection: () => void;
  isCapturing: boolean;
  capturedImage: string | null;
  resetCapture: () => void;
  faceAngle: number;
  isModelLoaded: boolean;
  isTargetAngleReached: boolean;
  faceApiLoaded: boolean;
}

interface FaceApiProps {
  videoRef?: RefObject<HTMLVideoElement | null>;
  canvasRef?: RefObject<HTMLCanvasElement | null>;
  targetAngle?: number;
  onCapture?: (image: string) => void;
}

/**
 * Face-API.js 로딩 여부를 확인하는 함수
 */
const isFaceApiLoaded = (): boolean => {
  return typeof window !== "undefined" && typeof window.faceapi !== "undefined" && window.faceapi !== null;
};

/**
 * Face-API.js 로딩 완료 대기 함수
 */
const waitForFaceApi = async (): Promise<void> => {
  // 이미 로드되었는지 확인
  if (isFaceApiLoaded()) {
    return Promise.resolve();
  }

  // 로드될 때까지 대기
  return new Promise<void>((resolve) => {
    const checkInterval = setInterval(() => {
      if (isFaceApiLoaded()) {
        clearInterval(checkInterval);
        resolve();
      }
    }, 100);

    // 10초 후에도 로드되지 않으면 자동 해제
    setTimeout(() => {
      clearInterval(checkInterval);
    }, 10000);
  });
};

const useFaceApi = ({ videoRef: externalVideoRef, canvasRef: externalCanvasRef, targetAngle = -45, onCapture }: FaceApiProps = {}): FaceApiReturnType => {
  const internalVideoRef = useRef<HTMLVideoElement | null>(null);
  const internalCanvasRef = useRef<HTMLCanvasElement | null>(null);

  // 외부에서 전달된 ref 또는 내부 ref 사용
  const videoRef = externalVideoRef || internalVideoRef;
  const canvasRef = externalCanvasRef || internalCanvasRef;

  // 상태 관리
  const [isCapturing, setIsCapturing] = useState<boolean>(false);
  const [isModelLoaded, setIsModelLoaded] = useState<boolean>(false);
  const [capturedImage, setCapturedImage] = useState<string | null>(null);
  const [faceAngle, setFaceAngle] = useState<number>(0);
  const [lastCaptureTime, setLastCaptureTime] = useState<number>(0);
  const [isTargetAngleReached, setIsTargetAngleReached] = useState<boolean>(false);
  const [faceApiLoaded, setFaceApiLoaded] = useState<boolean>(isFaceApiLoaded());
  const [detectionStarted, setDetectionStarted] = useState<boolean>(false);
  const [isMirrored, setIsMirrored] = useState<boolean>(true); // 카메라가 미러링되는지 여부 (기본값: true)

  // 상수 정의
  const captureDelay = 2000; // 연속 촬영 방지를 위한 딜레이 (ms)
  const angleThreshold = 10; // ±10도 오차 허용

  // 리소스 정리 함수
  const cleanupResources = useCallback(() => {
    // Face-API.js는 별도의 리소스 해제가 필요 없음
  }, []);

  // 얼굴 감지 중지
  const stopFaceDetection = useCallback(() => {
    setIsCapturing(false);
    setDetectionStarted(false);
    cleanupResources();
  }, [cleanupResources]);

  // 얼굴 감지 초기화
  const initFaceDetection = useCallback(async (): Promise<boolean> => {
    try {
      console.log("얼굴 감지 초기화 시작");

      if (!isFaceApiLoaded()) {
        console.error("Face-API.js가 로드되지 않았습니다");
        return false;
      }

      // 모델 경로 디버깅
      const modelUrl = "/models";
      console.log("모델 URL:", modelUrl);

      // Face-API.js 모델 로드
      console.log("모델 로드 시도...");

      // loadFromUri 메서드 사용 - 이 방법이 더 안정적입니다
      await faceapi.loadTinyFaceDetectorModel(modelUrl);
      await faceapi.loadFaceLandmarkModel(modelUrl);

      console.log("얼굴 감지 모델 로드 완료");
      setIsModelLoaded(true);
      return true;
    } catch (error) {
      console.error("얼굴 감지 초기화 실패:", error);
      return false;
    }
  }, []);

  // 각도 계산 함수
  const calculateYaw = useCallback(
    (landmarks: any): number => {
      try {
        // 왼쪽과 오른쪽 눈 중심점
        const leftEye = landmarks.getLeftEye();
        const rightEye = landmarks.getRightEye();

        if (!leftEye || !rightEye || leftEye.length === 0 || rightEye.length === 0) {
          console.error("눈 랜드마크를 찾을 수 없음");
          return 0;
        }

        console.log("눈 랜드마크 확인:", "좌(개수):", leftEye.length, "우(개수):", rightEye.length);

        const leftEyeCenter = {
          x: leftEye.reduce((sum: number, pt: any) => sum + pt.x, 0) / leftEye.length,
          y: leftEye.reduce((sum: number, pt: any) => sum + pt.y, 0) / leftEye.length,
        };

        const rightEyeCenter = {
          x: rightEye.reduce((sum: number, pt: any) => sum + pt.x, 0) / rightEye.length,
          y: rightEye.reduce((sum: number, pt: any) => sum + pt.y, 0) / rightEye.length,
        };

        console.log("눈 위치:", "좌:", leftEyeCenter.x.toFixed(1), leftEyeCenter.y.toFixed(1), "우:", rightEyeCenter.x.toFixed(1), rightEyeCenter.y.toFixed(1));

        // 눈 사이 거리
        const eyeDistance = Math.sqrt(Math.pow(rightEyeCenter.x - leftEyeCenter.x, 2) + Math.pow(rightEyeCenter.y - leftEyeCenter.y, 2));

        if (eyeDistance <= 0) {
          console.error("눈 사이 거리가 0 이하임");
          return 0;
        }

        // 코 위치 (코끝)
        const nose = landmarks.getNose();
        if (!nose || nose.length < 4) {
          console.error("코 랜드마크를 찾을 수 없음");
          return 0;
        }

        console.log("코 랜드마크 확인 - 개수:", nose.length, "데이터:", nose.map((n: any, i: number) => `[${i}]: (${n.x.toFixed(1)}, ${n.y.toFixed(1)})`).join(", "));

        // 코끝 - 더 정확한 포인트 선택 (코 배열에서 코끝에 해당하는 요소)
        // 68랜드마크 모델에서는 30번(배열에서는 3번)이 코끝
        const noseTip = nose[3];

        // 눈 중심선과 코의 거리 계산
        const eyeMidX = (leftEyeCenter.x + rightEyeCenter.x) / 2;
        const deviation = (eyeMidX - noseTip.x) / eyeDistance;

        console.log("코끝 위치:", noseTip.x.toFixed(1), noseTip.y.toFixed(1));
        console.log("눈 중심:", eyeMidX.toFixed(1), "편차:", deviation.toFixed(3), "눈 거리:", eyeDistance.toFixed(1));

        // 각도로 변환 (근사치)
        // 중요: 미러링된 화면에서는 방향이 반대로 나타납니다.
        let angle = deviation * -90; // 기본 각도 계산

        // 미러링에 따른 각도 보정
        if (isMirrored) {
          // 미러링된 경우 부호를 반전
          angle = -angle;
          console.log("각도 보정(미러링 적용):", angle.toFixed(1));
        } else {
          console.log("각도 보정(미러링 미적용):", angle.toFixed(1));
        }

        console.log("계산된 얼굴 각도:", angle.toFixed(1));

        return angle;
      } catch (error) {
        console.error("각도 계산 중 오류 발생:", error);
        return 0;
      }
    },
    [isMirrored]
  ); // isMirrored를 의존성 배열에 추가

  // 프레임 캡처 함수
  const captureFrame = useCallback(() => {
    if (!canvasRef.current || !videoRef.current) return;

    setIsCapturing(false);

    const ctx = canvasRef.current.getContext("2d");
    if (ctx) {
      ctx.drawImage(videoRef.current, 0, 0, canvasRef.current.width, canvasRef.current.height);
    }

    // 현재 프레임 캡처
    const imageData = canvasRef.current.toDataURL("image/png");
    setCapturedImage(imageData);

    // 콜백 함수가 제공된 경우 호출
    if (onCapture) {
      onCapture(imageData);
    }

    // 리소스 해제
    cleanupResources();
  }, [onCapture, cleanupResources]);

  // 비디오 프레임 처리 함수
  const processVideo = useCallback(async () => {
    if (!isCapturing || !isFaceApiLoaded() || !detectionStarted) return;

    try {
      if (!videoRef.current || !canvasRef.current) return;

      // 비디오 미러링 상태 감지
      const videoStyle = window.getComputedStyle(videoRef.current);
      const isVideoMirrored = videoStyle.transform.includes("scaleX(-1)");

      if (isVideoMirrored !== isMirrored) {
        console.log("비디오 미러링 상태 변경 감지:", isVideoMirrored);
        setIsMirrored(isVideoMirrored);
      }

      // 비디오 요소 정보 확인
      console.log(
        "비디오 상태:",
        "준비상태:",
        videoRef.current.readyState,
        "크기:",
        videoRef.current.videoWidth,
        "x",
        videoRef.current.videoHeight,
        "스트림:",
        videoRef.current.srcObject ? "있음" : "없음",
        "미러링:",
        isVideoMirrored ? "적용" : "미적용"
      );

      // 캔버스 컨텍스트 가져오기
      const ctx = canvasRef.current.getContext("2d");
      if (!ctx) return;

      // 캔버스 크기 조정
      if (canvasRef.current.width !== videoRef.current.videoWidth || canvasRef.current.height !== videoRef.current.videoHeight) {
        canvasRef.current.width = videoRef.current.videoWidth;
        canvasRef.current.height = videoRef.current.videoHeight;
        console.log("캔버스 크기 조정:", canvasRef.current.width, "x", canvasRef.current.height);
      }

      // 비디오 프레임을 캔버스에 그림
      ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);

      // 디버깅 정보: 가이드라인 그리기
      const canvasWidth = canvasRef.current.width;
      const canvasHeight = canvasRef.current.height;

      // 중앙선 그리기 (디버깅용)
      ctx.beginPath();
      ctx.moveTo(canvasWidth / 2, 0);
      ctx.lineTo(canvasWidth / 2, canvasHeight);
      ctx.strokeStyle = "rgba(255, 0, 0, 0.5)";
      ctx.stroke();

      // 목표 각도 가이드라인 그리기
      ctx.save();
      ctx.translate(canvasWidth / 2, canvasHeight / 2); // 캔버스 중앙으로 이동
      ctx.rotate((targetAngle * Math.PI) / 180); // 라디안으로 변환하여 회전

      // 목표 각도 선 그리기
      ctx.beginPath();
      ctx.moveTo(0, -canvasHeight / 3);
      ctx.lineTo(0, canvasHeight / 3);
      ctx.strokeStyle = "rgba(0, 255, 0, 0.5)";
      ctx.lineWidth = 2;
      ctx.stroke();

      // 각도 텍스트 추가
      ctx.fillStyle = "rgba(0, 255, 0, 0.8)";
      ctx.font = "20px Arial";
      ctx.fillText(`목표 ${targetAngle}°`, 10, 0);

      ctx.restore(); // 캔버스 상태 복원

      let detections;
      try {
        console.time("faceDetection");
        // 얼굴 감지 - 옵션 조정
        detections = await faceapi
          .detectAllFaces(
            videoRef.current,
            new faceapi.TinyFaceDetectorOptions({
              inputSize: 416, // 입력 크기 증가
              scoreThreshold: 0.3, // 임계값 더 낮춤
            })
          )
          .withFaceLandmarks();
        console.timeEnd("faceDetection");

        // 감지 결과 로깅
        console.log("감지된 얼굴 수:", detections.length);
      } catch (detectError) {
        console.error("얼굴 감지 중 오류:", detectError);
        detections = [];
      }

      // 감지된 얼굴이 있는 경우
      if (detections && detections.length > 0) {
        // 첫 번째 얼굴 선택
        const face = detections[0];
        console.log(
          "얼굴 감지됨",
          "위치:",
          "x:",
          face.detection.box.x.toFixed(1),
          "y:",
          face.detection.box.y.toFixed(1),
          "너비:",
          face.detection.box.width.toFixed(1),
          "높이:",
          face.detection.box.height.toFixed(1),
          "신뢰도:",
          face.detection.score.toFixed(3)
        );

        // 얼굴 박스 그리기
        ctx.lineWidth = 2;
        ctx.strokeStyle = "rgba(0, 255, 255, 0.8)";
        ctx.strokeRect(face.detection.box.x, face.detection.box.y, face.detection.box.width, face.detection.box.height);

        // 랜드마크 그리기
        try {
          ctx.lineWidth = 2; // 더 두꺼운 선으로 그리기
          faceapi.draw.drawFaceLandmarks(canvasRef.current, face);
        } catch (drawError) {
          console.error("랜드마크 그리기 오류:", drawError);
        }

        // 헤드 포즈 각도 계산 시도
        try {
          const yawAngle = calculateYaw(face.landmarks);

          // 값이 유효한 경우에만 상태 업데이트
          if (!isNaN(yawAngle) && isFinite(yawAngle)) {
            setFaceAngle(yawAngle);

            // 콘솔에 현재 각도 정보 출력
            console.log("최종 각도:", yawAngle.toFixed(1), "도, 타겟 각도:", targetAngle);

            // 각도 표시 (캔버스에 그리기)
            ctx.save();
            ctx.translate(canvasWidth / 2, canvasHeight / 2); // 캔버스 중앙으로 이동
            ctx.rotate((yawAngle * Math.PI) / 180); // 라디안으로 변환하여 회전

            // 현재 각도 선 그리기
            ctx.beginPath();
            ctx.moveTo(0, -canvasHeight / 4);
            ctx.lineTo(0, canvasHeight / 4);
            ctx.strokeStyle = "rgba(255, 255, 0, 0.7)";
            ctx.lineWidth = 3;
            ctx.stroke();

            // 각도 텍스트 추가
            ctx.fillStyle = "rgba(255, 255, 0, 0.9)";
            ctx.font = "20px Arial";
            ctx.fillText(`${yawAngle.toFixed(1)}°`, 10, 30);

            ctx.restore(); // 캔버스 상태 복원

            // 각도가 목표 범위 내에 있는지 확인
            const isInTargetRange = Math.abs(yawAngle - targetAngle) <= angleThreshold;
            setIsTargetAngleReached(isInTargetRange);
            console.log("목표 각도 도달:", isInTargetRange, "허용 범위:", angleThreshold);

            // 목표 각도에 도달하면 자동 캡처
            if (isInTargetRange) {
              // 화면에 "정확한 각도입니다!" 표시
              ctx.fillStyle = "rgba(0, 255, 0, 0.7)";
              ctx.font = "bold 30px Arial";
              ctx.textAlign = "center";
              ctx.fillText("정확한 각도입니다!", canvasWidth / 2, 50);

              const currentTime = Date.now();
              if (currentTime - lastCaptureTime > captureDelay) {
                console.log("목표 각도 도달 - 캡처 예약 (300ms 후)");
                // 여기서 바로 캡처하지 않고 시간을 두고 실행
                setTimeout(() => {
                  if (isCapturing) {
                    // 캡처 중인지 다시 확인
                    console.log("캡처 실행");
                    captureFrame();
                    setLastCaptureTime(currentTime);
                  }
                }, 300);
              } else {
                console.log("대기 중 - 마지막 캡처 후", currentTime - lastCaptureTime, "ms 경과 (딜레이:", captureDelay, "ms)");
              }
            } else {
              // 각도 편차 표시
              const deviation = Math.abs(yawAngle - targetAngle);
              let message = "";

              if (deviation > 30) {
                message = "많이 돌려주세요";
              } else if (deviation > 20) {
                message = "조금 더 돌려주세요";
              } else if (deviation > 10) {
                message = "거의 다 왔어요!";
              }

              if (message) {
                ctx.fillStyle = "rgba(255, 165, 0, 0.8)";
                ctx.font = "bold 24px Arial";
                ctx.textAlign = "center";
                ctx.fillText(message, canvasWidth / 2, 50);
              }
            }
          } else {
            console.warn("계산된 각도가 유효하지 않음:", yawAngle);
          }
        } catch (angleError) {
          console.error("각도 계산 오류:", angleError);
        }
      } else {
        setIsTargetAngleReached(false);
        console.log("얼굴이 감지되지 않음");

        // 얼굴이 감지되지 않음 메시지 표시
        ctx.fillStyle = "rgba(255, 0, 0, 0.7)";
        ctx.font = "bold 28px Arial";
        ctx.textAlign = "center";
        ctx.fillText("얼굴이 감지되지 않습니다", canvasWidth / 2, canvasHeight / 2);
      }

      // 다음 프레임 처리 (requestAnimationFrame 사용)
      if (isCapturing) {
        requestAnimationFrame(() => processVideo());
      }
    } catch (err) {
      console.error("비디오 처리 오류:", err);
      if (isCapturing) {
        // 오류 발생 시 약간의 지연 후 다시 시도
        setTimeout(() => {
          requestAnimationFrame(() => processVideo());
        }, 500);
      }
    }
  }, [isCapturing, detectionStarted, videoRef, canvasRef, captureFrame, calculateYaw, lastCaptureTime, targetAngle, angleThreshold, isMirrored]);

  // 얼굴 감지 시작 (기존 카메라 스트림 사용)
  const startFaceDetection = useCallback(async () => {
    if (!faceApiLoaded) {
      console.error("Face-API.js가 아직 로드되지 않았습니다");
      return;
    }

    if (!isModelLoaded) {
      // 초기화
      const success = await initFaceDetection();
      if (!success) {
        console.error("얼굴 감지 초기화에 실패했습니다");
        return;
      }
    }

    // 이미 캡처 중이면 중지
    if (isCapturing) {
      stopFaceDetection();
    }

    console.log("얼굴 감지 시작");

    // 비디오 요소가 있고 스트림이 이미 연결되어 있는지 확인
    if (videoRef.current && videoRef.current.srcObject) {
      try {
        // 캔버스 크기 설정
        if (canvasRef.current) {
          canvasRef.current.width = videoRef.current.videoWidth || 640;
          canvasRef.current.height = videoRef.current.videoHeight || 480;
        }

        // 캡처 상태 초기화
        setIsCapturing(true);
        setCapturedImage(null);
        setIsTargetAngleReached(false);

        // 약간의 지연 후 감지 시작
        setTimeout(() => {
          setDetectionStarted(true);
          processVideo();
        }, 500);
      } catch (error) {
        console.error("얼굴 감지 시작 실패:", error);
      }
    } else {
      console.error("비디오 스트림이 없거나 접근할 수 없습니다");
    }
  }, [processVideo, videoRef, faceApiLoaded, isModelLoaded, isCapturing, initFaceDetection, stopFaceDetection]);

  // 캡처 리셋
  const resetCapture = useCallback(() => {
    if (isCapturing) {
      stopFaceDetection();
    }

    setIsCapturing(true);
    setCapturedImage(null);
    setIsTargetAngleReached(false);

    // 프레임 처리 재시작
    setDetectionStarted(true);
    processVideo();
  }, [isCapturing, processVideo, stopFaceDetection]);

  // Face-API.js 초기화 (최초 한 번)
  useEffect(() => {
    async function initFaceAPI() {
      try {
        // Face-API.js 로드 대기
        await waitForFaceApi();
        console.log("Face-API.js 로드 확인됨, 초기화 시작");
        setFaceApiLoaded(true);

        // 얼굴 감지 초기화
        await initFaceDetection();
      } catch (error) {
        console.error("Face-API.js 초기화 실패:", error);
      }
    }

    // 이미 로드되었는지 확인
    if (isFaceApiLoaded()) {
      console.log("Face-API.js가 이미 로드되어 있습니다");
      setFaceApiLoaded(true);
      initFaceDetection();
    } else {
      initFaceAPI();
    }

    // 클린업 함수
    return () => {
      stopFaceDetection();
    };
  }, [initFaceDetection, stopFaceDetection]);

  return {
    startFaceDetection,
    stopFaceDetection,
    isCapturing,
    capturedImage,
    resetCapture,
    faceAngle,
    isModelLoaded,
    isTargetAngleReached,
    faceApiLoaded,
  };
};

export default useFaceApi;

→ 얼굴 각도 계산이 안됨


0426

자동 촬영 기능 구현

typescript
if (detections && detections.length > 0) {
        // 첫 번째 얼굴 선택
        // 캔버스 컨텍스트 가져오기
        const ctx = canvasRef.current.getContext("2d");
        if (!ctx) {
          setIsProcessing(false);
          return;
        }

        // 캔버스 크기 조정
        if (canvasRef.current.width !== videoRef.current.videoWidth || canvasRef.current.height !== videoRef.current.videoHeight) {
          canvasRef.current.width = videoRef.current.videoWidth;
          canvasRef.current.height = videoRef.current.videoHeight;
        }

        // 캔버스 초기화
        ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);

        // 비디오 프레임을 캔버스에 그리기
        if (isMirrored) {
          // 미러링 적용
          ctx.save();
          ctx.scale(-1, 1);
          ctx.drawImage(videoRef.current, -canvasRef.current.width, 0, canvasRef.current.width, canvasRef.current.height);
          ctx.restore();
        } else {
          ctx.drawImage(videoRef.current, 0, 0, canvasRef.current.width, canvasRef.current.height);
        }

        // 가이드라인 그리기
        const canvasWidth = canvasRef.current.width;
        const canvasHeight = canvasRef.current.height;

        // 중앙선
        ctx.beginPath();
        ctx.moveTo(canvasWidth / 2, 0);
        ctx.lineTo(canvasWidth / 2, canvasHeight);
        ctx.strokeStyle = "rgba(255, 0, 0, 0.5)";
        ctx.stroke();

        // 목표 각도 선 - 현재 정확한 목표 각도 사용
        ctx.save();
        ctx.translate(canvasWidth / 2, canvasHeight / 2);
        ctx.rotate((targetAngle * Math.PI) / 180);
        ctx.beginPath();
        ctx.moveTo(0, -canvasHeight / 3);
        ctx.lineTo(0, canvasHeight / 3);
        ctx.strokeStyle = "rgba(0, 255, 0, 0.5)";
        ctx.lineWidth = 2;
        ctx.stroke();
        ctx.font = "20px Arial";
        ctx.fillStyle = "rgba(0, 255, 0, 0.8)";
        // 목표 각도와 현재 단계 표시
        ctx.fillText(`목표 ${targetAngle}° (${phase})`, 10, 0);
        ctx.restore();

        // 현재 촬영 단계 표시
        let phaseText = "";
        switch (phase) {
          case "front":
            phaseText = "정면을 바라봐주세요";
            break;
          case "left":
            phaseText = "좌측으로 돌려주세요";
            break;
          case "right":
            phaseText = "우측으로 돌려주세요";
            break;
          default:
            phaseText = "촬영 완료";
        }

        ctx.font = "bold 20px Arial";
        ctx.fillStyle = "rgba(255, 255, 255, 0.9)";
        ctx.textAlign = "center";
        ctx.fillText(phaseText, canvasWidth / 2, 30);

        // Face-API 얼굴 감지
        if (!faceapi || typeof faceapi.detectAllFaces !== "function") {
          setIsProcessing(false);
          return;
        }

        // 얼굴 감지 수행
        const detectorOptions = new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: 0.3 });

        // 원본 비디오로부터 감지 (미러링 여부와 상관없이)
        const detections = await faceapi.detectAllFaces(videoRef.current, detectorOptions).withFaceLandmarks();

        // 얼굴이 감지되지 않은 경우
        if (!detections || detections.length === 0) {
          ctx.fillStyle = "rgba(255, 0, 0, 0.7)";
          ctx.font = "bold 24px Arial";
          ctx.textAlign = "center";
          ctx.fillText("얼굴이 감지되지 않습니다", canvasWidth / 2, canvasHeight / 2);
          setFaceAngle(0);
          setIsTargetAngleReached(false);

          if (isProcessing) {
            requestAnimationFrameId.current = requestAnimationFrame(processFrame);
          }
          return;
        }
typescript
setIsTargetAngleReached(isInRange);

        // 목표 각도 도달 여부 표시 및 유용한 피드백 제공
        if (isInRange) {
          ctx.fillStyle = "rgba(0, 255, 0, 0.7)";
          ctx.font = "bold 24px Arial";
          ctx.textAlign = "center";
          ctx.fillText("각도가 적절합니다", canvasWidth / 2, 70);
        } else {
          ctx.fillStyle = "rgba(255, 0, 0, 0.7)";
          ctx.font = "bold 24px Arial";
          ctx.textAlign = "center";

          // 더 유용한 피드백 메시지 제공
          let feedbackMsg = "";
          switch (phase) {
            case "front":
              feedbackMsg = "정면을 바라봐주세요";
              break;
            case "left":
              if (yawAngle >= 0) {
                feedbackMsg = "왼쪽으로 더 돌려주세요";
              } else if (Math.abs(yawAngle) < 20) {
                feedbackMsg = "왼쪽으로 더 돌려주세요";
              } else if (Math.abs(yawAngle) > Math.abs(targetAngle) + angleThreshold) {
                feedbackMsg = "너무 많이 돌렸습니다";
typescript
case "right":
              if (yawAngle <= 0) {
                feedbackMsg = "오른쪽으로 더 돌려주세요";
              } else if (Math.abs(yawAngle) < 20) {
                feedbackMsg = "오른쪽으로 더 돌려주세요";
              } else if (Math.abs(yawAngle) > Math.abs(targetAngle) + angleThreshold) {
                feedbackMsg = "너무 많이 돌렸습니다";
              } else {
                feedbackMsg = `목표 각도(${targetAngle}°)에 가깝게 조정하세요`;
typescript
const currentTime = Date.now();
        const captureDelay = 1500; // 1.5초 딜레이

        if (isInRange && currentTime - lastCaptureTime > captureDelay) {
          setLastCaptureTime(currentTime);
          captureFrame();
          setIsProcessing(false);
          return; // 캡처 후 다음 프레임은 처리하지 않음
        }
      } catch (error) {
        console.error("비디오 프레임 처리 오류:", error);
      }
typescript
// processVideo 함수를 참조에 할당
  useEffect(() => {
    processVideoRef.current = processVideo;
  }, [processVideo]);

  // 얼굴 감지 시작
  const startFaceDetection = useCallback(async () => {
    // 기존 실행 중인 프로세스 중지
    stopFaceDetection();

    // 상태 초기화 (초기화할 때 현재 단계 유지, 리셋할 때만 front로 설정)
    console.log("얼굴 감지 시작: 상태 리셋");

    // 외부에서 전달된 targetAngle이 있으면 해당 각도에 맞는 단계로 설정
    if (targetAngle !== undefined) {
      if (targetAngle === 0 || Math.abs(targetAngle) < 10) {
        setCapturePhase("front");
      } else if (targetAngle > 0) {
        setCapturePhase("left");
      } else if (targetAngle < 0) {
        setCapturePhase("right");
      }
      // 직접 targetAngle 값을 사용
      setCurrentTargetAngle(targetAngle);
      console.log(`외부 목표 각도 적용: ${targetAngle}°`);
    }
    // 아니면 현재 단계 기반으로 각도 결정
    else if (capturePhase === "complete") {
      setCapturePhase("front");
      setCurrentTargetAngle(angleByPhase.front);
    } else {
      // 현재 단계 유지하고 해당 각도만 업데이트
      setCurrentTargetAngle(angleByPhase[capturePhase]);

좌우 반전 계속 되고 얼굴 각도 감지를 못하는 오류 계속 발생 따라서 단순 버튼 클릭으로 변경

typescript
import { useEffect, useState, useRef, useCallback, RefObject } from "react";

// Face API 훅을 위한 타입 정의
interface UseFaceApiParams {
  videoRef: RefObject<HTMLVideoElement | null>;
  canvasRef: RefObject<HTMLCanvasElement | null>;
  onCapture?: (imageData: string) => void;
  initialPhase?: "front" | "left" | "right";
}

interface FaceApiReturnType {
  faceAngle: number;
  isTargetAngleReached: boolean;
  isForheadVisible: boolean;
  isLoading: boolean;
  isDetectionActive: boolean; // 감지 상태 노출
  startDetection: () => void;
  stopDetection: () => void;
}

// Face-API 관련 타입 정의
interface FaceDetection {
  detection: {
    box: {
      width: number;
      height: number;
    };
  };
  landmarks: {
    positions: Array<{ x: number; y: number }>;
    getJawOutline: () => Array<{ x: number; y: number }>;
    getNose: () => Array<{ x: number; y: number }>;
    getLeftEye: () => Array<{ x: number; y: number }>;
    getRightEye: () => Array<{ x: number; y: number }>;
  };
}

/**
 * Face API를 사용하여 얼굴 각도 감지 및 분석을 수행하는 훅
 */
const useFaceApi = ({ videoRef, canvasRef, onCapture, initialPhase = "front" }: UseFaceApiParams): FaceApiReturnType => {
  // 상태 관리
  const [faceAngle, setFaceAngle] = useState<number>(0);
  const [isTargetAngleReached, setIsTargetAngleReached] = useState<boolean>(false);
  const [isForheadVisible, setIsForheadVisible] = useState<boolean>(false);
  const [isLoading, setIsLoading] = useState<boolean>(true);
  const [isDetectionActive, setIsDetectionActive] = useState<boolean>(false);
  const [currentPhase, setCurrentPhase] = useState<"front" | "left" | "right">(initialPhase);
  const [detectionsCount, setDetectionsCount] = useState<number>(0); // 감지 횟수 추적

  // 감지 프로세스 관리를 위한 refs
  const detectionIntervalRef = useRef<number | null>(null);
  const faceDetectionPromiseRef = useRef<Promise<void> | null>(null);
  const detectionCountRef = useRef<number>(0);
  const stabilityCounterRef = useRef<number>(0);
  const lastCaptureTimeRef = useRef<number>(0);
  const isDetectionActiveRef = useRef<boolean>(false); // 감지 활성화 상태를 추적하는 ref 추가

  // 모델 로딩 및 초기화
  useEffect(() => {
    const loadModels = async () => {
      try {
        setIsLoading(true);
        console.log("Face-API 모델 로딩 시작");

        // faceapi가 window 객체에 등록되었는지 확인
        if (typeof window.faceapi === "undefined") {
          console.error("Face-API가 로드되지 않았습니다.");
          throw new Error("Face-API가 로드되지 않았습니다.");
        }

        // 필요한 모델 로드 - 상대 경로로 변경
        await Promise.all([window.faceapi.nets.tinyFaceDetector.loadFromUri("/models"), window.faceapi.nets.faceLandmark68Net.loadFromUri("/models")]);

        // 모델 로딩 상태 확인
        console.log("Face-API 모델 로딩 완료 상태:", {
          tinyFaceDetector: window.faceapi.nets.tinyFaceDetector.isLoaded,
          faceLandmark68Net: window.faceapi.nets.faceLandmark68Net.isLoaded,
        });

        setIsLoading(false);
      } catch (error) {
        console.error("Face-API 모델 로딩 실패:", error);
        setIsLoading(false);
      }
    };

    loadModels();

    // 컴포넌트 언마운트 시 정리
    return () => {
      stopDetection();
    };
  }, []);

  // 현재 단계가 변경될 때 업데이트
  useEffect(() => {
    setCurrentPhase(initialPhase);
    console.log("현재 얼굴 감지 단계 변경:", initialPhase);
  }, [initialPhase]);

  // 얼굴 감지 및 각도 계산
  const detectFace = useCallback(async () => {
    if (!videoRef.current || !canvasRef.current || !isDetectionActiveRef.current) {
      // 디버깅: 감지가 실행되지 않는 이유 확인
      if (!isDetectionActiveRef.current) {
        console.log("감지가 활성화되지 않아 실행되지 않음 (ref 기준)");
      }
      if (!videoRef.current) {
        console.log("비디오 요소가 없어 감지 실행되지 않음");
      }
      if (!canvasRef.current) {
        console.log("캔버스 요소가 없어 감지 실행되지 않음");
      }
      return;
    }

    const video = videoRef.current;
    const canvas = canvasRef.current;

    // 비디오가 재생 중이고 크기가 유효한지 확인
    if (video.paused || video.ended || !video.videoWidth) {
      console.log("비디오 상태 확인 - 재생 중 아님:", {
        paused: video.paused,
        ended: video.ended,
        videoWidth: video.videoWidth,
      });
      return;
    }

    try {
      // 캔버스 크기 설정
      canvas.width = video.videoWidth;
      canvas.height = video.videoHeight;

      const ctx = canvas.getContext("2d");
      if (!ctx) return;

      // 캔버스 초기화
      ctx.clearRect(0, 0, canvas.width, canvas.height);

      // 얼굴 감지 수행
      const options = new window.faceapi.TinyFaceDetectorOptions({ inputSize: 224, scoreThreshold: 0.5 });

      // 감지 시작 시간 로깅 (성능 디버깅용)
      const detectionStartTime = performance.now();

      const detections = await window.faceapi.detectAllFaces(video, options).withFaceLandmarks();

      // 감지 완료 시간 및 소요 시간 로깅
      const detectionEndTime = performance.now();
      console.log(`얼굴 감지 소요 시간: ${(detectionEndTime - detectionStartTime).toFixed(2)}ms`);

      detectionCountRef.current++;
      setDetectionsCount((prevCount) => prevCount + 1);

      // 얼굴이 감지되지 않았을 경우
      if (!detections || detections.length === 0) {
        console.log("얼굴이 감지되지 않음", detectionCountRef.current);
        setFaceAngle(0);
        setIsTargetAngleReached(false);
        setIsForheadVisible(false);
        stabilityCounterRef.current = 0;
        return;
      }

      console.log("얼굴 감지됨:", detections.length);

      // 가장 큰 얼굴 선택 (여러 얼굴이 감지된 경우)
      const face = detections.sort((a: FaceDetection, b: FaceDetection) => b.detection.box.width * b.detection.box.height - a.detection.box.width * a.detection.box.height)[0];

      // 랜드마크 좌표 추출
      const landmarks = face.landmarks;
      const nose = landmarks.getNose();
      const jawline = landmarks.getJawOutline();
      const leftEye = landmarks.getLeftEye();
      const rightEye = landmarks.getRightEye();

      // 얼굴 중앙점 (코 끝)
      const noseTip = nose[3];

      // 얼굴 회전 각도 계산
      const leftEyeCenter = {
        x: leftEye.reduce((sum: number, point: { x: number; y: number }) => sum + point.x, 0) / leftEye.length,
        y: leftEye.reduce((sum: number, point: { x: number; y: number }) => sum + point.y, 0) / leftEye.length,
      };

      const rightEyeCenter = {
        x: rightEye.reduce((sum: number, point: { x: number; y: number }) => sum + point.x, 0) / rightEye.length,
        y: rightEye.reduce((sum: number, point: { x: number; y: number }) => sum + point.y, 0) / rightEye.length,
      };

      // 좌우 턱 끝 점 (얼굴 좌우 경계)
      const leftJaw = jawline[0];
      const rightJaw = jawline[jawline.length - 1];
typescript
// 코와 턱 중앙 좌표 계산
      const jawCenter = {
        x: (leftJaw.x + rightJaw.x) / 2,
        y: (leftJaw.y + rightJaw.y) / 2,
      };

      // 얼굴 너비 계산
      const faceWidth = rightJaw.x - leftJaw.x;

      // 코와 턱 중앙의 x좌표 차이 계산 (중요: 이 값이 얼굴 회전 각도의 기본)
      const faceRotation = noseTip.x - jawCenter.x;

      // 얼굴 너비를 기준으로 정규화된 회전 각도 계산 (-45도~45도 범위)
      // 카메라 영상은 거울처럼 좌우가 반대로 보이므로 -1을 곱해 보정
      const normalizedRotation = (faceRotation / (faceWidth / 2)) * 45;

      // 계산 과정 디버깅 로그
      console.log("얼굴 각도 계산:", {
        noseTipX: noseTip.x,
        jawCenterX: jawCenter.x,
        faceRotation: faceRotation,
        faceWidth: faceWidth,
        normalizedRotation: normalizedRotation,
      });

      // 이마 노출 여부 확인
      const forehead = landmarks.positions[20]; // 이마 부분에 해당하는 랜드마크
      const isForheadVisible = forehead && forehead.y > 0 && forehead.y < video.videoHeight;

      // 얼굴 각도 및 상태 업데이트
      setFaceAngle(normalizedRotation);
      setIsForheadVisible(isForheadVisible);

      // 현재 단계에 따른 적절한 각도 도달 여부 체크
      let targetReached = false;

      switch (currentPhase) {
        case "front":
          // 정면 조건: 각도가 ±15도 이내
          targetReached = Math.abs(normalizedRotation) <= 15;
          break;

        case "left":
          // 좌측 조건: 각도가 -5도 미만(더 음수)
          targetReached = normalizedRotation < -5;
          break;

        case "right":
          // 우측 조건: 각도가 +5도 초과
          targetReached = normalizedRotation > 5;
          break;
      }

      // 안정된 상태일 때만 캡처 이벤트 발생
      if (targetReached) {
        stabilityCounterRef.current++;

        // 0.5초 이상 안정된 상태를 유지했을 때 (30fps 기준으로 약 15프레임)
        if (stabilityCounterRef.current >= 15) {
          setIsTargetAngleReached(true);

          // 캡처 콜백이 있고, 마지막 캡처 이후 2초 이상 지났을 때만 캡처
          const now = Date.now();
          if (onCapture && now - lastCaptureTimeRef.current > 2000) {
            lastCaptureTimeRef.current = now;

            // 얼굴 각도가 조건에 맞고, 충분히 안정화되었을 때 캡처 이벤트 발생
            onCapture("capture");

            // 안정화 카운터 초기화
            stabilityCounterRef.current = 0;
          }
        }
      } else {
        stabilityCounterRef.current = 0;
        setIsTargetAngleReached(false);
      }

      // 감지 결과 시각화
      // window.faceapi.draw.drawDetections(canvas, [face.detection]);
      // 랜드마크 표시 제거 (주석 처리)
      // window.faceapi.draw.drawFaceLandmarks(canvas, [landmarks]);

      // 디버깅 텍스트 표시 제거 (주석 처리)
      /*
      // 각도 표시
      ctx.font = "16px Arial";
      ctx.fillStyle = "red";
      ctx.fillText(`각도: ${normalizedRotation.toFixed(1)}°`, 10, 30);

      // 현재 단계 표시
      ctx.fillText(`단계: ${currentPhase}`, 10, 60);

      // 타겟 도달 여부 표시
      ctx.fillText(`타겟 도달: ${targetReached ? "✓" : "✗"}`, 10, 90);

      // 감지 카운트 표시
      ctx.fillText(`감지 횟수: ${detectionCountRef.current}`, 10, 120);
      */
    } catch (error) {
      console.error("얼굴 감지 중 오류 발생:", error);
    }
  }, [videoRef, canvasRef, isDetectionActiveRef, currentPhase, onCapture]);

  // 얼굴 감지 시작
  const startDetection = useCallback(() => {
    if (isDetectionActiveRef.current) {
      console.log("이미 감지가 활성화되어 있습니다 (ref 기준).");
      return;
    }

    if (!videoRef.current) {
      console.error("비디오 참조가 없어 감지를 시작할 수 없습니다.");
      return;
    }

    const video = videoRef.current;
    if (video.paused || video.ended || !video.videoWidth) {
      console.error("비디오가 재생 중이지 않아 감지를 시작할 수 없습니다:", {
        paused: video.paused,
        ended: video.ended,
        videoWidth: video.videoWidth,
      });

      // 비디오가 준비되지 않았다면 재생 시도
      try {
        video.play().catch((err) => {
          console.error("비디오 재생 실패:", err);
        });
      } catch (error) {
        console.error("비디오 재생 시도 중 오류:", error);
      }

      // 1초 후에 다시 시도
      setTimeout(() => {
        console.log("비디오 준비 상태 확인 후 감지 재시도");
        startDetection();
      }, 1000);
      return;
    }

    console.log("얼굴 감지 시작");
    setIsDetectionActive(true);
    isDetectionActiveRef.current = true; // Ref 값 업데이트

    console.log("감지 활성화 상태 설정됨 (ref):", isDetectionActiveRef.current);

    stabilityCounterRef.current = 0;
    detectionCountRef.current = 0;
    setDetectionsCount(0);

    // 감지 인터벌 설정 (30ms마다 = 약 30fps)
    detectionIntervalRef.current = window.setInterval(() => {
      // 이전 감지가 완료될 때까지 대기
      if (!faceDetectionPromiseRef.current) {
        faceDetectionPromiseRef.current = detectFace().finally(() => {
          faceDetectionPromiseRef.current = null;
        });
      }
    }, 30);
  }, [detectFace, videoRef]);

  // 얼굴 감지 중지
  const stopDetection = useCallback(() => {
    if (!isDetectionActiveRef.current || !detectionIntervalRef.current) {
      console.log("감지가 이미 중지되어 있습니다 (ref 기준).");
      return;
    }

    console.log("얼굴 감지 중지");
    window.clearInterval(detectionIntervalRef.current);
    detectionIntervalRef.current = null;

    setIsDetectionActive(false);
    isDetectionActiveRef.current = false; // Ref 값 업데이트
    setIsTargetAngleReached(false);
    stabilityCounterRef.current = 0;

    // 캔버스 초기화
    if (canvasRef.current) {
      const ctx = canvasRef.current.getContext("2d");
      if (ctx) {
        ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
      }
    }
  }, [canvasRef]);

  // 비디오 스트림 준비 상태 모니터링
  useEffect(() => {
    if (!videoRef.current) return;

    const checkVideoReady = () => {
      const video = videoRef.current;
      if (!video) return false;

      return !video.paused && !video.ended && video.videoWidth > 0;
    };

    // 비디오 요소 준비 상태 확인
    const videoReadyCheck = setInterval(() => {
      if (checkVideoReady() && !isDetectionActiveRef.current) {
        console.log("비디오 준비됨, 감지 시작 가능");
        clearInterval(videoReadyCheck);
      }
    }, 500);

    // 이벤트 리스너 설정
    const handleVideoPlay = () => {
      console.log("비디오 재생 시작됨");
      if (!isDetectionActiveRef.current && !isLoading) {
        console.log("비디오 준비됨, 감지 자동 시작 시도");
        startDetection();
      }
    };

    videoRef.current.addEventListener("play", handleVideoPlay);

    return () => {
      clearInterval(videoReadyCheck);
      if (videoRef.current) {
        videoRef.current.removeEventListener("play", handleVideoPlay);
      }
    };
  }, [videoRef, startDetection, isLoading]);

  // 반환값
  return {
    faceAngle,
    isTargetAngleReached,
    isForheadVisible,
    isLoading,
    isDetectionActive,
    startDetection,
    stopDetection,
  };
};

// face-api.js 타입 정의 추가
declare global {
  interface Window {
    faceapi: any;
  }
}

export default useFaceApi;

정면, 좌측, 우측 촬영 기능 구현 완료 UI 수정 필요

태그

#Face-api.js