- BottleGrid: Implement blurred backdrop effect for bottle cards - Cascade OCR: TextDetector → RegEx → Fuzzy Match → window.ai pipeline - Smart Scan: Native OCR for Android, Live Text fallback for iOS - OCR Dashboard: Admin page at /admin/ocr-logs with stats and scan history - Features: Add feature flags in src/config/features.ts - SQL: Add ocr_logs table migration - Services: Update analyze-bottle to use OpenRouter, add save-ocr-log
278 lines
9.8 KiB
TypeScript
278 lines
9.8 KiB
TypeScript
'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<HTMLVideoElement>(null);
|
|
const streamRef = useRef<MediaStream | null>(null);
|
|
const animationRef = useRef<number | null>(null);
|
|
|
|
const { processVideoFrame } = useScanFlow();
|
|
|
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
const [detectedTexts, setDetectedTexts] = useState<string[]>([]);
|
|
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 (
|
|
<div className="fixed inset-0 z-50 bg-black">
|
|
{/* Header */}
|
|
<div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between p-4 bg-gradient-to-b from-black/80 to-transparent">
|
|
<div className="flex items-center gap-2 text-white">
|
|
<Zap size={20} className="text-orange-500" />
|
|
<span className="font-bold text-sm">Native OCR</span>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 rounded-full bg-white/10 text-white hover:bg-white/20"
|
|
>
|
|
<X size={24} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Video Feed */}
|
|
<video
|
|
ref={videoRef}
|
|
playsInline
|
|
muted
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
|
|
{/* Scan Overlay */}
|
|
<div className="absolute inset-0 pointer-events-none">
|
|
{/* Scan Frame */}
|
|
<div className="absolute inset-[10%] border-2 border-orange-500/50 rounded-2xl">
|
|
<div className="absolute top-0 left-0 w-8 h-8 border-t-4 border-l-4 border-orange-500 rounded-tl-xl" />
|
|
<div className="absolute top-0 right-0 w-8 h-8 border-t-4 border-r-4 border-orange-500 rounded-tr-xl" />
|
|
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-4 border-l-4 border-orange-500 rounded-bl-xl" />
|
|
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-4 border-r-4 border-orange-500 rounded-br-xl" />
|
|
</div>
|
|
|
|
{/* Scanning indicator */}
|
|
{isStreaming && !isAutoCapturing && (
|
|
<div className="absolute top-[12%] left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 bg-black/60 rounded-full text-white text-sm">
|
|
<Loader2 size={16} className="animate-spin text-orange-500" />
|
|
Scanning...
|
|
</div>
|
|
)}
|
|
|
|
{/* Auto-capture success */}
|
|
{isAutoCapturing && (
|
|
<div className="absolute top-[12%] left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 bg-green-600 rounded-full text-white text-sm">
|
|
<CheckCircle size={16} />
|
|
Captured!
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Detected Text Display */}
|
|
<div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/90 to-transparent">
|
|
{extractedData.distillery && (
|
|
<div className="mb-2 px-3 py-1 bg-orange-600 rounded-full inline-block">
|
|
<span className="text-white text-sm font-bold">
|
|
🏭 {extractedData.distillery}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2 flex-wrap mb-2">
|
|
{extractedData.abv && (
|
|
<span className="px-2 py-1 bg-white/20 rounded text-white text-xs">
|
|
{extractedData.abv}% ABV
|
|
</span>
|
|
)}
|
|
{extractedData.age && (
|
|
<span className="px-2 py-1 bg-white/20 rounded text-white text-xs">
|
|
{extractedData.age} Years
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{detectedTexts.length > 0 && (
|
|
<div className="max-h-20 overflow-y-auto">
|
|
<p className="text-zinc-400 text-xs">
|
|
{detectedTexts.slice(0, 5).join(' • ')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{!detectedTexts.length && isStreaming && (
|
|
<p className="text-zinc-500 text-sm text-center">
|
|
Point camera at the bottle label
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|