Files
Dramlog-Prod/src/components/NativeOCRScanner.tsx
robin 9ba0825bcd feat: Add Spotify-style backdrop, Cascade OCR, Smart Scan Flow & OCR Dashboard
- 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
2026-01-18 20:38:48 +01:00

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>
);
}