ba062e92d0
- App.tsx: wrappers de padding (p-4/md:p-8) e max-w-7xl/mx-auto agora só se aplicam quando não é tela standalone; login/landing/public renderizam full-bleed - OnboardingChat.tsx: classe ob-noscroll esconde a barra de rolagem do slide intro (mantém scroll), substituindo a custom-scrollbar indefinida no fluxo de login Inclui também demais alterações pendentes do working tree (snapshot do estado atual de dev). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
204 lines
9.3 KiB
TypeScript
204 lines
9.3 KiB
TypeScript
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<Props> = ({ onCapture, onClose, title, allowGallery = true }) => {
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const captureCanvas = useRef<HTMLCanvasElement | null>(null);
|
|
const analysisCanvas = useRef<HTMLCanvasElement | null>(null);
|
|
const prevGray = useRef<Float32Array | null>(null);
|
|
const streamRef = useRef<MediaStream | null>(null);
|
|
const rafRef = useRef<number>(0);
|
|
const fileInputRef = useRef<HTMLInputElement>(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<HTMLCanvasElement | null>) => (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<HTMLInputElement>) => {
|
|
const f = e.target.files?.[0];
|
|
if (f) { stop(); onCapture(f); onClose(); }
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black z-[90] flex flex-col select-none">
|
|
<div className="flex items-center justify-between px-4 py-3 text-white flex-shrink-0">
|
|
<span className="text-xs font-black uppercase truncate">{title || 'CÂMERA'}</span>
|
|
<button onClick={() => { stop(); onClose(); }} className="p-2 hover:bg-white/10 rounded-lg"><X size={20} /></button>
|
|
</div>
|
|
|
|
<div className="flex-1 relative min-h-0 flex items-center justify-center overflow-hidden">
|
|
{error ? (
|
|
<div className="text-center text-white/80 px-6">
|
|
<AlertTriangle size={36} className="mx-auto mb-3 text-amber-400" />
|
|
<p className="text-sm font-bold uppercase">{error}</p>
|
|
{allowGallery && (
|
|
<button onClick={() => fileInputRef.current?.click()} className="mt-4 px-4 py-2 bg-white/10 rounded-xl text-xs font-black uppercase">
|
|
Usar galeria
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<video ref={videoRef} playsInline muted className="max-w-full max-h-full object-contain" />
|
|
{!ready && <Loader2 size={32} className="absolute animate-spin text-white/60" />}
|
|
{ready && <div className={`absolute inset-6 border-2 rounded-2xl pointer-events-none transition-colors duration-200 ${steady ? 'border-emerald-400' : 'border-white/40'}`} />}
|
|
{ready && (
|
|
<div className={`absolute bottom-4 left-1/2 -translate-x-1/2 text-[11px] font-black uppercase px-3 py-1.5 rounded-full ${steady ? 'bg-emerald-500 text-white' : 'bg-black/60 text-white'}`}>
|
|
{hint}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between px-6 py-5 gap-3 flex-shrink-0">
|
|
<div className="flex gap-2 w-24">
|
|
{allowGallery && (
|
|
<button onClick={() => fileInputRef.current?.click()} className="p-3 bg-white/10 text-white rounded-full" title="Galeria"><ImageIcon size={20} /></button>
|
|
)}
|
|
{torchSupported && (
|
|
<button onClick={toggleTorch} className={`p-3 rounded-full ${torchOn ? 'bg-amber-400 text-black' : 'bg-white/10 text-white'}`} title="Lanterna">
|
|
{torchOn ? <Zap size={20} /> : <ZapOff size={20} />}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<button onClick={capture} disabled={!ready} className="w-16 h-16 rounded-full bg-white border-4 border-white/40 disabled:opacity-40 flex items-center justify-center active:scale-95 transition-transform">
|
|
<Camera size={26} className="text-gray-800" />
|
|
</button>
|
|
<div className="flex gap-2 w-24 justify-end">
|
|
<button onClick={() => setAuto(a => !a)} className={`px-3 py-2 rounded-full text-[10px] font-black uppercase ${auto ? 'bg-emerald-500 text-white' : 'bg-white/10 text-white'}`} title="Auto-disparo quando firme">AUTO</button>
|
|
<button onClick={() => { const m = facing === 'environment' ? 'user' : 'environment'; setFacing(m); start(m); }} className="p-3 bg-white/10 text-white rounded-full" title="Trocar câmera"><RotateCcw size={20} /></button>
|
|
</div>
|
|
</div>
|
|
|
|
{allowGallery && <input ref={fileInputRef} type="file" accept="image/*" capture="environment" className="hidden" onChange={onPickFile} />}
|
|
</div>
|
|
);
|
|
};
|