2025-07-21 14:53:06 -04:00
|
|
|
|
import React, { useState, useRef, useEffect } from 'react';
|
|
|
|
|
|
import Tesseract from 'tesseract.js';
|
|
|
|
|
|
|
|
|
|
|
|
interface CameraScannerProps {
|
|
|
|
|
|
onCardScanned: (cardData: any) => void;
|
|
|
|
|
|
onError: (error: string) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface ScanResult {
|
|
|
|
|
|
text: string;
|
|
|
|
|
|
confidence: number;
|
|
|
|
|
|
cardName?: string;
|
|
|
|
|
|
setName?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const CameraScanner: React.FC<CameraScannerProps> = ({ onCardScanned, onError }) => {
|
|
|
|
|
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
|
|
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
|
|
|
|
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
|
|
|
|
|
const [capturedImage, setCapturedImage] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
|
|
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
|
|
const streamRef = useRef<MediaStream | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
// Start camera stream
|
|
|
|
|
|
const startCamera = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
|
|
|
|
video: {
|
|
|
|
|
|
facingMode: 'environment', // Use back camera on mobile
|
|
|
|
|
|
width: { ideal: 1920 },
|
|
|
|
|
|
height: { ideal: 1080 }
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (videoRef.current) {
|
|
|
|
|
|
videoRef.current.srcObject = stream;
|
|
|
|
|
|
streamRef.current = stream;
|
|
|
|
|
|
setIsStreaming(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Camera access error:', err);
|
|
|
|
|
|
onError('Unable to access camera. Please check permissions.');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Stop camera stream
|
|
|
|
|
|
const stopCamera = () => {
|
|
|
|
|
|
if (streamRef.current) {
|
|
|
|
|
|
streamRef.current.getTracks().forEach(track => track.stop());
|
|
|
|
|
|
streamRef.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
setIsStreaming(false);
|
|
|
|
|
|
setCapturedImage(null);
|
|
|
|
|
|
setScanResult(null);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Capture image from video stream
|
|
|
|
|
|
const captureImage = () => {
|
|
|
|
|
|
if (!videoRef.current || !canvasRef.current) return;
|
|
|
|
|
|
|
|
|
|
|
|
const video = videoRef.current;
|
|
|
|
|
|
const canvas = canvasRef.current;
|
|
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
|
|
|
|
|
|
|
|
// Set canvas dimensions to match video
|
|
|
|
|
|
canvas.width = video.videoWidth;
|
|
|
|
|
|
canvas.height = video.videoHeight;
|
|
|
|
|
|
|
|
|
|
|
|
// Draw current video frame to canvas
|
|
|
|
|
|
ctx?.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
|
|
|
|
|
|
|
|
|
|
// Get image data URL
|
|
|
|
|
|
const imageDataUrl = canvas.toDataURL('image/jpeg', 0.8);
|
|
|
|
|
|
setCapturedImage(imageDataUrl);
|
|
|
|
|
|
|
|
|
|
|
|
// Process with OCR
|
|
|
|
|
|
processImage(imageDataUrl);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Process image with Tesseract OCR
|
|
|
|
|
|
const processImage = async (imageData: string) => {
|
|
|
|
|
|
setIsProcessing(true);
|
|
|
|
|
|
setScanResult(null);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { data: { text, confidence } } = await Tesseract.recognize(imageData, 'eng', {
|
|
|
|
|
|
logger: m => console.log(m) // Optional: log progress
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
console.log('OCR Result:', text);
|
|
|
|
|
|
console.log('Confidence:', confidence);
|
|
|
|
|
|
|
|
|
|
|
|
// Extract card information from OCR text
|
|
|
|
|
|
const cardInfo = extractCardInfo(text);
|
|
|
|
|
|
|
|
|
|
|
|
const result: ScanResult = {
|
|
|
|
|
|
text: text.trim(),
|
|
|
|
|
|
confidence,
|
|
|
|
|
|
cardName: cardInfo.name,
|
|
|
|
|
|
setName: cardInfo.set
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
setScanResult(result);
|
|
|
|
|
|
|
|
|
|
|
|
// If we found card info, try to match against database
|
|
|
|
|
|
if (cardInfo.name) {
|
|
|
|
|
|
onCardScanned({
|
|
|
|
|
|
name: cardInfo.name,
|
|
|
|
|
|
set: cardInfo.set,
|
|
|
|
|
|
ocrText: text,
|
|
|
|
|
|
confidence: confidence
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('OCR processing error:', error);
|
|
|
|
|
|
onError('Failed to process image. Please try again.');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsProcessing(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Extract card name and set from OCR text
|
|
|
|
|
|
const extractCardInfo = (text: string) => {
|
|
|
|
|
|
const lines = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
|
|
|
|
|
|
|
|
|
|
|
|
// Common patterns for different card games
|
|
|
|
|
|
const patterns = {
|
|
|
|
|
|
// Magic: The Gathering patterns
|
|
|
|
|
|
mtg: {
|
|
|
|
|
|
cardName: /^[A-Z][a-zA-Z\s,'-]+(?=\s|$)/,
|
|
|
|
|
|
setInfo: /\b([A-Z]{3}|[A-Z]{4})\b/, // 3-4 letter set codes
|
|
|
|
|
|
},
|
|
|
|
|
|
// Pokemon patterns
|
|
|
|
|
|
pokemon: {
|
|
|
|
|
|
cardName: /^[A-Z][a-zA-Z\s]+(?=\s+[0-9])/,
|
|
|
|
|
|
hpPattern: /HP\s*[0-9]+/,
|
|
|
|
|
|
},
|
|
|
|
|
|
// Lorcana patterns
|
|
|
|
|
|
lorcana: {
|
|
|
|
|
|
cardName: /^[A-Z][a-zA-Z\s,'-]+(?=\s*-)/,
|
|
|
|
|
|
subtitle: /-\s*([A-Z][a-zA-Z\s]+)/,
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let cardName = '';
|
|
|
|
|
|
let setName = '';
|
|
|
|
|
|
|
|
|
|
|
|
// Try to find the card name (usually the first substantial line)
|
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
|
if (line.length > 3 && line.length < 50) { // Reasonable card name length
|
|
|
|
|
|
// Skip common non-name text
|
|
|
|
|
|
if (!line.match(/^(hp|©|legendary|instant|sorcery|creature|artifact|enchantment)$/i)) {
|
|
|
|
|
|
if (!cardName || line.length > cardName.length) {
|
|
|
|
|
|
cardName = line;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Try to extract set information
|
|
|
|
|
|
const setText = lines.find(line => patterns.mtg.setInfo.test(line));
|
|
|
|
|
|
if (setText) {
|
|
|
|
|
|
const setMatch = setText.match(patterns.mtg.setInfo);
|
|
|
|
|
|
if (setMatch) {
|
|
|
|
|
|
setName = setMatch[1];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
name: cardName || '',
|
|
|
|
|
|
set: setName || ''
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Cleanup on unmount
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
stopCamera();
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="camera-scanner">
|
|
|
|
|
|
{/* Camera Controls */}
|
|
|
|
|
|
<div className="flex gap-4 mb-4">
|
|
|
|
|
|
{!isStreaming ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={startCamera}
|
2025-07-21 22:06:38 -04:00
|
|
|
|
className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-lg font-medium transition-colors flex items-center gap-2"
|
2025-07-21 14:53:06 -04:00
|
|
|
|
>
|
|
|
|
|
|
<span>📹</span> Start Camera
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={captureImage}
|
|
|
|
|
|
disabled={isProcessing}
|
|
|
|
|
|
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white px-6 py-3 rounded-lg font-medium transition-colors flex items-center gap-2"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>📸</span>
|
|
|
|
|
|
{isProcessing ? 'Processing...' : 'Capture Card'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={stopCamera}
|
|
|
|
|
|
className="bg-red-600 hover:bg-red-700 text-white px-6 py-3 rounded-lg font-medium transition-colors flex items-center gap-2"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>⏹️</span> Stop Camera
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Camera Preview */}
|
|
|
|
|
|
{isStreaming && (
|
|
|
|
|
|
<div className="relative bg-black rounded-lg overflow-hidden mb-4">
|
|
|
|
|
|
<video
|
|
|
|
|
|
ref={videoRef}
|
|
|
|
|
|
autoPlay
|
|
|
|
|
|
playsInline
|
|
|
|
|
|
muted
|
|
|
|
|
|
className="w-full h-auto max-h-96 object-cover"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Scan Guide Overlay */}
|
|
|
|
|
|
<div className="absolute inset-0 pointer-events-none">
|
|
|
|
|
|
<div className="absolute inset-4 border-2 border-white border-dashed rounded-lg flex items-center justify-center">
|
|
|
|
|
|
<div className="bg-black bg-opacity-50 text-white px-4 py-2 rounded-lg text-sm text-center">
|
|
|
|
|
|
<div className="font-medium">Position card within this area</div>
|
|
|
|
|
|
<div className="text-xs opacity-75">Ensure good lighting and focus</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Hidden canvas for image capture */}
|
|
|
|
|
|
<canvas ref={canvasRef} className="hidden" />
|
|
|
|
|
|
|
|
|
|
|
|
{/* Processing Status */}
|
|
|
|
|
|
{isProcessing && (
|
|
|
|
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="font-medium text-blue-900">Processing image...</div>
|
|
|
|
|
|
<div className="text-sm text-blue-600">Extracting card information</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Captured Image Preview */}
|
|
|
|
|
|
{capturedImage && !isProcessing && (
|
|
|
|
|
|
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-4">
|
|
|
|
|
|
<div className="font-medium text-gray-900 mb-2">Captured Image</div>
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={capturedImage}
|
|
|
|
|
|
alt="Captured card"
|
|
|
|
|
|
className="max-w-full h-auto max-h-48 rounded border border-gray-300"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* OCR Results */}
|
|
|
|
|
|
{scanResult && (
|
|
|
|
|
|
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
|
|
|
|
<div className="font-medium text-gray-900 mb-2">Scan Results</div>
|
|
|
|
|
|
|
|
|
|
|
|
{scanResult.cardName ? (
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<span className="text-green-600">✅</span>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="font-medium">Card Found: {scanResult.cardName}</div>
|
|
|
|
|
|
{scanResult.setName && (
|
|
|
|
|
|
<div className="text-sm text-gray-600">Set: {scanResult.setName}</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-sm text-gray-500">
|
|
|
|
|
|
Confidence: {Math.round(scanResult.confidence)}%
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<span className="text-yellow-600">⚠️</span>
|
|
|
|
|
|
<div className="font-medium">Card not recognized</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<details className="text-sm text-gray-600">
|
|
|
|
|
|
<summary className="cursor-pointer">View OCR text</summary>
|
|
|
|
|
|
<pre className="mt-2 whitespace-pre-wrap bg-gray-50 p-2 rounded text-xs">
|
|
|
|
|
|
{scanResult.text}
|
|
|
|
|
|
</pre>
|
|
|
|
|
|
</details>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Instructions */}
|
|
|
|
|
|
<div className="bg-gray-50 rounded-lg p-4 mt-4">
|
|
|
|
|
|
<div className="font-medium text-gray-900 mb-2">📋 Scanning Tips</div>
|
|
|
|
|
|
<ul className="text-sm text-gray-600 space-y-1">
|
|
|
|
|
|
<li>• Ensure good lighting and avoid shadows</li>
|
|
|
|
|
|
<li>• Keep the card flat and in focus</li>
|
|
|
|
|
|
<li>• Position the card name clearly in view</li>
|
|
|
|
|
|
<li>• Avoid glare and reflections</li>
|
|
|
|
|
|
<li>• Works best with English cards</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default CameraScanner;
|