diff --git a/components/CameraScanner.js b/components/CameraScanner.js index 8ce59af..4b7dc76 100644 --- a/components/CameraScanner.js +++ b/components/CameraScanner.js @@ -3,16 +3,12 @@ import { aiCardOCR, ollamaCardOCR, puterCardOCR, geminiCardOCR } from '../lib/ai 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 [scanningAnimation, setScanningAnimation] = useState(false); + const [scanResult, setScanResult] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); const [ocrSettings, setOcrSettings] = useState({ - service: 'gemini', // Default to Gemini + service: 'gemini', openaiApiKey: '', geminiApiKey: '', ollamaUrl: 'http://localhost:11434' @@ -20,26 +16,29 @@ export default function CameraScanner({ onCardScanned, onError }) { const videoRef = useRef(null); const canvasRef = useRef(null); - const detectionCanvasRef = useRef(null); // Separate canvas for computer vision + const detectionCanvasRef = useRef(null); 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 + const trackingIntervalRef = useRef(null); + + // Card tracking state + const [trackedCards, setTrackedCards] = useState([]); // Array of tracked card objects + const trackedCardsRef = useRef([]); + const nextCardIdRef = useRef(1); - // Load OCR settings from localStorage + // Mana symbol settings + const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false }); + + // Load OCR settings and auto-configure Gemini useEffect(() => { const loadOcrSettings = async () => { let settings = { - service: 'gemini', // Default to Gemini + service: 'gemini', openaiApiKey: '', geminiApiKey: '', ollamaUrl: 'http://localhost:11434' }; - // Load saved settings const savedSettings = localStorage.getItem('ocrSettings'); if (savedSettings) { try { @@ -50,7 +49,7 @@ export default function CameraScanner({ onCardScanned, onError }) { } } - // Try to auto-load Gemini API key from environment if not already set + // Auto-load Gemini API key from environment if (!settings.geminiApiKey) { try { const response = await fetch('/api/config/gemini'); @@ -58,7 +57,7 @@ export default function CameraScanner({ onCardScanned, onError }) { const data = await response.json(); if (data.hasKey && data.apiKey) { settings.geminiApiKey = data.apiKey; - settings.service = 'gemini'; // Ensure Gemini is selected + settings.service = 'gemini'; console.log('βœ… Auto-configured Gemini API key from environment'); } } @@ -94,9 +93,9 @@ export default function CameraScanner({ onCardScanned, onError }) { } }, []); - // Computer vision-based card detection (fast, no API calls) - const detectCardWithComputerVision = () => { - if (!videoRef.current || !detectionCanvasRef.current) return null; + // Continuous shape detection for card-like rectangles + const detectCardShapes = () => { + if (!videoRef.current || !detectionCanvasRef.current || !isStreaming) return []; const video = videoRef.current; const canvas = detectionCanvasRef.current; @@ -113,114 +112,93 @@ export default function CameraScanner({ onCardScanned, onError }) { 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]); + for (let i = 0; i < data.length; i += 4) { + const gray = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[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; + // Simple edge detection (Sobel-like) + for (let y = 1; y < canvas.height - 1; y++) { + for (let x = 1; x < canvas.width - 1; x++) { + const idx = y * canvas.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]; + const gx = -grayscale[idx - 1] + grayscale[idx + 1] + + -2 * grayscale[idx - 1 + canvas.width] + 2 * grayscale[idx + 1 + canvas.width] + + -grayscale[idx - 1 + 2 * canvas.width] + grayscale[idx + 1 + 2 * canvas.width]; - // 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]; + const gy = -grayscale[idx - canvas.width] - 2 * grayscale[idx] - grayscale[idx + canvas.width] + + grayscale[idx - canvas.width + 2 * canvas.width] + 2 * grayscale[idx + 2 * canvas.width] + grayscale[idx + canvas.width + 2 * canvas.width]; - // Edge magnitude const magnitude = Math.sqrt(gx * gx + gy * gy); - edges[idx] = magnitude > 80 ? 255 : 0; // Increased threshold from 50 to 80 + edges[idx] = magnitude > 100 ? 255 : 0; // Threshold for edge detection } } - // Look for rectangular regions with stricter criteria - const cardCandidates = findRectangularRegions(edges, width, height); + // Find rectangular regions that could be cards + const cardShapes = []; + const { width, height } = canvas; - // Score candidates based on card-like properties with higher standards - const bestCandidate = scoreCardCandidates(cardCandidates, width, height); - - return bestCandidate; - }; + // Card aspect ratio constraints (typical trading cards are ~2.5:3.5 ratio) + const minCardWidth = Math.floor(width * 0.2); // Increased from 0.15 + const maxCardWidth = Math.floor(width * 0.6); // Decreased from 0.8 + const minCardHeight = Math.floor(height * 0.25); // Increased from 0.2 + const maxCardHeight = Math.floor(height * 0.7); // Decreased from 0.9 - // 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 + // Scan for edge-dense rectangular regions + for (let y = 0; y < height - minCardHeight; y += 15) { // Increased step + for (let x = 0; x < width - minCardWidth; x += 15) { // Increased step + for (let w = minCardWidth; w <= maxCardWidth && x + w < width; w += 20) { // Increased step + for (let h = minCardHeight; h <= maxCardHeight && y + h < height; h += 20) { // Increased step - // Stricter card-like aspect ratio check + // Check aspect ratio (cards are typically 0.65-0.75) const aspectRatio = w / h; - if (aspectRatio < 0.65 || aspectRatio > 0.77) continue; // Narrowed from 0.6-0.8 to 0.65-0.77 + if (aspectRatio < 0.63 || aspectRatio > 0.77) continue; // Stricter range - // Count edges in this region with more selective sampling + // Count edges in this region let edgeCount = 0; let totalPixels = 0; - let cornerEdges = 0; // Count edges near corners (cards have defined corners) + let perimeterEdges = 0; - // 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 + // Sample the region + for (let sy = y; sy < y + h; sy += 4) { // Increased step for performance + for (let sx = x; sx < x + w; sx += 4) { // Increased step for performance 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++; + // Check if this edge is near the perimeter (cards have strong borders) + const isPerimeter = (sx < x + w * 0.15 || sx > x + w * 0.85 || + sy < y + h * 0.15 || sy > y + h * 0.85); + if (isPerimeter) { + perimeterEdges++; + } } totalPixels++; } } const edgeDensity = edgeCount / totalPixels; - const cornerEdgeDensity = cornerEdges / (edgeCount || 1); + const perimeterRatio = perimeterEdges / (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 + // Much stricter criteria for card-like objects + if (edgeDensity > 0.2 && edgeDensity < 0.6 && perimeterRatio > 0.4 && edgeCount > 80) { + const score = edgeDensity * 100 + perimeterRatio * 60 + (edgeCount / 10); - // 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 + // Higher threshold for accepting shapes + if (score > 50) { + // Convert back to video coordinates + const videoCoords = convertToVideoCoordinates({ x, y, width: w, height: h }, canvas, video); + + cardShapes.push({ + ...videoCoords, + score, + aspectRatio, + edgeDensity, + timestamp: Date.now() }); } } @@ -229,315 +207,133 @@ export default function CameraScanner({ onCardScanned, onError }) { } } - return candidates; + // Sort by score and return top candidates to allow multiple cards + return cardShapes.sort((a, b) => b.score - a.score).slice(0, 5); // Allow up to 5 cards simultaneously }; - // 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; + // Convert detection coordinates to video coordinates + const convertToVideoCoordinates = (detection, canvas, video) => { + const scaleX = video.videoWidth / canvas.width; + const scaleY = video.videoHeight / canvas.height; - // 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 + x: detection.x * scaleX, + y: detection.y * scaleY, + width: detection.width * scaleX, + height: detection.height * scaleY }; }; - // 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); + // Check if two rectangles overlap significantly + const rectanglesOverlap = (rect1, rect2, threshold = 0.3) => { // Reduced default threshold + const x1 = Math.max(rect1.x, rect2.x); + const y1 = Math.max(rect1.y, rect2.y); + const x2 = Math.min(rect1.x + rect1.width, rect2.x + rect2.width); + const y2 = Math.min(rect1.y + rect1.height, rect2.y + rect2.height); + + if (x2 <= x1 || y2 <= y1) return false; + + const overlapArea = (x2 - x1) * (y2 - y1); + const rect1Area = rect1.width * rect1.height; + const rect2Area = rect2.width * rect2.height; + const smallerArea = Math.min(rect1Area, rect2Area); + + // Use smaller area as denominator for better tracking of moving cards + return (overlapArea / smallerArea) > threshold; + }; - // 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; + // Update tracked cards with new detections + const updateTrackedCards = (detectedShapes) => { + const currentTime = Date.now(); + const updatedCards = [...trackedCardsRef.current]; + + // Remove cards that haven't been seen recently (3 seconds for better responsiveness) + for (let i = updatedCards.length - 1; i >= 0; i--) { + if (currentTime - updatedCards[i].lastSeen > 3000) { + console.log(`πŸ—‘οΈ Removing stale tracked card ${updatedCards[i].id} (last seen ${Math.round((currentTime - updatedCards[i].lastSeen)/1000)}s ago)`); + updatedCards.splice(i, 1); } - 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; + + // Match detected shapes with existing tracked cards + detectedShapes.forEach(shape => { + let matchedCard = null; + // Find existing card that overlaps with this shape + for (const card of updatedCards) { + if (rectanglesOverlap(shape, card.bounds, 0.4)) { // Slightly more lenient overlap + matchedCard = card; + break; + } + } + + if (matchedCard) { + // Update existing card position and timestamp + matchedCard.bounds = { ...shape }; + matchedCard.lastSeen = currentTime; + matchedCard.stableCount = Math.min(matchedCard.stableCount + 1, 10); + + // Reset scan attempts if card moved significantly (allows re-scanning) + const positionChange = Math.abs(matchedCard.bounds.x - shape.x) + Math.abs(matchedCard.bounds.y - shape.y); + if (positionChange > 50 && matchedCard.status === 'negative') { + console.log(`πŸ”„ Card ${matchedCard.id} moved significantly, allowing re-scan`); + matchedCard.status = 'detecting'; + matchedCard.scanAttempts = 0; + matchedCard.stableCount = 1; + } + } else { + // Create new tracked card + const newCard = { + id: nextCardIdRef.current++, + bounds: { ...shape }, + status: 'detecting', // 'detecting', 'confirmed', 'negative', 'scanned' + firstSeen: currentTime, + lastSeen: currentTime, + stableCount: 1, + scanAttempts: 0 + }; + + updatedCards.push(newCard); + console.log(`🎯 New card shape detected: ${newCard.id}`); + } + }); + + trackedCardsRef.current = updatedCards; + setTrackedCards(updatedCards); + }; + + // Quick card verification using AI + const verifyCardShape = async (cardTracker) => { + if (!videoRef.current || !canvasRef.current || cardTracker.status !== 'detecting') return; + + try { + console.log(`πŸ” Verifying card ${cardTracker.id}...`); + cardTracker.scanAttempts++; + + const video = videoRef.current; + const canvas = canvasRef.current; + const ctx = canvas.getContext('2d'); + + // Capture the specific region + const { x, y, width, height } = cardTracker.bounds; + const margin = 20; // Add some margin around the detected area + + canvas.width = width + margin * 2; + canvas.height = height + margin * 2; + + // Draw the card region with margin + ctx.drawImage( + video, + Math.max(0, x - margin), Math.max(0, y - margin), + width + margin * 2, height + margin * 2, + 0, 0, + canvas.width, canvas.height + ); + + const imageData = canvas.toDataURL('image/jpeg', 0.8); + + // Quick AI verification + let ocrResult; console.log('πŸ” Current OCR settings:', ocrSettings); if (ocrSettings.service === 'puter') { @@ -559,33 +355,34 @@ export default function CameraScanner({ onCardScanned, onError }) { } 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; - } + + if (ocrResult.isCard && ocrResult.confidence > 60) { + // Confirmed as a card! + cardTracker.status = 'confirmed'; + cardTracker.cardData = ocrResult; + console.log(`βœ… Card ${cardTracker.id} confirmed: ${ocrResult.cardName}`); + + // Process the card through database lookup + await processConfirmedCard(cardTracker, imageData); + + } else { + // Not a card or low confidence + cardTracker.status = 'negative'; + console.log(`❌ Card ${cardTracker.id} rejected: ${ocrResult.reason || 'Low confidence'}`); } + + } catch (error) { + console.error(`Error verifying card ${cardTracker.id}:`, error); + cardTracker.status = 'negative'; + } + }; - // 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 + // Process confirmed card through database lookup + const processConfirmedCard = async (cardTracker, imageData) => { + try { + const ocrResult = cardTracker.cardData; + + // Database lookup console.log('πŸ” Cross-referencing with database...'); const dbResponse = await fetch('/api/cards/find-or-create', { method: 'POST', @@ -618,92 +415,182 @@ export default function CameraScanner({ onCardScanned, onError }) { } 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; - } + + // Handle successful card identification + if (dbResult.card) { + const finalCard = dbResult.card; + + // Mark as scanned and send to parent + cardTracker.status = 'scanned'; + + // Update browser tab title with card name + const originalTitle = document.title; + document.title = `πŸ“Έ ${finalCard.name} - Card Scanner`; + + // Reset title after 3 seconds + setTimeout(() => { + document.title = originalTitle; + }, 3000); + + onCardScanned({ + name: finalCard.name, + 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, + databaseId: finalCard.id, + isExisting: dbResult.isExisting + }); + + console.log(`πŸŽ‰ Card ${cardTracker.id} successfully scanned: ${finalCard.name}`); } - - 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); + console.error(`Error processing card ${cardTracker.id}:`, error); + cardTracker.status = 'negative'; } }; + // Start continuous detection + const startDetection = () => { + if (detectionIntervalRef.current || !isStreaming) return; + + console.log('🎯 Starting continuous card detection...'); + setIsDetecting(true); + + // Shape detection every 200ms + detectionIntervalRef.current = setInterval(() => { + const shapes = detectCardShapes(); + updateTrackedCards(shapes); + }, 200); + + // Card verification every 1 second + trackingIntervalRef.current = setInterval(() => { + const cardsToVerify = trackedCardsRef.current.filter(card => + card.status === 'detecting' && + card.stableCount >= 4 && // Reduced back to 4 for better responsiveness + card.scanAttempts < 2 && // Allow 2 attempts again + Date.now() - card.firstSeen > 1500 // Reduced to 1.5 seconds + ); + + // Verify up to 2 cards simultaneously to allow multi-card scanning + const cardsToProcess = cardsToVerify.slice(0, 2); + cardsToProcess.forEach(card => { + verifyCardShape(card); + }); + }, 1000); // Back to 1 second intervals + }; + + // Stop detection + const stopDetection = () => { + console.log('πŸ›‘ Stopping card detection...'); + setIsDetecting(false); + + if (detectionIntervalRef.current) { + clearInterval(detectionIntervalRef.current); + detectionIntervalRef.current = null; + } + + if (trackingIntervalRef.current) { + clearInterval(trackingIntervalRef.current); + trackingIntervalRef.current = null; + } + + // Clear tracked cards + trackedCardsRef.current = []; + setTrackedCards([]); + }; + + // Start camera stream + const startCamera = async () => { + try { + console.log('πŸŽ₯ Starting camera...'); + const stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: 'environment', + width: { ideal: 1280 }, + height: { ideal: 720 }, + aspectRatio: { ideal: 16/9 } + } + }); + + console.log('πŸ“Ή Camera stream obtained:', stream); + + if (videoRef.current) { + videoRef.current.srcObject = stream; + streamRef.current = stream; + + videoRef.current.onloadedmetadata = () => { + console.log('πŸ“Ί Video metadata loaded, attempting to play...'); + videoRef.current?.play().then(() => { + console.log('▢️ Video playback started successfully'); + setIsStreaming(true); + }).catch((err) => { + console.error('❌ Video playback failed:', err); + onError(`Video playback failed: ${err.message}`); + }); + }; + + videoRef.current.onerror = (err) => { + console.error('❌ Video element error:', err); + onError('Video element error occurred'); + }; + + // Add a fallback timeout + setTimeout(() => { + if (!isStreaming && videoRef.current && videoRef.current.readyState >= 2) { + console.log('πŸ”„ Fallback: Attempting to play video directly...'); + videoRef.current.play().then(() => { + console.log('▢️ Fallback video playback started'); + setIsStreaming(true); + }).catch(console.error); + } + }, 2000); + + } else { + console.error('❌ Video element not available'); + 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 = () => { + setIsStreaming(false); + stopDetection(); + + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + streamRef.current = null; + } + + if (videoRef.current) { + videoRef.current.srcObject = null; + } + }; + + // Auto-start detection when camera starts + useEffect(() => { + if (isStreaming && !isDetecting) { + // Small delay to let camera stabilize + setTimeout(() => { + startDetection(); + }, 1000); + } + }, [isStreaming]); + // Cleanup on unmount useEffect(() => { return () => { @@ -712,277 +599,219 @@ export default function CameraScanner({ onCardScanned, onError }) { }, []); return ( -
- {/* Camera Controls */} -
- {!isStreaming ? ( - - ) : ( - <> - - - - - - - )} -
- - {/* Camera Preview with Card-like Aspect Ratio */} +
+ {/* Camera Feed Container */}
+ {/* Video Element - Always rendered but visibility controlled */}