deckhearth/src/components/CameraScanner.tsx

319 lines
10 KiB
TypeScript
Raw Normal View History

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