'use client'; /** * Native OCR Scanner Component * * Uses the Shape Detection API (TextDetector) for zero-latency, * zero-download OCR directly from the camera stream. * * Only works on Android/Chrome/Edge. iOS uses the Live Text fallback. */ import React, { useRef, useEffect, useState, useCallback } from 'react'; import { X, Camera, Loader2, Zap, CheckCircle } from 'lucide-react'; import { useScanFlow } from '@/hooks/useScanFlow'; import { normalizeDistillery } from '@/lib/distillery-matcher'; interface NativeOCRScannerProps { isOpen: boolean; onClose: () => void; onTextDetected: (texts: string[]) => void; onAutoCapture?: (result: { rawTexts: string[]; distillery: string | null; abv: number | null; age: number | null; }) => void; } // RegEx patterns for auto-extraction const PATTERNS = { abv: /(\d{1,2}[.,]\d{1}|\d{1,2})\s*%\s*(?:vol|alc)?/i, age: /(\d{1,2})\s*(?:years?|yo|y\.?o\.?|jahre?)\s*(?:old)?/i, }; export default function NativeOCRScanner({ isOpen, onClose, onTextDetected, onAutoCapture }: NativeOCRScannerProps) { const videoRef = useRef(null); const streamRef = useRef(null); const animationRef = useRef(null); const { processVideoFrame } = useScanFlow(); const [isStreaming, setIsStreaming] = useState(false); const [detectedTexts, setDetectedTexts] = useState([]); const [extractedData, setExtractedData] = useState<{ distillery: string | null; abv: number | null; age: number | null; }>({ distillery: null, abv: null, age: null }); const [isAutoCapturing, setIsAutoCapturing] = useState(false); // Start camera stream const startStream = useCallback(async () => { try { console.log('[NativeOCR] Starting camera stream...'); const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 }, }, }); streamRef.current = stream; if (videoRef.current) { videoRef.current.srcObject = stream; await videoRef.current.play(); setIsStreaming(true); console.log('[NativeOCR] Camera stream started'); } } catch (err) { console.error('[NativeOCR] Camera access failed:', err); } }, []); // Stop camera stream const stopStream = useCallback(() => { console.log('[NativeOCR] Stopping camera stream...'); if (animationRef.current) { cancelAnimationFrame(animationRef.current); animationRef.current = null; } if (streamRef.current) { streamRef.current.getTracks().forEach(track => track.stop()); streamRef.current = null; } if (videoRef.current) { videoRef.current.srcObject = null; } setIsStreaming(false); setDetectedTexts([]); }, []); // Process frames continuously const processLoop = useCallback(async () => { if (!videoRef.current || !isStreaming) return; const texts = await processVideoFrame(videoRef.current); if (texts.length > 0) { setDetectedTexts(texts); onTextDetected(texts); // Try to extract structured data const allText = texts.join(' '); // ABV const abvMatch = allText.match(PATTERNS.abv); const abv = abvMatch ? parseFloat(abvMatch[1].replace(',', '.')) : null; // Age const ageMatch = allText.match(PATTERNS.age); const age = ageMatch ? parseInt(ageMatch[1], 10) : null; // Distillery (fuzzy match) let distillery: string | null = null; for (const text of texts) { if (text.length >= 4 && text.length <= 40) { const match = normalizeDistillery(text); if (match.matched) { distillery = match.name; break; } } } setExtractedData({ distillery, abv, age }); // Auto-capture if we have enough data if (distillery && (abv || age) && !isAutoCapturing) { console.log('[NativeOCR] Auto-capture triggered:', { distillery, abv, age }); setIsAutoCapturing(true); if (onAutoCapture) { onAutoCapture({ rawTexts: texts, distillery, abv, age, }); } // Visual feedback before closing setTimeout(() => { onClose(); }, 1500); } } // Continue loop (throttled to ~5 FPS for performance) animationRef.current = window.setTimeout(() => { requestAnimationFrame(processLoop); }, 200) as unknown as number; }, [isStreaming, processVideoFrame, onTextDetected, onAutoCapture, isAutoCapturing, onClose]); // Start/stop based on isOpen useEffect(() => { if (isOpen) { startStream(); } else { stopStream(); } return () => { stopStream(); }; }, [isOpen, startStream, stopStream]); // Start processing loop when streaming useEffect(() => { if (isStreaming) { processLoop(); } }, [isStreaming, processLoop]); if (!isOpen) return null; return (
{/* Header */}
Native OCR
{/* Video Feed */}