248 lines
7.6 KiB
TypeScript
248 lines
7.6 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode';
|
|
import CameraView from './CameraView';
|
|
|
|
interface ScannerProps {
|
|
onScanSuccess: (decodedText: string) => void;
|
|
onOCRMatch?: (text: string) => void;
|
|
paused?: boolean;
|
|
}
|
|
|
|
export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerProps) {
|
|
const html5QrCodeRef = useRef<Html5Qrcode | null>(null);
|
|
const [isStarted, setIsStarted] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
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);
|
|
const [detectedWords, setDetectedWords] = useState<{ text: string, bbox: { x0: number, y0: number, x1: number, y1: number } }[]>([]);
|
|
const [isSelecting, setIsSelecting] = useState(false);
|
|
const [capturedImage, setCapturedImage] = useState<string | null>(null);
|
|
const isBusy = useRef(false);
|
|
const isTransitioning = useRef(false); // Flag to track start/stop transitions
|
|
const scannerId = "reader-container-unique";
|
|
|
|
useEffect(() => {
|
|
if (!html5QrCodeRef.current) {
|
|
html5QrCodeRef.current = new Html5Qrcode(scannerId);
|
|
}
|
|
|
|
const startScanner = async () => {
|
|
if (isBusy.current || isTransitioning.current) return;
|
|
isBusy.current = true;
|
|
isTransitioning.current = true;
|
|
|
|
try {
|
|
// If already scanning, we MUST stop it first
|
|
if (html5QrCodeRef.current?.isScanning) {
|
|
try {
|
|
await html5QrCodeRef.current.stop();
|
|
} catch (e) {
|
|
console.warn("Graceful stop failed, might be in transition:", e);
|
|
// If it's already in transition, we just return and wait for the next effect trigger
|
|
return;
|
|
}
|
|
}
|
|
|
|
const config = {
|
|
fps: 15,
|
|
qrbox: { width: 320, height: 320 },
|
|
aspectRatio: 1.0,
|
|
formatsToSupport: [
|
|
Html5QrcodeSupportedFormats.QR_CODE,
|
|
Html5QrcodeSupportedFormats.CODE_128,
|
|
Html5QrcodeSupportedFormats.CODE_39,
|
|
Html5QrcodeSupportedFormats.EAN_13,
|
|
Html5QrcodeSupportedFormats.UPC_A,
|
|
Html5QrcodeSupportedFormats.DATA_MATRIX
|
|
],
|
|
videoConstraints: {
|
|
facingMode: "environment"
|
|
}
|
|
};
|
|
|
|
await html5QrCodeRef.current?.start(
|
|
{ facingMode: "environment" },
|
|
config,
|
|
(decodedText) => {
|
|
onScanSuccess(decodedText);
|
|
},
|
|
() => {} // Ignore frame errors
|
|
);
|
|
|
|
const video = document.querySelector(`#${scannerId} video`) as HTMLVideoElement;
|
|
const track = (video?.srcObject as MediaStream)?.getVideoTracks()[0];
|
|
const caps = track?.getCapabilities() as any;
|
|
if (track && caps?.zoom) {
|
|
setHasZoom(true);
|
|
setMaxZoom(caps.zoom.max || 3);
|
|
}
|
|
|
|
setIsStarted(true);
|
|
setError(null);
|
|
} catch (err: any) {
|
|
const errorMsg = String(err);
|
|
if (!errorMsg.includes("is already starting") && !errorMsg.includes("already under transition")) {
|
|
console.error("Scanner failed", err);
|
|
setError(errorMsg || "Failed to access camera");
|
|
setIsStarted(false);
|
|
}
|
|
} finally {
|
|
isBusy.current = false;
|
|
isTransitioning.current = false;
|
|
}
|
|
};
|
|
|
|
if (!paused) {
|
|
startScanner();
|
|
}
|
|
|
|
return () => {
|
|
if (html5QrCodeRef.current?.isScanning) {
|
|
isTransitioning.current = true;
|
|
html5QrCodeRef.current.stop()
|
|
.then(() => {
|
|
setIsStarted(false);
|
|
})
|
|
.catch(e => console.error("Unmount stop failed", e))
|
|
.finally(() => {
|
|
isTransitioning.current = false;
|
|
});
|
|
}
|
|
};
|
|
}, [paused, onScanSuccess]);
|
|
|
|
// Automated OCR Countdown Timer
|
|
useEffect(() => {
|
|
if (!isStarted || paused || isSelecting || ocrProcessing) {
|
|
return;
|
|
}
|
|
|
|
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;
|
|
setOcrProcessing(true);
|
|
try {
|
|
const video = document.querySelector(`#${scannerId} video`) as HTMLVideoElement;
|
|
if (!video) return;
|
|
|
|
const canvas = document.createElement('canvas');
|
|
const vWidth = video.videoWidth || 1280;
|
|
const vHeight = video.videoHeight || 720;
|
|
|
|
const cropFactor = 0.6;
|
|
const sw = vWidth * cropFactor;
|
|
const sh = vHeight * cropFactor;
|
|
const sx = (vWidth - sw) / 2;
|
|
const sy = (vHeight - sh) / 2;
|
|
|
|
canvas.width = 1200;
|
|
canvas.height = (sh / sw) * 1200;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
if (ctx) {
|
|
ctx.filter = 'grayscale(100%) contrast(180%) brightness(105%)';
|
|
ctx.drawImage(video, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
|
|
}
|
|
|
|
// Lazy-load tesseract.js only when OCR is actually needed
|
|
const { createWorker } = await import('tesseract.js');
|
|
const workerPromise = createWorker('eng');
|
|
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("OCR Engine timeout")), 20000));
|
|
|
|
const worker = await Promise.race([workerPromise, timeoutPromise]) as any;
|
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
|
|
|
setCapturedImage(dataUrl);
|
|
|
|
const result = await worker.recognize(dataUrl);
|
|
const data = result.data;
|
|
|
|
if (data && data.text && (!data.words || data.words.length === 0)) {
|
|
onOCRMatch?.(data.text);
|
|
await worker.terminate();
|
|
setCapturedImage(null);
|
|
return;
|
|
}
|
|
|
|
if (!data || !data.words || data.words.length === 0) {
|
|
await worker.terminate();
|
|
setCapturedImage(null);
|
|
return;
|
|
}
|
|
|
|
const words = data.words.map((w: any) => ({
|
|
text: w.text,
|
|
bbox: w.bbox
|
|
}));
|
|
|
|
setDetectedWords(words);
|
|
setIsSelecting(true);
|
|
await worker.terminate();
|
|
} catch (err) {
|
|
console.error("OCR failed", err);
|
|
} finally {
|
|
setOcrProcessing(false);
|
|
}
|
|
};
|
|
|
|
const handleWordSelect = (text: string) => {
|
|
onOCRMatch?.(text);
|
|
setIsSelecting(false);
|
|
setCapturedImage(null);
|
|
setDetectedWords([]);
|
|
setCountdown(4); // Restart countdown
|
|
};
|
|
|
|
const handleCancelSelection = () => {
|
|
setIsSelecting(false);
|
|
setCapturedImage(null);
|
|
setCountdown(4);
|
|
};
|
|
|
|
const handleZoomChange = async (nextZoom: number) => {
|
|
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);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<CameraView
|
|
scannerId={scannerId}
|
|
isStarted={isStarted}
|
|
paused={paused}
|
|
error={error}
|
|
hasZoom={hasZoom}
|
|
zoom={zoom}
|
|
maxZoom={maxZoom}
|
|
onZoomChange={handleZoomChange}
|
|
countdown={countdown}
|
|
ocrProcessing={ocrProcessing}
|
|
isSelecting={isSelecting}
|
|
capturedImage={capturedImage}
|
|
detectedWords={detectedWords}
|
|
onWordSelect={handleWordSelect}
|
|
onCancelSelection={handleCancelSelection}
|
|
/>
|
|
);
|
|
}
|