import { useAuth } from "@/_core/hooks/useAuth"; import { trpc } from "@/lib/trpc"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; import { toast } from "sonner"; import { Camera, CameraOff, RotateCcw, CheckCircle2, AlertTriangle, Smartphone, Monitor, FlipHorizontal, Zap, Activity } from "lucide-react"; import { useRef, useState, useEffect, useCallback } from "react"; type CameraFacing = "user" | "environment"; interface PoseScore { overall: number; posture: number; balance: number; armAngle: number; } // Camera position guide steps const SETUP_STEPS = [ { title: "放置设备", desc: "将手机/平板固定在三脚架或稳定平面上", icon: }, { title: "调整距离", desc: "确保摄像头能拍到全身(距离2-3米)", icon: }, { title: "调整高度", desc: "摄像头高度约在腰部位置,略微仰角", icon: }, { title: "确认画面", desc: "确保光线充足,背景简洁,全身可见", icon: }, ]; export default function LiveCamera() { const { user } = useAuth(); const videoRef = useRef(null); const canvasRef = useRef(null); const streamRef = useRef(null); const poseRef = useRef(null); const animFrameRef = useRef(0); const [cameraActive, setCameraActive] = useState(false); const [facing, setFacing] = useState("environment"); const [showSetupGuide, setShowSetupGuide] = useState(true); const [setupStep, setSetupStep] = useState(0); const [analyzing, setAnalyzing] = useState(false); const [liveScore, setLiveScore] = useState(null); const [frameCount, setFrameCount] = useState(0); const [fps, setFps] = useState(0); const [hasMultipleCameras, setHasMultipleCameras] = useState(false); const [exerciseType, setExerciseType] = useState("forehand"); const [feedback, setFeedback] = useState([]); // Check available cameras useEffect(() => { navigator.mediaDevices?.enumerateDevices().then(devices => { const cameras = devices.filter(d => d.kind === "videoinput"); setHasMultipleCameras(cameras.length > 1); }).catch(() => {}); }, []); // FPS counter useEffect(() => { if (!analyzing) return; const interval = setInterval(() => { setFps(prev => { const current = frameCount; setFrameCount(0); return current; }); }, 1000); return () => clearInterval(interval); }, [analyzing, frameCount]); const startCamera = useCallback(async () => { try { // Stop existing stream if (streamRef.current) { streamRef.current.getTracks().forEach(t => t.stop()); } const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent); const constraints: MediaStreamConstraints = { video: { facingMode: facing, width: { ideal: isMobile ? 1280 : 1920 }, height: { ideal: isMobile ? 720 : 1080 }, frameRate: { ideal: 30, max: 30 }, }, audio: false, }; const stream = await navigator.mediaDevices.getUserMedia(constraints); streamRef.current = stream; if (videoRef.current) { videoRef.current.srcObject = stream; await videoRef.current.play(); } setCameraActive(true); toast.success("摄像头已启动"); } catch (err: any) { console.error("Camera error:", err); if (err.name === "NotAllowedError") { toast.error("请允许摄像头访问权限"); } else if (err.name === "NotFoundError") { toast.error("未找到摄像头设备"); } else { toast.error("摄像头启动失败: " + err.message); } } }, [facing]); const stopCamera = useCallback(() => { if (streamRef.current) { streamRef.current.getTracks().forEach(t => t.stop()); streamRef.current = null; } if (animFrameRef.current) { cancelAnimationFrame(animFrameRef.current); } setCameraActive(false); setAnalyzing(false); setLiveScore(null); }, []); const switchCamera = useCallback(() => { const newFacing = facing === "user" ? "environment" : "user"; setFacing(newFacing); if (cameraActive) { stopCamera(); setTimeout(() => startCamera(), 300); } }, [facing, cameraActive, stopCamera, startCamera]); // Start pose analysis const startAnalysis = useCallback(async () => { if (!videoRef.current || !canvasRef.current) return; setAnalyzing(true); toast.info("正在加载姿势识别模型..."); try { const { Pose } = await import("@mediapipe/pose"); const { drawConnectors, drawLandmarks } = await import("@mediapipe/drawing_utils"); const pose = new Pose({ locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/pose/${file}`, }); pose.setOptions({ modelComplexity: 1, smoothLandmarks: true, enableSegmentation: false, minDetectionConfidence: 0.5, minTrackingConfidence: 0.5, }); const POSE_CONNECTIONS = [ [11, 12], [11, 13], [13, 15], [12, 14], [14, 16], [11, 23], [12, 24], [23, 24], [23, 25], [24, 26], [25, 27], [26, 28], [15, 17], [16, 18], [15, 19], [16, 20], [17, 19], [18, 20], ]; pose.onResults((results: any) => { const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!canvas || !ctx || !videoRef.current) return; canvas.width = videoRef.current.videoWidth; canvas.height = videoRef.current.videoHeight; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(videoRef.current, 0, 0); if (results.poseLandmarks) { // Draw skeleton const landmarks = results.poseLandmarks; // Draw connections ctx.strokeStyle = "rgba(0, 200, 100, 0.8)"; ctx.lineWidth = 3; POSE_CONNECTIONS.forEach(([a, b]) => { const la = landmarks[a]; const lb = landmarks[b]; if (la && lb && la.visibility > 0.3 && lb.visibility > 0.3) { ctx.beginPath(); ctx.moveTo(la.x * canvas.width, la.y * canvas.height); ctx.lineTo(lb.x * canvas.width, lb.y * canvas.height); ctx.stroke(); } }); // Draw landmarks landmarks.forEach((lm: any, i: number) => { if (lm.visibility > 0.3) { ctx.fillStyle = [11, 12, 13, 14, 15, 16].includes(i) ? "#ff4444" : "#00cc66"; ctx.beginPath(); ctx.arc(lm.x * canvas.width, lm.y * canvas.height, 5, 0, 2 * Math.PI); ctx.fill(); } }); // Calculate live scores const score = calculateLiveScore(landmarks, exerciseType); setLiveScore(score); setFeedback(generateLiveFeedback(landmarks, exerciseType)); setFrameCount(prev => prev + 1); // Draw score overlay ctx.fillStyle = "rgba(0,0,0,0.6)"; ctx.fillRect(10, 10, 180, 40); ctx.fillStyle = "#fff"; ctx.font = "bold 16px sans-serif"; ctx.fillText(`评分: ${score.overall}/100`, 20, 35); } }); poseRef.current = pose; const processFrame = async () => { if (!videoRef.current || !analyzing) return; if (videoRef.current.readyState >= 2) { await pose.send({ image: videoRef.current }); } animFrameRef.current = requestAnimationFrame(processFrame); }; toast.success("模型加载完成,开始实时分析"); processFrame(); } catch (err) { console.error("Pose init error:", err); toast.error("姿势识别模型加载失败"); setAnalyzing(false); } }, [analyzing, exerciseType]); const stopAnalysis = useCallback(() => { if (animFrameRef.current) { cancelAnimationFrame(animFrameRef.current); } setAnalyzing(false); setLiveScore(null); }, []); // Cleanup on unmount useEffect(() => { return () => { stopCamera(); }; }, [stopCamera]); // Setup guide dialog const handleSetupComplete = () => { setShowSetupGuide(false); startCamera(); }; return ( 实时姿势分析 通过摄像头实时捕捉并分析您的挥拍动作 正手挥拍 反手挥拍 发球 截击 脚步移动 {/* Camera position setup guide */} 摄像头位置设置 为获得最佳分析效果,请按以下步骤调整设备位置 {SETUP_STEPS.map((step, idx) => ( {idx < setupStep ? : step.icon} {step.title} {step.desc} ))} {setupStep > 0 && ( setSetupStep(s => s - 1)}>上一步 )} {setupStep < SETUP_STEPS.length - 1 ? ( setSetupStep(s => s + 1)}>下一步 ) : ( 开始使用摄像头 )} {/* Main camera view */} {!cameraActive && ( 摄像头未启动 setShowSetupGuide(true)}> 启动摄像头 )} {/* FPS indicator */} {analyzing && ( {fps} FPS )} {/* Controls bar */} {!cameraActive ? ( setShowSetupGuide(true)} className="gap-2"> 启动摄像头 ) : ( <> 关闭 {hasMultipleCameras && ( {facing === "user" ? "后置" : "前置"} )} {!analyzing ? ( 开始分析 ) : ( 停止分析 )} setShowSetupGuide(true)} className="gap-1.5"> 重新调整 > )} {/* Live score panel */} 实时评分 {liveScore ? ( <> {liveScore.overall} 综合评分 > ) : ( 开始分析后显示实时评分 )} 实时反馈 {feedback.length > 0 ? ( {feedback.map((f, i) => ( {f} ))} ) : ( 分析中将显示实时矫正建议 )} {/* Tips card */} 📱 移动端提示 · 横屏模式效果更佳 · 使用后置摄像头获得更高画质 · 保持2-3米拍摄距离 · 确保光线充足 ); } // Score bar component function ScoreBar({ label, value }: { label: string; value: number }) { const color = value >= 80 ? "bg-green-500" : value >= 60 ? "bg-yellow-500" : "bg-red-500"; return ( {label} {Math.round(value)} ); } // Live score calculation from landmarks function calculateLiveScore(landmarks: any[], exerciseType: string): PoseScore { const getAngle = (a: any, b: any, c: any) => { const radians = Math.atan2(c.y - b.y, c.x - b.x) - Math.atan2(a.y - b.y, a.x - b.x); let angle = Math.abs(radians * 180 / Math.PI); if (angle > 180) angle = 360 - angle; return angle; }; // Shoulder alignment (posture) const leftShoulder = landmarks[11]; const rightShoulder = landmarks[12]; const shoulderDiff = Math.abs(leftShoulder.y - rightShoulder.y); const postureScore = Math.max(0, 100 - shoulderDiff * 500); // Hip alignment (balance) const leftHip = landmarks[23]; const rightHip = landmarks[24]; const hipDiff = Math.abs(leftHip.y - rightHip.y); const balanceScore = Math.max(0, 100 - hipDiff * 500); // Arm angle based on exercise type let armScore = 70; if (exerciseType === "forehand" || exerciseType === "backhand") { const shoulder = exerciseType === "forehand" ? landmarks[12] : landmarks[11]; const elbow = exerciseType === "forehand" ? landmarks[14] : landmarks[13]; const wrist = exerciseType === "forehand" ? landmarks[16] : landmarks[15]; const angle = getAngle(shoulder, elbow, wrist); // Ideal forehand/backhand elbow angle: 90-150 degrees if (angle >= 90 && angle <= 150) armScore = 90 + (1 - Math.abs(angle - 120) / 30) * 10; else armScore = Math.max(30, 90 - Math.abs(angle - 120)); } else if (exerciseType === "serve") { const rightElbow = landmarks[14]; const rightShoulder2 = landmarks[12]; const rightWrist = landmarks[16]; const angle = getAngle(rightShoulder2, rightElbow, rightWrist); if (angle >= 150 && angle <= 180) armScore = 95; else armScore = Math.max(40, 95 - Math.abs(angle - 165) * 2); } const overall = Math.round(postureScore * 0.3 + balanceScore * 0.3 + armScore * 0.4); return { overall: Math.min(100, Math.max(0, overall)), posture: Math.min(100, Math.max(0, Math.round(postureScore))), balance: Math.min(100, Math.max(0, Math.round(balanceScore))), armAngle: Math.min(100, Math.max(0, Math.round(armScore))), }; } // Generate live feedback function generateLiveFeedback(landmarks: any[], exerciseType: string): string[] { const tips: string[] = []; const leftShoulder = landmarks[11]; const rightShoulder = landmarks[12]; if (Math.abs(leftShoulder.y - rightShoulder.y) > 0.05) { tips.push("⚠️ 双肩不平衡,注意保持肩膀水平"); } const leftHip = landmarks[23]; const rightHip = landmarks[24]; if (Math.abs(leftHip.y - rightHip.y) > 0.05) { tips.push("⚠️ 重心不稳,注意保持髋部水平"); } const nose = landmarks[0]; const midShoulder = { x: (leftShoulder.x + rightShoulder.x) / 2, y: (leftShoulder.y + rightShoulder.y) / 2 }; if (Math.abs(nose.x - midShoulder.x) > 0.08) { tips.push("⚠️ 头部偏移,保持头部在身体中心线上"); } if (exerciseType === "forehand") { const rightElbow = landmarks[14]; const rightWrist = landmarks[16]; if (rightElbow.y > rightShoulder.y + 0.15) { tips.push("💡 正手:抬高肘部,保持手臂在肩膀高度"); } } if (exerciseType === "serve") { const rightWrist = landmarks[16]; if (rightWrist.y > rightShoulder.y) { tips.push("💡 发球:手臂需要充分伸展向上"); } } if (tips.length === 0) { tips.push("✅ 姿势良好,继续保持!"); } return tips; }
通过摄像头实时捕捉并分析您的挥拍动作
{step.title}
{step.desc}
摄像头未启动
{liveScore.overall}
综合评分
开始分析后显示实时评分
分析中将显示实时矫正建议
📱 移动端提示