🔧 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
988 lines
No EOL
37 KiB
JavaScript
988 lines
No EOL
37 KiB
JavaScript
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>
|
||
);
|
||
}
|