| 689 | const video = videoRef.current; |
| 690 | |
| 691 | const start = async () => { |
| 692 | if (!enableWebcam || !modelsReady) return; |
| 693 | if (!video) return; |
| 694 | |
| 695 | try { |
| 696 | const stream = await navigator.mediaDevices.getUserMedia({ |
| 697 | video: { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 720 } }, |
| 698 | audio: false |
| 699 | }); |
| 700 | video.srcObject = stream; |
| 701 | await video.play(); |
| 702 | } catch { |
| 703 | return; |
| 704 | } |
| 705 | |
| 706 | const opts = new faceapi.TinyFaceDetectorOptions({ inputSize: 320, scoreThreshold: 0.5 }); |
| 707 | |
| 708 | const detect = async ts => { |
| 709 | if (stop) return; |
| 710 | |
| 711 | if (ts - lastDetect >= 33) { |
| 712 | lastDetect = ts; |
| 713 | try { |
| 714 | const res = await faceapi.detectSingleFace(video, opts).withFaceLandmarks(true); |
| 715 | if (res && res.detection) { |
| 716 | const det = res.detection; |
| 717 | const box = det.box; |
| 718 | const vw = video.videoWidth || 1; |
| 719 | const vh = video.videoHeight || 1; |
| 720 | |
| 721 | const cx = box.x + box.width * 0.5; |
| 722 | const cy = box.y + box.height * 0.5; |
| 723 | const nx = (cx / vw) * 2 - 1; |
| 724 | const ny = (cy / vh) * 2 - 1; |
| 725 | medianPush(bufX.current, nx, 5); |
| 726 | medianPush(bufY.current, ny, 5); |
| 727 | const nxm = median(bufX.current); |
| 728 | const nym = median(bufY.current); |
| 729 | |
| 730 | const look = new THREE.Vector2(Math.tanh(nxm), Math.tanh(nym)); |
| 731 | |
| 732 | const faceSize = Math.min(1, Math.hypot(box.width / vw, box.height / vh)); |
| 733 | const depthScale = 1 + depthResponse * (faceSize - 0.25); |
| 734 | lookTarget.current.copy(look.multiplyScalar(depthScale)); |
| 735 | |
| 736 | const leftEye = res.landmarks.getLeftEye(); |
| 737 | const rightEye = res.landmarks.getRightEye(); |
| 738 | const lc = centroid(leftEye); |
| 739 | const rc = centroid(rightEye); |
| 740 | const tilt = Math.atan2(rc.y - lc.y, rc.x - lc.x); |
| 741 | medianPush(bufT.current, tilt, 5); |
| 742 | tiltTarget.current = median(bufT.current); |
| 743 | |
| 744 | const nose = res.landmarks.getNose(); |
| 745 | const tip = nose[nose.length - 1] || nose[Math.floor(nose.length / 2)]; |
| 746 | const jaw = res.landmarks.getJawOutline(); |
| 747 | const leftCheek = jaw[3] || jaw[2]; |
| 748 | const rightCheek = jaw[13] || jaw[14]; |