Spaces:
Sleeping
Sleeping
| import React, { useRef, useEffect, useState, useCallback } from 'react'; | |
| import { X, RefreshCw, Image as ImageIcon, AlertCircle, Layers, ScanFace, BoxSelect } from 'lucide-react'; | |
| import { Product, ProductCategory } from '../types'; | |
| import { FilesetResolver, PoseLandmarker } from '@mediapipe/tasks-vision'; | |
| interface CameraViewProps { | |
| product: Product; | |
| onCapture: (imageSrc: string) => void; | |
| onClose: () => void; | |
| } | |
| const CameraView: React.FC<CameraViewProps> = ({ product, onCapture, onClose }) => { | |
| const videoRef = useRef<HTMLVideoElement>(null); | |
| const canvasRef = useRef<HTMLCanvasElement>(null); | |
| const lastVideoTimeRef = useRef<number>(-1); | |
| // State | |
| const [stream, setStream] = useState<MediaStream | null>(null); | |
| const [error, setError] = useState<string | null>(null); | |
| const [countdown, setCountdown] = useState<number | null>(null); | |
| const [isCameraReady, setIsCameraReady] = useState(false); | |
| const [isTracking, setIsTracking] = useState(false); | |
| // AR Transform State (Calculated in real-time) | |
| const [arStyle, setArStyle] = useState<React.CSSProperties>({ | |
| opacity: 0, // Hide until tracked | |
| transform: 'translate(-50%, -50%) scale(1) rotate(0deg)', | |
| top: '50%', | |
| left: '50%', | |
| width: '50%', | |
| }); | |
| // Manual override state (if tracking fails or user wants to adjust) | |
| const [manualMode, setManualMode] = useState(false); | |
| const [manualScale, setManualScale] = useState(1); | |
| const [manualY, setManualY] = useState(0); | |
| const landmarkerRef = useRef<any>(null); | |
| const requestRef = useRef<number | null>(null); | |
| // 1. Initialize Camera | |
| useEffect(() => { | |
| let mediaStream: MediaStream | null = null; | |
| const startCamera = async () => { | |
| try { | |
| if (!navigator.mediaDevices?.getUserMedia) throw new Error("No camera access"); | |
| mediaStream = await navigator.mediaDevices.getUserMedia({ | |
| video: { | |
| width: { ideal: 1280 }, | |
| height: { ideal: 720 }, | |
| facingMode: 'user' | |
| }, | |
| audio: false, | |
| }); | |
| setStream(mediaStream); | |
| } catch (err) { | |
| console.error("Camera Error:", err); | |
| setError("Could not access camera. Please allow permissions."); | |
| } | |
| }; | |
| startCamera(); | |
| return () => { | |
| if (mediaStream) mediaStream.getTracks().forEach(t => t.stop()); | |
| if (requestRef.current) cancelAnimationFrame(requestRef.current); | |
| if (landmarkerRef.current) landmarkerRef.current.close(); | |
| }; | |
| }, []); | |
| // 2. Initialize MediaPipe Pose Landmarker | |
| useEffect(() => { | |
| const loadLandmarker = async () => { | |
| try { | |
| const vision = await FilesetResolver.forVisionTasks( | |
| "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.17/wasm" | |
| ); | |
| landmarkerRef.current = await PoseLandmarker.createFromOptions(vision, { | |
| baseOptions: { | |
| modelAssetPath: `https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/1/pose_landmarker_lite.task`, | |
| delegate: "GPU" | |
| }, | |
| runningMode: "VIDEO", | |
| numPoses: 1 | |
| }); | |
| console.log("AR Engine Loaded"); | |
| } catch (err) { | |
| console.error("Failed to load AR engine:", err); | |
| // Fallback to manual mode if AR fails | |
| setManualMode(true); | |
| } | |
| }; | |
| loadLandmarker(); | |
| }, []); | |
| // 3. Real-time Tracking Loop | |
| const predictWebcam = useCallback(() => { | |
| if (!landmarkerRef.current || !videoRef.current || !isCameraReady) { | |
| requestRef.current = requestAnimationFrame(predictWebcam); | |
| return; | |
| } | |
| const video = videoRef.current; | |
| // Performance optimization: Only process if frame changed | |
| if (video.currentTime !== lastVideoTimeRef.current) { | |
| lastVideoTimeRef.current = video.currentTime; | |
| try { | |
| const result = landmarkerRef.current.detectForVideo(video, performance.now()); | |
| if (result.landmarks && result.landmarks.length > 0) { | |
| setIsTracking(true); | |
| const landmarks = result.landmarks[0]; // First person | |
| updateOverlay(landmarks); | |
| } else { | |
| setIsTracking(false); | |
| } | |
| } catch (e) { | |
| console.warn("Tracking glitch", e); | |
| } | |
| } | |
| if (!manualMode) { | |
| requestRef.current = requestAnimationFrame(predictWebcam); | |
| } | |
| }, [isCameraReady, manualMode, product.category]); | |
| useEffect(() => { | |
| if (isCameraReady && !manualMode) { | |
| requestRef.current = requestAnimationFrame(predictWebcam); | |
| } | |
| return () => { | |
| if (requestRef.current) cancelAnimationFrame(requestRef.current); | |
| }; | |
| }, [isCameraReady, manualMode, predictWebcam]); | |
| // 4. Calculate Coordinates & Apply Physics | |
| const updateOverlay = (landmarks: any[]) => { | |
| // MediaPipe Landmarks: | |
| // 11: left_shoulder, 12: right_shoulder, 0: nose, 15: left_wrist, 16: right_wrist | |
| let top = 50; | |
| let left = 50; | |
| let width = 50; | |
| let rotation = 0; | |
| const lShoulder = landmarks[11]; | |
| const rShoulder = landmarks[12]; | |
| const nose = landmarks[0]; | |
| const lEar = landmarks[7]; | |
| const rEar = landmarks[8]; | |
| // Calculate Shoulder Width (Screen Space) | |
| const shoulderDx = (rShoulder.x - lShoulder.x); | |
| const shoulderDy = (rShoulder.y - lShoulder.y); | |
| const shoulderDist = Math.sqrt(shoulderDx*shoulderDx + shoulderDy*shoulderDy); | |
| // Calculate Body Rotation (Tilt) | |
| const angleRad = Math.atan2(shoulderDy, shoulderDx); | |
| const angleDeg = angleRad * (180 / Math.PI); | |
| // LOGIC PER CATEGORY | |
| if (product.category === ProductCategory.SHIRT || product.category === ProductCategory.PANTS) { | |
| // Anchor to Chest (Midpoint of shoulders) | |
| left = (lShoulder.x + rShoulder.x) / 2 * 100; | |
| top = ((lShoulder.y + rShoulder.y) / 2) * 100; | |
| // Shirt width is roughly 2.5x shoulder width | |
| width = shoulderDist * 280; // Multiplier heuristic | |
| rotation = angleDeg; | |
| // Offset down slightly for shirts so it covers torso | |
| top += 15; | |
| } | |
| else if (product.category === ProductCategory.EYEWEAR) { | |
| // Anchor to Eyes | |
| const lEye = landmarks[2]; | |
| const rEye = landmarks[5]; | |
| const eyeDist = Math.sqrt(Math.pow(rEye.x - lEye.x, 2) + Math.pow(rEye.y - lEye.y, 2)); | |
| left = (nose.x * 100); | |
| top = (nose.y * 100) - 2; // Slightly above nose tip | |
| width = eyeDist * 350; // Glasses are wider than eye-distance | |
| rotation = Math.atan2(rEye.y - lEye.y, rEye.x - lEye.x) * (180/Math.PI); | |
| } | |
| else if (product.category === ProductCategory.HEADWEAR) { | |
| // Anchor to Forehead | |
| left = (nose.x * 100); | |
| const headTopY = Math.min(lEar.y, rEar.y) - (Math.abs(lEar.x - rEar.x) * 0.8); | |
| top = (headTopY * 100); | |
| width = Math.abs(lEar.x - rEar.x) * 250; | |
| rotation = angleDeg; // Follow head tilt (approx shoulder tilt or calc ears) | |
| } | |
| setArStyle({ | |
| position: 'absolute', | |
| left: `${left}%`, | |
| top: `${top}%`, | |
| width: `${width}%`, | |
| transform: `translate(-50%, -50%) rotate(${rotation}deg)`, | |
| opacity: 1, | |
| transition: 'all 0.1s linear', // Smooth interpolation | |
| mixBlendMode: 'multiply', | |
| filter: 'brightness(1.1) contrast(1.1)', | |
| pointerEvents: 'none' | |
| }); | |
| }; | |
| // Video Binding | |
| useEffect(() => { | |
| if (videoRef.current && stream) { | |
| videoRef.current.srcObject = stream; | |
| } | |
| }, [stream]); | |
| const handleCapture = () => { | |
| if (countdown !== null) return; | |
| setCountdown(3); | |
| let count = 3; | |
| const timer = setInterval(() => { | |
| count--; | |
| if (count > 0) setCountdown(count); | |
| else { | |
| clearInterval(timer); | |
| setCountdown(null); | |
| takePhoto(); | |
| } | |
| }, 1000); | |
| }; | |
| const takePhoto = () => { | |
| if (videoRef.current && canvasRef.current) { | |
| const video = videoRef.current; | |
| const canvas = canvasRef.current; | |
| const context = canvas.getContext('2d'); | |
| if (context) { | |
| canvas.width = video.videoWidth; | |
| canvas.height = video.videoHeight; | |
| context.translate(canvas.width, 0); | |
| context.scale(-1, 1); | |
| context.drawImage(video, 0, 0, canvas.width, canvas.height); | |
| onCapture(canvas.toDataURL('image/jpeg', 0.9)); | |
| } | |
| } | |
| }; | |
| const getFinalStyle = () => { | |
| if (manualMode) { | |
| return { | |
| position: 'absolute' as const, | |
| top: '50%', | |
| left: '50%', | |
| transform: `translate(-50%, -50%) scale(${manualScale}) translateY(${manualY}px)`, | |
| width: product.category === ProductCategory.EYEWEAR ? '40%' : '60%', | |
| mixBlendMode: 'multiply' as const, | |
| opacity: 1 | |
| }; | |
| } | |
| return arStyle; | |
| }; | |
| return ( | |
| <div className="fixed inset-0 z-[60] bg-black flex flex-col"> | |
| {/* Header */} | |
| <div className="absolute top-0 left-0 right-0 p-4 flex justify-between items-center z-10 bg-gradient-to-b from-black/80 to-transparent"> | |
| <button onClick={onClose} className="p-2 bg-white/10 backdrop-blur-md rounded-full text-white hover:bg-white/20 border border-white/10"> | |
| <X className="w-6 h-6" /> | |
| </button> | |
| <div className="flex flex-col items-center"> | |
| <span className="text-white/80 text-[10px] uppercase tracking-widest mb-0.5"> | |
| {manualMode ? "Manual Adjust" : isTracking ? "AR Active" : "Scanning..."} | |
| </span> | |
| <span className="text-white font-bold text-sm shadow-sm">{product.name}</span> | |
| </div> | |
| <button | |
| onClick={() => setManualMode(!manualMode)} | |
| className={`p-2 rounded-full border ${manualMode ? 'bg-white text-black' : 'bg-white/10 text-white'} transition-colors`} | |
| > | |
| <BoxSelect className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| {/* Main Viewport */} | |
| <div className="flex-1 relative bg-gray-900 overflow-hidden flex items-center justify-center"> | |
| {!isCameraReady && !error && ( | |
| <div className="absolute inset-0 flex flex-col items-center justify-center z-20 bg-gray-900 text-white gap-3"> | |
| <div className="w-10 h-10 border-4 border-brand-500 border-t-transparent rounded-full animate-spin"></div> | |
| <p className="text-sm font-medium animate-pulse">Initializing Vision Engine...</p> | |
| </div> | |
| )} | |
| {error ? ( | |
| <div className="text-white text-center p-6 max-w-sm z-20 flex flex-col items-center"> | |
| <AlertCircle className="w-12 h-12 text-red-500 mb-4" /> | |
| <p className="mb-6 font-medium text-lg">{error}</p> | |
| <button onClick={() => window.location.reload()} className="bg-white text-black px-6 py-3 rounded-xl font-bold">Reload</button> | |
| </div> | |
| ) : ( | |
| <div className="relative w-full h-full flex items-center justify-center"> | |
| {/* The wrapper handles the flipping for BOTH video and overlay */} | |
| <div className="relative w-full h-full transform -scale-x-100 origin-center"> | |
| <video | |
| ref={videoRef} | |
| autoPlay | |
| playsInline | |
| muted | |
| onLoadedData={() => setIsCameraReady(true)} | |
| className="absolute w-full h-full object-cover" | |
| /> | |
| {/* AR OVERLAY */} | |
| {isCameraReady && ( | |
| <div style={getFinalStyle()} className="pointer-events-none z-10"> | |
| <img | |
| src={product.imageUrl} | |
| crossOrigin="anonymous" | |
| alt="AR Overlay" | |
| className="w-full h-full object-contain" | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| {/* Tracking Indicator - Displayed Normally (Not flipped) */} | |
| {!manualMode && !isTracking && isCameraReady && ( | |
| <div className="absolute top-20 bg-black/50 text-white text-xs px-3 py-1 rounded-full backdrop-blur flex items-center gap-2 animate-pulse z-20"> | |
| <ScanFace className="w-3 h-3" /> | |
| Stand further back to track body | |
| </div> | |
| )} | |
| {countdown !== null && ( | |
| <div className="absolute inset-0 flex items-center justify-center bg-black/40 backdrop-blur-sm z-30"> | |
| <span className="text-[10rem] font-bold text-white animate-bounce">{countdown}</span> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| <canvas ref={canvasRef} className="hidden" /> | |
| </div> | |
| {/* Controls */} | |
| <div className="bg-black/90 backdrop-blur-md pb-8 pt-4 px-6 border-t border-white/10 z-50"> | |
| {/* Manual Controls (Only visible in Manual Mode) */} | |
| {manualMode && ( | |
| <div className="flex justify-center gap-4 mb-6 animate-in slide-in-from-bottom duration-300"> | |
| <div className="flex items-center gap-2 bg-gray-800 rounded-lg p-2 border border-gray-700"> | |
| <span className="text-[10px] text-gray-400 uppercase w-8 text-center">Size</span> | |
| <input type="range" min="0.5" max="2" step="0.1" value={manualScale} onChange={(e) => setManualScale(parseFloat(e.target.value))} className="w-20 h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer accent-brand-500" /> | |
| </div> | |
| <div className="flex items-center gap-2 bg-gray-800 rounded-lg p-2 border border-gray-700"> | |
| <span className="text-[10px] text-gray-400 uppercase w-8 text-center">Pos</span> | |
| <input type="range" min="-200" max="200" step="10" value={manualY} onChange={(e) => setManualY(parseFloat(e.target.value))} className="w-20 h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer accent-brand-500" /> | |
| </div> | |
| </div> | |
| )} | |
| {/* Shutter Button */} | |
| <div className="flex items-center justify-center"> | |
| <button | |
| onClick={handleCapture} | |
| className="w-20 h-20 rounded-full border-4 border-white/90 flex items-center justify-center relative group shadow-[0_0_30px_rgba(255,255,255,0.2)] hover:shadow-[0_0_40px_rgba(255,255,255,0.4)] transition-all" | |
| > | |
| <div className="w-16 h-16 bg-white rounded-full group-hover:scale-90 transition-transform duration-200" /> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default CameraView; |