import React, { useRef, useState, useEffect, useCallback } from 'react'; import { Camera, X, Zap, ZapOff, Image as ImageIcon, RotateCcw, Loader2, AlertTriangle } from 'lucide-react'; interface Props { onCapture: (file: File) => void; onClose: () => void; title?: string; allowGallery?: boolean; } // Resolução baixa só para análise de movimento/reflexo (leve no celular). const AW = 80, AH = 60; /** * Captura por câmera no NAVEGADOR (getUserMedia). Recursos: * - Câmera traseira ao vivo, guia de enquadramento. * - Auto-disparo quando a imagem fica ESTÁVEL (anti-tremor) — opcional (botão AUTO). * - Detecção de REFLEXO (estouro de luz) avisando para reposicionar. * - Lanterna (torch) onde o navegador suporta (Android/Chrome). * - Fallback para galeria/arquivo. * Observação: requer HTTPS (dev.scoreodonto.com já é). */ export const CameraCapture: React.FC = ({ onCapture, onClose, title, allowGallery = true }) => { const videoRef = useRef(null); const captureCanvas = useRef(null); const analysisCanvas = useRef(null); const prevGray = useRef(null); const streamRef = useRef(null); const rafRef = useRef(0); const fileInputRef = useRef(null); const stableCountRef = useRef(0); const capturedRef = useRef(false); const [facing, setFacing] = useState<'environment' | 'user'>('environment'); const [error, setError] = useState(''); const [ready, setReady] = useState(false); const [torchSupported, setTorchSupported] = useState(false); const [torchOn, setTorchOn] = useState(false); const [auto, setAuto] = useState(true); const [hint, setHint] = useState('INICIANDO CÂMERA…'); const [steady, setSteady] = useState(false); const cvs = (r: React.MutableRefObject) => (r.current ??= document.createElement('canvas')); const stop = useCallback(() => { cancelAnimationFrame(rafRef.current); streamRef.current?.getTracks().forEach(t => t.stop()); streamRef.current = null; }, []); const start = useCallback(async (mode: 'environment' | 'user') => { setError(''); setReady(false); prevGray.current = null; stableCountRef.current = 0; capturedRef.current = false; stop(); if (!navigator.mediaDevices?.getUserMedia) { setError('CÂMERA NÃO SUPORTADA NESTE NAVEGADOR.'); return; } try { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: mode }, width: { ideal: 1920 }, height: { ideal: 1080 } }, audio: false, }); streamRef.current = stream; const v = videoRef.current; if (v) { v.srcObject = stream; await v.play().catch(() => {}); } const track = stream.getVideoTracks()[0]; const caps: any = track.getCapabilities ? track.getCapabilities() : {}; setTorchSupported(!!caps.torch); setTorchOn(false); setReady(true); setHint('ENQUADRE E SEGURE FIRME'); } catch (e: any) { setError( e?.name === 'NotAllowedError' ? 'PERMISSÃO DE CÂMERA NEGADA.' : e?.name === 'NotFoundError' ? 'NENHUMA CÂMERA ENCONTRADA.' : 'NÃO FOI POSSÍVEL ABRIR A CÂMERA.' ); } }, [stop]); useEffect(() => { start(facing); return stop; /* eslint-disable-next-line */ }, []); const toGray = (v: HTMLVideoElement): Float32Array => { const c = cvs(analysisCanvas); c.width = AW; c.height = AH; const ctx = c.getContext('2d', { willReadFrequently: true })!; ctx.drawImage(v, 0, 0, AW, AH); const d = ctx.getImageData(0, 0, AW, AH).data; const g = new Float32Array(AW * AH); for (let i = 0, j = 0; i < d.length; i += 4, j++) g[j] = d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114; return g; }; const capture = useCallback(() => { if (capturedRef.current) return; const v = videoRef.current; if (!v || !v.videoWidth) return; capturedRef.current = true; const c = cvs(captureCanvas); c.width = v.videoWidth; c.height = v.videoHeight; c.getContext('2d')!.drawImage(v, 0, 0); c.toBlob(blob => { if (!blob) { capturedRef.current = false; return; } onCapture(new File([blob], `foto_${Date.now()}.jpg`, { type: 'image/jpeg' })); stop(); onClose(); }, 'image/jpeg', 0.92); }, [onCapture, onClose, stop]); // Loop de análise: estabilidade + reflexo + auto-disparo useEffect(() => { if (!ready) return; let last = 0; const loop = (ts: number) => { rafRef.current = requestAnimationFrame(loop); if (ts - last < 120) return; last = ts; const v = videoRef.current; if (!v || !v.videoWidth || capturedRef.current) return; const g = toGray(v); let bright = 0; for (let i = 0; i < g.length; i++) if (g[i] > 245) bright++; const glare = bright / g.length; let motion = 999; if (prevGray.current) { let sum = 0; for (let i = 0; i < g.length; i++) sum += Math.abs(g[i] - prevGray.current[i]); motion = sum / g.length; } prevGray.current = g; const isSteady = motion < 5; setSteady(isSteady && glare <= 0.18); if (glare > 0.18) { setHint('REFLEXO DETECTADO — INCLINE OU AFASTE A LUZ'); stableCountRef.current = 0; return; } if (!isSteady) { setHint('SEGURE FIRME…'); stableCountRef.current = 0; return; } stableCountRef.current++; setHint(auto ? 'ESTÁVEL — CAPTURANDO…' : 'ESTÁVEL — TOQUE NO BOTÃO'); if (auto && stableCountRef.current >= 4) capture(); }; rafRef.current = requestAnimationFrame(loop); return () => cancelAnimationFrame(rafRef.current); }, [ready, auto, capture]); const toggleTorch = async () => { const track = streamRef.current?.getVideoTracks()[0]; if (!track) return; try { await track.applyConstraints({ advanced: [{ torch: !torchOn }] as any }); setTorchOn(t => !t); } catch { /* ignore */ } }; const onPickFile = (e: React.ChangeEvent) => { const f = e.target.files?.[0]; if (f) { stop(); onCapture(f); onClose(); } }; return (
{title || 'CÂMERA'}
{error ? (

{error}

{allowGallery && ( )}
) : ( <>
); };