deckhearth/components/CameraScanner.js
Randall Stillwell afb79c57d9 Major Scanner Improvements
🔧 Gemini AI Integration:
- Added Google Gemini API as default OCR service
- Auto-configures from GEMINI_AI_API_KEY environment variable
- Fixed Puter.js authentication issues
- Enhanced OCR settings with connection testing

🎨 Redesigned Scanner Queue:
- New thumbnail + content layout with checkbox overlay
- Smart quantity management (duplicates increment quantity)
- Complete card information display from database
- Two-row action layout (primary/secondary actions)
- Floating bottom toolbar for bulk actions
- Real card images from database

�� Enhanced User Experience:
- Fixed Canvas2D performance warnings
- Better error handling and fallbacks
- Improved responsive design
- Database confirmation indicators
- Professional card scanning workflow

📱 Mobile Ready:
- Optimized layouts for mobile scanning
- Touch-friendly controls and interactions
- Improved visual feedback and status indicators
2025-07-29 14:19:48 -05:00

988 lines
No EOL
37 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef } from 'react';
import { aiCardOCR, ollamaCardOCR, puterCardOCR, geminiCardOCR } from '../lib/ai-ocr';
export default function CameraScanner({ onCardScanned, onError }) {
const [isStreaming, setIsStreaming] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [scanResult, setScanResult] = useState(null);
const [capturedImage, setCapturedImage] = useState(null);
const [isAutoScanning, setIsAutoScanning] = useState(true);
const [detectedCard, setDetectedCard] = useState(null);
const [scanningAnimation, setScanningAnimation] = useState(false);
const [toast, setToast] = useState(null);
const [isDetecting, setIsDetecting] = useState(false);
const [ocrSettings, setOcrSettings] = useState({
service: 'gemini', // Default to Gemini
openaiApiKey: '',
geminiApiKey: '',
ollamaUrl: 'http://localhost:11434'
});
const videoRef = useRef(null);
const canvasRef = useRef(null);
const detectionCanvasRef = useRef(null); // Separate canvas for computer vision
const streamRef = useRef(null);
const autoScanIntervalRef = useRef(null);
const detectionIntervalRef = useRef(null);
const lastScanTimeRef = useRef(0);
const lastDetectionTimeRef = useRef(0);
const detectionHistoryRef = useRef([]); // Track detection stability
const stableDetectionRef = useRef(null); // Current stable detection
// Load OCR settings from localStorage
useEffect(() => {
const loadOcrSettings = async () => {
let settings = {
service: 'gemini', // Default to Gemini
openaiApiKey: '',
geminiApiKey: '',
ollamaUrl: 'http://localhost:11434'
};
// Load saved settings
const savedSettings = localStorage.getItem('ocrSettings');
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings);
settings = { ...settings, ...parsed };
} catch (error) {
console.error('Failed to load OCR settings:', error);
}
}
// Try to auto-load Gemini API key from environment if not already set
if (!settings.geminiApiKey) {
try {
const response = await fetch('/api/config/gemini');
if (response.ok) {
const data = await response.json();
if (data.hasKey && data.apiKey) {
settings.geminiApiKey = data.apiKey;
settings.service = 'gemini'; // Ensure Gemini is selected
console.log('✅ Auto-configured Gemini API key from environment');
}
}
} catch (error) {
console.log('Could not auto-load Gemini API key:', error);
}
}
setOcrSettings(settings);
// Configure AI services
if (settings.openaiApiKey) {
aiCardOCR.setApiKey(settings.openaiApiKey);
}
if (settings.geminiApiKey) {
geminiCardOCR.setApiKey(settings.geminiApiKey);
}
if (settings.ollamaUrl) {
ollamaCardOCR.setBaseUrl(settings.ollamaUrl);
}
};
loadOcrSettings();
}, []);
// Configure canvas contexts for optimal performance
useEffect(() => {
if (canvasRef.current) {
const ctx = canvasRef.current.getContext('2d', { willReadFrequently: true });
}
if (detectionCanvasRef.current) {
const ctx = detectionCanvasRef.current.getContext('2d', { willReadFrequently: true });
}
}, []);
// Computer vision-based card detection (fast, no API calls)
const detectCardWithComputerVision = () => {
if (!videoRef.current || !detectionCanvasRef.current) return null;
const video = videoRef.current;
const canvas = detectionCanvasRef.current;
const ctx = canvas.getContext('2d');
// Set canvas size for detection (smaller for performance)
canvas.width = 320;
canvas.height = 240;
// Draw current video frame
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// Get image data for analysis
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// Card detection algorithm using computer vision
const cardDetection = analyzeImageForCard(data, canvas.width, canvas.height);
return cardDetection;
};
// Computer vision algorithm to detect rectangular card-like objects
const analyzeImageForCard = (imageData, width, height) => {
// Convert to grayscale and detect edges
const grayscale = [];
const edges = [];
// Convert to grayscale
for (let i = 0; i < imageData.length; i += 4) {
const gray = Math.round(0.299 * imageData[i] + 0.587 * imageData[i + 1] + 0.114 * imageData[i + 2]);
grayscale.push(gray);
}
// Enhanced edge detection with stronger thresholds
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const idx = y * width + x;
// Horizontal gradient
const gx = -grayscale[idx - width - 1] - 2 * grayscale[idx - 1] - grayscale[idx + width - 1] +
grayscale[idx - width + 1] + 2 * grayscale[idx + 1] + grayscale[idx + width + 1];
// Vertical gradient
const gy = -grayscale[idx - width - 1] - 2 * grayscale[idx - width] - grayscale[idx - width + 1] +
grayscale[idx + width - 1] + 2 * grayscale[idx + width] + grayscale[idx + width + 1];
// Edge magnitude
const magnitude = Math.sqrt(gx * gx + gy * gy);
edges[idx] = magnitude > 80 ? 255 : 0; // Increased threshold from 50 to 80
}
}
// Look for rectangular regions with stricter criteria
const cardCandidates = findRectangularRegions(edges, width, height);
// Score candidates based on card-like properties with higher standards
const bestCandidate = scoreCardCandidates(cardCandidates, width, height);
return bestCandidate;
};
// Find rectangular regions that could be cards with stricter criteria
const findRectangularRegions = (edges, width, height) => {
const candidates = [];
const minCardWidth = Math.floor(width * 0.2); // Increased from 15% to 20%
const maxCardWidth = Math.floor(width * 0.7); // Decreased from 80% to 70%
const minCardHeight = Math.floor(height * 0.25); // Increased from 20% to 25%
const maxCardHeight = Math.floor(height * 0.8); // Decreased from 90% to 80%
// Scan for edge-dense rectangular regions with larger steps for performance
for (let y = 0; y < height - minCardHeight; y += 15) { // Increased step from 10 to 15
for (let x = 0; x < width - minCardWidth; x += 15) { // Increased step from 10 to 15
for (let w = minCardWidth; w <= maxCardWidth && x + w < width; w += 25) { // Increased step from 20 to 25
for (let h = minCardHeight; h <= maxCardHeight && y + h < height; h += 25) { // Increased step from 20 to 25
// Stricter card-like aspect ratio check
const aspectRatio = w / h;
if (aspectRatio < 0.65 || aspectRatio > 0.77) continue; // Narrowed from 0.6-0.8 to 0.65-0.77
// Count edges in this region with more selective sampling
let edgeCount = 0;
let totalPixels = 0;
let cornerEdges = 0; // Count edges near corners (cards have defined corners)
// Sample the region (not every pixel for performance)
for (let sy = y; sy < y + h; sy += 4) { // Increased step from 3 to 4
for (let sx = x; sx < x + w; sx += 4) { // Increased step from 3 to 4
const idx = sy * width + sx;
if (edges[idx] === 255) {
edgeCount++;
// Check if this edge is near a corner (cards have distinct corners)
const isNearCorner = (
(sx < x + w * 0.2 && sy < y + h * 0.2) || // Top-left
(sx > x + w * 0.8 && sy < y + h * 0.2) || // Top-right
(sx < x + w * 0.2 && sy > y + h * 0.8) || // Bottom-left
(sx > x + w * 0.8 && sy > y + h * 0.8) // Bottom-right
);
if (isNearCorner) cornerEdges++;
}
totalPixels++;
}
}
const edgeDensity = edgeCount / totalPixels;
const cornerEdgeDensity = cornerEdges / (edgeCount || 1);
// Stricter criteria: cards should have moderate edge density AND corner definition
if (edgeDensity > 0.08 && edgeDensity < 0.25 && cornerEdgeDensity > 0.1) { // Increased min from 0.05 to 0.08, added corner requirement
// Additional check: look for rectangular perimeter (cards have clear borders)
const perimeterStrength = checkRectangularPerimeter(edges, x, y, w, h, width);
if (perimeterStrength > 0.3) { // Only accept if perimeter is well-defined
candidates.push({
x: x,
y: y,
width: w,
height: h,
edgeDensity: edgeDensity,
aspectRatio: aspectRatio,
cornerEdgeDensity: cornerEdgeDensity,
perimeterStrength: perimeterStrength
});
}
}
}
}
}
}
return candidates;
};
// Check if the region has a well-defined rectangular perimeter (like a card border)
const checkRectangularPerimeter = (edges, x, y, w, h, frameWidth) => {
let perimeterEdges = 0;
let perimeterPixels = 0;
// Check top and bottom edges
for (let sx = x; sx < x + w; sx += 2) {
// Top edge
const topIdx = y * frameWidth + sx;
if (topIdx >= 0 && topIdx < edges.length) {
if (edges[topIdx] === 255) perimeterEdges++;
perimeterPixels++;
}
// Bottom edge
const bottomIdx = (y + h - 1) * frameWidth + sx;
if (bottomIdx >= 0 && bottomIdx < edges.length) {
if (edges[bottomIdx] === 255) perimeterEdges++;
perimeterPixels++;
}
}
// Check left and right edges
for (let sy = y; sy < y + h; sy += 2) {
// Left edge
const leftIdx = sy * frameWidth + x;
if (leftIdx >= 0 && leftIdx < edges.length) {
if (edges[leftIdx] === 255) perimeterEdges++;
perimeterPixels++;
}
// Right edge
const rightIdx = sy * frameWidth + (x + w - 1);
if (rightIdx >= 0 && rightIdx < edges.length) {
if (edges[rightIdx] === 255) perimeterEdges++;
perimeterPixels++;
}
}
return perimeterPixels > 0 ? perimeterEdges / perimeterPixels : 0;
};
// Score and select the best card candidate with higher standards
const scoreCardCandidates = (candidates, frameWidth, frameHeight) => {
if (candidates.length === 0) return null;
let bestCandidate = null;
let bestScore = 0;
for (const candidate of candidates) {
let score = 0;
// Prefer candidates with good aspect ratio (closer to 0.71 - typical card ratio)
const aspectRatioScore = 1 - Math.abs(candidate.aspectRatio - 0.71);
score += aspectRatioScore * 35; // Increased weight from 30 to 35
// Prefer moderate edge density (not too sparse, not too dense)
const edgeDensityScore = Math.min(candidate.edgeDensity * 100, 25);
score += edgeDensityScore;
// Reward corner definition (cards have clear corners)
const cornerScore = candidate.cornerEdgeDensity * 25;
score += cornerScore;
// Reward strong perimeter (cards have borders)
const perimeterScore = candidate.perimeterStrength * 20;
score += perimeterScore;
// Prefer cards that are reasonably sized
const cardArea = (candidate.width / frameWidth) * (candidate.height / frameHeight);
const sizeScore = cardArea > 0.05 && cardArea < 0.4 ? 15 : 0; // Reward reasonable size
score += sizeScore;
// Prefer cards closer to center (people usually center cards when scanning)
const centerX = frameWidth / 2;
const centerY = frameHeight / 2;
const cardCenterX = candidate.x + candidate.width / 2;
const cardCenterY = candidate.y + candidate.height / 2;
const distanceFromCenter = Math.sqrt(
Math.pow(cardCenterX - centerX, 2) + Math.pow(cardCenterY - centerY, 2)
);
const maxDistance = Math.sqrt(Math.pow(centerX, 2) + Math.pow(centerY, 2));
const centerScore = (1 - distanceFromCenter / maxDistance) * 10; // Reduced weight from 15 to 10
score += centerScore;
if (score > bestScore) {
bestScore = score;
bestCandidate = candidate;
}
}
// Much higher threshold - only return candidate if it's very likely to be a card
return bestScore > 70 ? bestCandidate : null; // Increased from 40 to 70
};
// Convert canvas coordinates to video coordinates (percentage)
const convertToVideoCoordinates = (candidate) => {
if (!candidate || !detectionCanvasRef.current || !videoRef.current) return null;
const canvas = detectionCanvasRef.current;
const video = videoRef.current;
// Convert from detection canvas coordinates to video percentage
return {
x: (candidate.x / canvas.width) * 100,
y: (candidate.y / canvas.height) * 100,
width: (candidate.width / canvas.width) * 100,
height: (candidate.height / canvas.height) * 100
};
};
// Fast computer vision detection effect
useEffect(() => {
if (isStreaming && isAutoScanning && !isProcessing) {
// Run computer vision detection every 500ms (fast, no API calls)
detectionIntervalRef.current = setInterval(() => {
const candidate = detectCardWithComputerVision();
// Add to detection history for stability tracking
const now = Date.now();
detectionHistoryRef.current.push({
timestamp: now,
detected: !!candidate,
candidate: candidate
});
// Keep only last 6 detections (3 seconds of history at 500ms intervals)
detectionHistoryRef.current = detectionHistoryRef.current.filter(
detection => now - detection.timestamp < 3000
);
// Check for stable detection (at least 4 out of last 6 detections must be positive)
const recentDetections = detectionHistoryRef.current.slice(-6);
const positiveDetections = recentDetections.filter(d => d.detected).length;
const isStableDetection = recentDetections.length >= 4 && positiveDetections >= 4;
if (isStableDetection && candidate) {
// Only update if this is a new stable detection or significantly different position
const shouldUpdate = !stableDetectionRef.current ||
Math.abs(candidate.x - stableDetectionRef.current.x) > 10 ||
Math.abs(candidate.y - stableDetectionRef.current.y) > 10;
if (shouldUpdate) {
const videoCoords = convertToVideoCoordinates(candidate);
if (videoCoords) {
setDetectedCard(videoCoords);
stableDetectionRef.current = candidate;
}
}
} else if (!isStableDetection) {
// Clear detection if not stable
setDetectedCard(null);
stableDetectionRef.current = null;
}
}, 500);
// Run AI analysis only when card is detected and stable for 3 seconds
autoScanIntervalRef.current = setInterval(() => {
const now = Date.now();
if (detectedCard && now - lastScanTimeRef.current > 3000) {
processDetectedCard();
}
}, 1000);
} else {
if (detectionIntervalRef.current) {
clearInterval(detectionIntervalRef.current);
detectionIntervalRef.current = null;
}
if (autoScanIntervalRef.current) {
clearInterval(autoScanIntervalRef.current);
autoScanIntervalRef.current = null;
}
// Clear detection history when not scanning
detectionHistoryRef.current = [];
stableDetectionRef.current = null;
}
return () => {
if (detectionIntervalRef.current) {
clearInterval(detectionIntervalRef.current);
}
if (autoScanIntervalRef.current) {
clearInterval(autoScanIntervalRef.current);
}
};
}, [isStreaming, isAutoScanning, isProcessing, detectedCard]);
// Show toast notification
const showToast = (message, type = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
// Start camera stream with card aspect ratio
const startCamera = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment', // Use back camera on mobile
width: { ideal: 1280 },
height: { ideal: 720 }, // 16:9 aspect ratio, good for cards
aspectRatio: { ideal: 16/9 }
}
});
if (videoRef.current) {
videoRef.current.srcObject = stream;
streamRef.current = stream;
// Wait for video to be ready
videoRef.current.onloadedmetadata = () => {
videoRef.current?.play().then(() => {
setIsStreaming(true);
}).catch((err) => {
onError(`Video playback failed: ${err.message}`);
});
};
videoRef.current.onerror = (err) => {
onError('Video element error occurred');
};
} else {
onError('Video element not available');
}
} catch (err) {
console.error('Camera access error:', err);
onError(`Unable to access camera: ${err.message}`);
}
};
// Stop camera stream
const stopCamera = () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
if (autoScanIntervalRef.current) {
clearInterval(autoScanIntervalRef.current);
autoScanIntervalRef.current = null;
}
if (detectionIntervalRef.current) {
clearInterval(detectionIntervalRef.current);
detectionIntervalRef.current = null;
}
setIsStreaming(false);
setCapturedImage(null);
setScanResult(null);
setDetectedCard(null);
setIsDetecting(false);
// Clear detection state
detectionHistoryRef.current = [];
stableDetectionRef.current = null;
};
// Process the detected card with full AI analysis
const processDetectedCard = async () => {
if (!detectedCard || isProcessing) return;
lastScanTimeRef.current = Date.now();
setScanningAnimation(true);
// Capture image
const imageDataUrl = captureImageData();
if (imageDataUrl) {
await processImage(imageDataUrl, true); // true for auto-scan
}
setTimeout(() => setScanningAnimation(false), 2000);
};
// Capture image from video stream
const captureImageData = () => {
if (!videoRef.current || !canvasRef.current) return null;
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
return canvas.toDataURL('image/jpeg', 0.8);
};
// Manual capture for testing
const captureImage = () => {
const imageDataUrl = captureImageData();
if (imageDataUrl) {
setCapturedImage(imageDataUrl);
processImage(imageDataUrl, false);
}
};
// Process image with AI OCR (only called when computer vision detects a card)
const processImage = async (imageData, isAutoScan = false) => {
setIsProcessing(true);
setScanResult(null);
try {
let ocrResult;
console.log('🔍 Current OCR settings:', ocrSettings);
if (ocrSettings.service === 'puter') {
console.log('🎯 Using Puter.js (Free AI Vision)...');
ocrResult = await puterCardOCR.analyzeCard(imageData);
console.log('✅ Puter.js Vision result:', ocrResult);
} else if (ocrSettings.service === 'openai') {
console.log('🤖 Using OpenAI Vision API...');
ocrResult = await aiCardOCR.analyzeCard(imageData);
console.log('✅ OpenAI Vision result:', ocrResult);
} else if (ocrSettings.service === 'ollama') {
console.log('🦙 Using Ollama Vision...');
ocrResult = await ollamaCardOCR.analyzeCard(imageData);
console.log('✅ Ollama Vision result:', ocrResult);
} else if (ocrSettings.service === 'gemini') {
console.log('🤖 Using Gemini Vision API...');
ocrResult = await geminiCardOCR.analyzeCard(imageData);
console.log('✅ Gemini Vision result:', ocrResult);
} else {
throw new Error('No OCR service configured');
}
// First check: Is this actually a trading card?
if (!ocrResult.isCard) {
if (isAutoScan) {
console.log('❌ No trading card detected in auto-scan, continuing...');
setDetectedCard(null); // Remove highlight
return;
} else {
// Show error for manual scans
onError(ocrResult.reason || 'No trading card detected in image');
return;
}
}
// Second check: Do we have a card name?
if (!ocrResult.cardName || ocrResult.cardName.trim().length < 2) {
if (isAutoScan) {
console.log('❌ Card name not clear in auto-scan, continuing...');
setDetectedCard(null);
return;
} else {
onError('Could not read card name clearly. Please try again with better lighting.');
return;
}
}
// Third check: Cross-reference with database
console.log('🔍 Cross-referencing with database...');
const dbResponse = await fetch('/api/cards/find-or-create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
},
body: JSON.stringify({
name: ocrResult.cardName.trim(),
set: ocrResult.setName,
setCode: ocrResult.setCode,
cardNumber: ocrResult.cardNumber,
game: ocrResult.game,
cardType: ocrResult.cardType,
rarity: ocrResult.rarity,
hp: ocrResult.hp,
manaCost: ocrResult.manaCost,
ocrData: {
confidence: ocrResult.confidence,
rawText: ocrResult.rawText,
abilities: ocrResult.abilities,
flavorText: ocrResult.flavorText,
artist: ocrResult.artist
}
})
});
if (!dbResponse.ok) {
throw new Error(`Database lookup failed: ${dbResponse.status}`);
}
const dbResult = await dbResponse.json();
// Handle different database response scenarios
if (dbResult.needsUserSelection) {
// Multiple matches found - let user choose
if (isAutoScan) {
showToast(`⚠️ Multiple matches for "${ocrResult.cardName}" - use manual mode to select`, 'warning');
setDetectedCard(null);
return;
} else {
// Show selection modal for manual scans
setScanResult({
type: 'multipleMatches',
cardName: ocrResult.cardName,
matches: dbResult.matches,
confidence: ocrResult.confidence
});
return;
}
}
if (dbResult.needsUserInput) {
// Low confidence or unclear card
if (isAutoScan) {
console.log('❌ Low confidence match, skipping auto-scan');
setDetectedCard(null);
return;
} else {
onError(dbResult.message || 'Card not found in database and confidence is too low');
return;
}
}
// Success! We have a confirmed card match
const finalCard = dbResult.card;
const cleanCardName = finalCard.name;
setScanResult({
type: 'success',
cardName: cleanCardName,
setName: finalCard.set_name,
game: finalCard.game,
rarity: finalCard.rarity,
confidence: ocrResult.confidence,
isExisting: dbResult.isExisting,
message: dbResult.message
});
// Show success toast for auto-scan
if (isAutoScan) {
showToast(`📸 Captured: ${cleanCardName}`, 'success');
setDetectedCard(null); // Remove highlight after successful scan
}
// Send the confirmed card data to parent
onCardScanned({
name: cleanCardName,
set: finalCard.set_name,
setCode: finalCard.set_code,
cardNumber: finalCard.card_number,
game: finalCard.game,
cardType: finalCard.card_type,
rarity: finalCard.rarity,
hp: finalCard.hp || ocrResult.hp,
manaCost: finalCard.mana_cost || ocrResult.manaCost,
abilities: ocrResult.abilities,
ocrText: ocrResult.rawText,
confidence: ocrResult.confidence,
capturedImage: imageData,
image_url: finalCard.image_url, // Add the database card image
databaseId: finalCard.id,
isExisting: dbResult.isExisting
});
} catch (error) {
console.error('Card processing error:', error);
if (!isAutoScan) {
onError(`Card processing failed: ${error.message}`);
} else {
// For auto-scans, just continue silently
setDetectedCard(null);
}
} finally {
setIsProcessing(false);
}
};
// Cleanup on unmount
useEffect(() => {
return () => {
stopCamera();
};
}, []);
return (
<div className="camera-scanner">
{/* Camera Controls */}
<div className="flex gap-4 mb-4 flex-wrap">
{!isStreaming ? (
<button
onClick={startCamera}
className="px-6 py-3 rounded-xl font-medium transition-all duration-200 flex items-center gap-2 hover:opacity-90"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white'
}}
>
<span>📹</span> Start Camera
</button>
) : (
<>
<button
onClick={() => setIsAutoScanning(!isAutoScanning)}
className={`px-4 py-2 rounded-xl font-medium transition-all duration-200 flex items-center gap-2 hover:opacity-90`}
style={{
backgroundColor: isAutoScanning ? 'var(--accent-flame)' : 'var(--bg-tertiary)',
color: isAutoScanning ? 'white' : 'var(--text-primary)',
border: isAutoScanning ? 'none' : '1px solid var(--border)'
}}
>
<span>{isAutoScanning ? '🔄' : '⏸️'}</span>
{isAutoScanning ? 'Auto Scanning' : 'Manual Mode'}
</button>
<button
onClick={captureImage}
disabled={isProcessing}
className="px-4 py-2 rounded-xl font-medium transition-all duration-200 flex items-center gap-2 hover:opacity-90 disabled:opacity-50"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
border: '1px solid var(--border)'
}}
>
<span>📸</span>
Manual Capture
</button>
<button
onClick={stopCamera}
className="px-4 py-2 rounded-xl font-medium transition-all duration-200 flex items-center gap-2 hover:opacity-90"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
border: '1px solid var(--border)'
}}
>
<span></span> Stop
</button>
</>
)}
</div>
{/* Camera Preview with Card-like Aspect Ratio */}
<div
className="relative rounded-xl overflow-hidden mb-4"
style={{
backgroundColor: 'var(--bg-secondary)',
aspectRatio: '16/9', // Card-friendly aspect ratio
maxHeight: '400px'
}}
>
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full h-full object-cover"
style={{
minHeight: isStreaming ? 'auto' : '300px',
aspectRatio: '16/9'
}}
/>
{/* Card Detection Highlight - Only show when card is actually detected */}
{detectedCard && isStreaming && (
<div
className="absolute border-4 rounded-xl transition-all duration-500 ease-in-out"
style={{
borderColor: 'var(--accent-ember)',
left: `${detectedCard.x}%`,
top: `${detectedCard.y}%`,
width: `${detectedCard.width}%`,
height: `${detectedCard.height}%`,
backgroundColor: 'rgba(var(--accent-ember-rgb), 0.1)',
boxShadow: '0 0 30px rgba(var(--accent-ember-rgb), 0.6)',
borderRadius: '12px', // Card-like rounded corners
animation: 'pulse 2s infinite'
}}
>
{/* Card Detected Label */}
<div
className="absolute -top-8 left-0 px-3 py-1 rounded-lg text-sm font-medium"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white',
fontSize: '12px'
}}
>
🃏 Card Detected
</div>
{/* Scanning Animation */}
{scanningAnimation && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
</div>
)}
</div>
)}
{/* Detection Status Indicator */}
{isStreaming && (
<div className="absolute top-4 left-4 flex items-center gap-2">
<div
className={`w-3 h-3 rounded-full transition-all duration-300 ${
detectedCard ? 'animate-pulse' : ''
}`}
style={{
backgroundColor: detectedCard ? '#10b981' : '#6b7280'
}}
/>
<span className="text-sm font-medium" style={{ color: 'white', textShadow: '0 1px 2px rgba(0,0,0,0.8)' }}>
{detectedCard ? '🃏 Card Found' : '👁️ Watching'}
</span>
</div>
)}
{/* Show overlay when streaming but no card detected */}
{isStreaming && !detectedCard && (
<div className="absolute inset-0 pointer-events-none">
<div className="absolute inset-8 border-2 border-dashed rounded-xl flex items-center justify-center" style={{ borderColor: 'rgba(var(--accent-ember-rgb), 0.5)' }}>
<div className="px-6 py-4 rounded-xl text-center" style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: 'white' }}>
<div className="font-medium text-lg mb-2">🃏 Position Trading Card</div>
<div className="text-sm opacity-75">
{isAutoScanning ? 'Computer vision will detect rectangular cards' : 'Position card and tap capture'}
</div>
<div className="text-xs opacity-60 mt-1">No API calls until card is detected</div>
</div>
</div>
</div>
)}
{/* Show placeholder when not streaming */}
{!isStreaming && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center" style={{ color: 'var(--text-secondary)' }}>
<div className="text-4xl mb-2">📹</div>
<div className="font-medium">Camera Preview</div>
<div className="text-sm opacity-75">Click "Start Camera" to begin</div>
<div className="text-xs opacity-60 mt-2">AI-optimized card detection</div>
</div>
</div>
)}
</div>
{/* Toast Notification */}
{toast && (
<div
className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-xl shadow-lg transition-all duration-300 ${
toast.type === 'success' ? 'border-green-500' : toast.type === 'warning' ? 'border-yellow-500' : 'border-red-500'
}`}
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: toast.type === 'success' ? '#10b981' : toast.type === 'warning' ? '#f59e0b' : '#ef4444',
borderWidth: '2px',
color: 'var(--text-primary)'
}}
>
<div className="flex items-center gap-2">
<span>{toast.type === 'success' ? '✅' : toast.type === 'warning' ? '⚠️' : '❌'}</span>
<span className="font-medium">{toast.message}</span>
</div>
</div>
)}
{/* Hidden canvases for image processing */}
<canvas ref={canvasRef} className="hidden" />
<canvas ref={detectionCanvasRef} className="hidden" />
{/* Processing Status */}
{isProcessing && !scanningAnimation && (
<div className="rounded-xl p-4 mb-4" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2" style={{ borderColor: 'var(--accent-ember)' }}></div>
<div>
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>Processing with AI...</div>
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>Card detected by computer vision</div>
</div>
</div>
</div>
)}
{/* Manual Capture Results (only show for manual captures) */}
{scanResult && capturedImage && (
<div className="rounded-xl border p-4 mb-4" style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}>
<div className="font-medium mb-2" style={{ color: 'var(--text-primary)' }}>Manual Scan Results</div>
{scanResult.type === 'multipleMatches' ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-yellow-600"></span>
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>Multiple matches found for "{scanResult.cardName}":</div>
</div>
{scanResult.matches.map((match, index) => (
<div key={index} className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<span>{match.name}</span>
<span>({match.set_name})</span>
</div>
))}
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Confidence: {Math.round(scanResult.confidence)}%
</div>
</div>
) : scanResult.type === 'success' ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-green-600"></span>
<div>
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>Card Found: {scanResult.cardName}</div>
{scanResult.setName && (
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>Set: {scanResult.setName}</div>
)}
{scanResult.game && (
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>Game: {scanResult.game}</div>
)}
{scanResult.rarity && (
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>Rarity: {scanResult.rarity}</div>
)}
{scanResult.message && (
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>{scanResult.message}</div>
)}
</div>
</div>
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
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" style={{ color: 'var(--text-primary)' }}>Card not clearly recognized</div>
</div>
<details className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<summary className="cursor-pointer">View raw OCR response</summary>
<pre className="mt-2 whitespace-pre-wrap p-2 rounded text-xs" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
{scanResult.rawText || 'No raw text available'}
</pre>
</details>
</div>
)}
</div>
)}
{/* Instructions */}
<div className="rounded-xl p-4 mt-4" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
<div className="font-medium mb-2" style={{ color: 'var(--text-primary)' }}>💡 Efficient Card Detection</div>
<ul className="text-sm space-y-1" style={{ color: 'var(--text-secondary)' }}>
<li> 👁 Computer vision detects rectangular objects (no API calls)</li>
<li> 🎯 AI analysis only when card-like shape is found</li>
<li> Fast detection every 500ms, AI scan every 3 seconds</li>
<li> 💰 Dramatically reduces API usage and costs</li>
<li> 🃏 Still maintains high accuracy for card recognition</li>
</ul>
</div>
</div>
);
}