Build [v.1.3.6]
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode';
|
||||
import { Camera, RefreshCw, XCircle, Type, Search } from 'lucide-react';
|
||||
import { RefreshCw, XCircle, Search } from 'lucide-react';
|
||||
import { createWorker } from 'tesseract.js';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
@@ -16,8 +16,8 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
const html5QrCodeRef = useRef<Html5Qrcode | null>(null);
|
||||
const [isStarted, setIsStarted] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isOCRMode, setIsOCRMode] = useState(false);
|
||||
const [ocrProcessing, setOcrProcessing] = useState(false);
|
||||
const [countdown, setCountdown] = useState(4);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [maxZoom, setMaxZoom] = useState(1);
|
||||
const [hasZoom, setHasZoom] = useState(false);
|
||||
@@ -41,8 +41,8 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
}
|
||||
|
||||
const config = {
|
||||
fps: 15, // Lower FPS for stability
|
||||
qrbox: { width: 280, height: 280 },
|
||||
fps: 15,
|
||||
qrbox: { width: 320, height: 320 },
|
||||
aspectRatio: 1.0,
|
||||
formatsToSupport: [
|
||||
Html5QrcodeSupportedFormats.QR_CODE,
|
||||
@@ -52,7 +52,6 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
Html5QrcodeSupportedFormats.UPC_A,
|
||||
Html5QrcodeSupportedFormats.DATAMATRIX
|
||||
],
|
||||
// Simplified constraints for maximum compatibility
|
||||
videoConstraints: {
|
||||
facingMode: "environment"
|
||||
}
|
||||
@@ -67,7 +66,6 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
() => {} // Ignore frame errors
|
||||
);
|
||||
|
||||
// Check for zoom capability via video track
|
||||
const video = document.querySelector(`#${scannerId} video`) as HTMLVideoElement;
|
||||
const track = (video?.srcObject as MediaStream)?.getVideoTracks()[0];
|
||||
const caps = track?.getCapabilities() as any;
|
||||
@@ -103,20 +101,26 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
.catch(e => console.error("Stop failed", e));
|
||||
}
|
||||
};
|
||||
}, [paused, onScanSuccess]); // Minimal deps
|
||||
}, [paused, onScanSuccess]);
|
||||
|
||||
// Separate effect for OCR interval to avoid restarting scanner
|
||||
// Automated OCR Countdown Timer
|
||||
useEffect(() => {
|
||||
let intervalId: any;
|
||||
if (isOCRMode && isStarted && !paused) {
|
||||
intervalId = setInterval(() => {
|
||||
handleOCR();
|
||||
}, 5000); // 5 seconds is safer
|
||||
if (!isStarted || paused || isSelecting || ocrProcessing) {
|
||||
return;
|
||||
}
|
||||
return () => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
};
|
||||
}, [isOCRMode, isStarted, paused]);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setCountdown(prev => {
|
||||
if (prev <= 1) {
|
||||
handleOCR();
|
||||
return 4; // Reset to 4
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isStarted, paused, isSelecting, ocrProcessing]);
|
||||
|
||||
const handleOCR = async () => {
|
||||
if (ocrProcessing) return;
|
||||
@@ -126,11 +130,9 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
if (!video) return;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
const vWidth = video.videoWidth || 1280;
|
||||
const vHeight = video.videoHeight || 720;
|
||||
|
||||
// Digital Zoom/Crop: 60% of center
|
||||
const cropFactor = 0.6;
|
||||
const sw = vWidth * cropFactor;
|
||||
const sh = vHeight * cropFactor;
|
||||
@@ -140,48 +142,36 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
canvas.width = 1200;
|
||||
canvas.height = (sh / sw) * 1200;
|
||||
|
||||
console.log(`OCR Capture: ${vWidth}x${vHeight} -> ${canvas.width}x${canvas.height}`);
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
// More balanced filter for both paper and metal labels
|
||||
ctx.filter = 'grayscale(100%) contrast(180%) brightness(105%)';
|
||||
ctx.drawImage(video, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
// Timeout protection for worker initialization (especially when offline for first run)
|
||||
const workerPromise = createWorker('eng');
|
||||
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("OCR Engine timeout - check internet for first run")), 8000));
|
||||
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("OCR Engine timeout")), 8000));
|
||||
|
||||
const worker = await Promise.race([workerPromise, timeoutPromise]) as any;
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
||||
|
||||
// DIAGNOSTIC: Set captured image early so user can see what was sent to AI
|
||||
setCapturedImage(dataUrl);
|
||||
|
||||
const result = await worker.recognize(dataUrl);
|
||||
console.log("OCR Result Size:", result.data?.text?.length);
|
||||
|
||||
const data = result.data;
|
||||
|
||||
// FALLBACK: If we have text but no word coordinates
|
||||
if (data && data.text && (!data.words || data.words.length === 0)) {
|
||||
console.log("Fallback to direct match");
|
||||
onOCRMatch(data.text);
|
||||
onOCRMatch?.(data.text);
|
||||
await worker.terminate();
|
||||
setCapturedImage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data || !data.words || data.words.length === 0) {
|
||||
console.warn("OCR produced no text or words.");
|
||||
toast.error("Label not readable. Try better light or zoom.");
|
||||
await worker.terminate();
|
||||
setCapturedImage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store words with their bounding boxes
|
||||
const words = data.words.map((w: any) => ({
|
||||
text: w.text,
|
||||
bbox: w.bbox
|
||||
@@ -192,155 +182,156 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
await worker.terminate();
|
||||
} catch (err) {
|
||||
console.error("OCR failed", err);
|
||||
toast.error("OCR selection failed");
|
||||
} finally {
|
||||
setOcrProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWordSelect = (text: string) => {
|
||||
onOCRMatch(text);
|
||||
// Reset selection state
|
||||
onOCRMatch?.(text);
|
||||
setIsSelecting(false);
|
||||
setCapturedImage(null);
|
||||
setDetectedWords([]);
|
||||
setCountdown(4); // Restart countdown
|
||||
};
|
||||
|
||||
const cn = (...classes: any[]) => classes.filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-[340px] mx-auto overflow-hidden rounded-[2.5rem] shadow-2xl bg-black border-[3px] border-slate-800 shadow-blue-500/10">
|
||||
<div className="absolute inset-0 z-10 pointer-events-none flex items-center justify-center">
|
||||
<div className="w-[280px] h-[280px] border-2 border-primary/50 rounded-3xl relative">
|
||||
<div className="absolute top-0 left-0 w-8 h-8 border-t-4 border-l-4 border-primary rounded-tl-xl" />
|
||||
<div className="absolute top-0 right-0 w-8 h-8 border-t-4 border-r-4 border-primary rounded-tr-xl" />
|
||||
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-4 border-l-4 border-primary rounded-bl-xl" />
|
||||
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-4 border-r-4 border-primary rounded-br-xl" />
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-primary/50 shadow-[0_0_15px_rgba(59,130,246,0.8)] animate-scan-fast" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id={scannerId} className="w-full aspect-square bg-slate-900" />
|
||||
|
||||
<div className="absolute top-4 right-4 z-30 flex gap-2">
|
||||
<button
|
||||
onClick={() => setIsOCRMode(!isOCRMode)}
|
||||
className={cn(
|
||||
"p-2 rounded-xl border backdrop-blur-md transition-all",
|
||||
isOCRMode ? "bg-primary border-primary text-white shadow-lg" : "bg-slate-900/50 border-slate-700 text-slate-300"
|
||||
)}
|
||||
>
|
||||
<Type size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Selection UI */}
|
||||
{isSelecting && capturedImage && (
|
||||
<div className="absolute inset-0 z-50 bg-slate-950 flex flex-col">
|
||||
<div className="relative flex-1 bg-black flex items-center justify-center overflow-hidden">
|
||||
<img src={capturedImage} className="max-w-full max-h-full object-contain" id="ocr-canvas-preview" />
|
||||
{/* Render Bounding Boxes */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="relative" style={{ width: '100%', height: '100%' }}>
|
||||
{detectedWords.map((w, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handleWordSelect(w.text)}
|
||||
className="absolute border border-primary bg-primary/20 rounded-sm active:bg-primary/50 transition-colors pointer-events-auto"
|
||||
style={{
|
||||
left: `${(w.bbox.x0 / 1600) * 100}%`,
|
||||
top: `${(w.bbox.y0 / (1600 * (9/16))) * 100}%`, // Assuming 16:9 ratio, this may need adjustment based on real canvas aspect
|
||||
width: `${((w.bbox.x1 - w.bbox.x0) / 1600) * 100}%`,
|
||||
height: `${((w.bbox.y1 - w.bbox.y0) / (1600 * (9/16))) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 bg-slate-900 border-t border-slate-800 flex flex-col gap-4">
|
||||
<p className="text-sm font-bold text-center text-primary">Tap the correct text on the label</p>
|
||||
<button
|
||||
onClick={() => { setIsSelecting(false); setCapturedImage(null); }}
|
||||
className="w-full py-4 bg-slate-800 text-white rounded-2xl font-bold text-xs"
|
||||
>
|
||||
Cancel & Rescan
|
||||
</button>
|
||||
<div className="w-full max-w-md mx-auto flex flex-col gap-6">
|
||||
{/* Video Viewport Area */}
|
||||
<div className="relative w-full aspect-square overflow-hidden rounded-[2.5rem] shadow-2xl bg-black border-[3px] border-slate-800 shadow-blue-500/10">
|
||||
<div className="absolute inset-0 z-10 pointer-events-none flex items-center justify-center">
|
||||
<div className="w-[320px] h-[320px] border-2 border-primary/50 rounded-3xl relative">
|
||||
<div className="absolute top-0 left-0 w-8 h-8 border-t-4 border-l-4 border-primary rounded-tl-xl" />
|
||||
<div className="absolute top-0 right-0 w-8 h-8 border-t-4 border-r-4 border-primary rounded-tr-xl" />
|
||||
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-4 border-l-4 border-primary rounded-bl-xl" />
|
||||
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-4 border-r-4 border-primary rounded-br-xl" />
|
||||
{isStarted && !paused && !isSelecting && (
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-primary/50 shadow-[0_0_15px_rgba(59,130,246,0.8)] animate-scan-fast" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOCRMode && (
|
||||
<div className="absolute inset-0 z-20 pointer-events-none border-2 border-primary/30 rounded-[2rem] animate-pulse" />
|
||||
)}
|
||||
<div id={scannerId} className="w-full aspect-square bg-slate-900" />
|
||||
|
||||
{!isStarted && !error && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-slate-900 text-slate-300 gap-4">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-primary" />
|
||||
<p className="text-sm font-medium">Initializing camera...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-slate-900 text-slate-300 px-8 text-center gap-4">
|
||||
<XCircle className="w-10 h-10 text-red-500" />
|
||||
<div>
|
||||
<p className="font-bold text-white">Camera Error</p>
|
||||
<p className="text-xs text-slate-400 mt-1">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 px-6 py-2 bg-slate-800 rounded-full text-sm font-bold hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-6 left-0 right-0 z-30 flex flex-col items-center gap-4 px-6">
|
||||
{hasZoom && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
// Cycle through: 1x -> 2x -> Max/2 -> Max
|
||||
let nextZoom = 1;
|
||||
if (zoom === 1) nextZoom = Math.min(2, maxZoom);
|
||||
else if (zoom < maxZoom / 2) nextZoom = Math.floor(maxZoom / 2);
|
||||
else if (zoom < maxZoom) nextZoom = maxZoom;
|
||||
else nextZoom = 1;
|
||||
|
||||
const video = document.querySelector(`#${scannerId} video`) as HTMLVideoElement;
|
||||
const track = (video?.srcObject as MediaStream)?.getVideoTracks()[0];
|
||||
if (track) {
|
||||
await track.applyConstraints({ advanced: [{ zoom: nextZoom }] as any });
|
||||
setZoom(nextZoom);
|
||||
}
|
||||
}}
|
||||
className="bg-slate-900/90 backdrop-blur-md border border-slate-700 text-white w-14 h-14 rounded-full flex flex-col items-center justify-center shadow-2xl active:scale-95 transition-transform"
|
||||
>
|
||||
<span className="text-xs font-black">{zoom.toFixed(1)}x</span>
|
||||
<span className="text-xs text-primary font-bold">Zoom</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isOCRMode ? (
|
||||
<button
|
||||
onClick={handleOCR}
|
||||
disabled={ocrProcessing}
|
||||
className={cn(
|
||||
"w-full py-4 rounded-2xl font-bold text-sm flex items-center justify-center gap-3 shadow-2xl transition-all active:scale-95 border border-white/10",
|
||||
ocrProcessing ? "bg-slate-800 text-slate-500" : "bg-primary text-white"
|
||||
)}
|
||||
>
|
||||
{ocrProcessing ? <RefreshCw className="animate-spin" size={18} /> : <Search size={18} />}
|
||||
{ocrProcessing ? "Analyzing..." : "Find by Label (OCR)"}
|
||||
</button>
|
||||
) : (
|
||||
<div className="bg-black/40 backdrop-blur-sm py-2 px-6 rounded-full border border-white/5">
|
||||
<p className="text-xs text-center text-slate-300 font-bold">
|
||||
Center barcode for auto-scan
|
||||
</p>
|
||||
{/* Selection UI */}
|
||||
{isSelecting && capturedImage && (
|
||||
<div className="absolute inset-0 z-50 bg-slate-950 flex flex-col">
|
||||
<div className="relative flex-1 bg-black flex items-center justify-center overflow-hidden">
|
||||
<img src={capturedImage} className="max-w-full max-h-full object-contain" id="ocr-canvas-preview" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="relative" style={{ width: '100%', height: '100%' }}>
|
||||
{detectedWords.map((w, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handleWordSelect(w.text)}
|
||||
className="absolute border border-primary bg-primary/20 rounded-sm active:bg-primary/50 transition-colors pointer-events-auto"
|
||||
style={{
|
||||
left: `${(w.bbox.x0 / 1600) * 100}%`,
|
||||
top: `${(w.bbox.y0 / (1600 * (9/16))) * 100}%`,
|
||||
width: `${((w.bbox.x1 - w.bbox.x0) / 1600) * 100}%`,
|
||||
height: `${((w.bbox.y1 - w.bbox.y0) / (1600 * (9/16))) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 bg-slate-900 border-t border-slate-800 flex flex-col gap-4">
|
||||
<p className="text-sm font-bold text-center text-primary">Tap the correct text on the label</p>
|
||||
<button
|
||||
onClick={() => { setIsSelecting(false); setCapturedImage(null); setCountdown(4); }}
|
||||
className="w-full py-4 bg-slate-800 text-white rounded-2xl font-bold text-xs"
|
||||
>
|
||||
Cancel & rescans
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isStarted && !error && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-slate-900 text-slate-300 gap-4">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-primary" />
|
||||
<p className="text-sm font-medium">Initializing camera...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-slate-900 text-slate-300 px-8 text-center gap-4">
|
||||
<XCircle className="w-10 h-10 text-red-500" />
|
||||
<div>
|
||||
<p className="font-bold text-white">Camera Error</p>
|
||||
<p className="text-xs text-slate-400 mt-1">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 px-6 py-2 bg-slate-800 rounded-full text-sm font-bold hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* External Controls Area */}
|
||||
<div className="flex flex-col gap-4 bg-slate-900/40 p-6 rounded-[2rem] border border-slate-800/50">
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
{hasZoom && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
let nextZoom = 1;
|
||||
if (zoom === 1) nextZoom = Math.min(2, maxZoom);
|
||||
else if (zoom < maxZoom / 2) nextZoom = Math.floor(maxZoom / 2);
|
||||
else if (zoom < maxZoom) nextZoom = maxZoom;
|
||||
else nextZoom = 1;
|
||||
|
||||
const video = document.querySelector(`#${scannerId} video`) as HTMLVideoElement;
|
||||
const track = (video?.srcObject as MediaStream)?.getVideoTracks()[0];
|
||||
if (track) {
|
||||
await track.applyConstraints({ advanced: [{ zoom: nextZoom }] as any });
|
||||
setZoom(nextZoom);
|
||||
}
|
||||
}}
|
||||
className="h-16 px-6 bg-slate-800 hover:bg-slate-700 border border-slate-700 text-white rounded-2xl flex flex-col items-center justify-center shadow-lg transition-all active:scale-95"
|
||||
>
|
||||
<span className="text-xs font-black">{zoom.toFixed(1)}x</span>
|
||||
<span className="text-[10px] text-primary font-bold uppercase">Zoom</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1 h-16 bg-slate-800/50 border border-slate-700 rounded-2xl flex items-center justify-center gap-3 px-4 relative overflow-hidden">
|
||||
{ocrProcessing ? (
|
||||
<>
|
||||
<RefreshCw className="animate-spin text-primary" size={20} />
|
||||
<span className="text-sm font-bold text-slate-200">Analyzing labels...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className={cn("text-slate-500", !isStarted && "opacity-20")} size={20} />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-slate-500 font-bold leading-none uppercase">Label Scanning</span>
|
||||
<span className="text-sm font-black text-primary leading-tight">
|
||||
{countdown === 0 ? "Scanning..." : `Next scan in ${countdown}s`}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Visual Progress Bar */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 h-1 bg-primary/30 transition-all duration-1000 ease-linear"
|
||||
style={{ width: `${((4 - countdown) / 4) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex justify-center items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
<p className="text-[10px] text-slate-500 font-bold">
|
||||
Barcode auto-scan active
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user