2025-07-29 15:19:48 -04:00
|
|
|
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 [isDetecting, setIsDetecting] = useState(false);
|
2025-08-01 18:41:42 -04:00
|
|
|
const [scanningAnimation, setScanningAnimation] = useState(false);
|
|
|
|
|
const [scanResult, setScanResult] = useState(null);
|
|
|
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
2025-07-29 15:19:48 -04:00
|
|
|
const [ocrSettings, setOcrSettings] = useState({
|
2025-08-01 18:41:42 -04:00
|
|
|
service: 'gemini',
|
2025-07-29 15:19:48 -04:00
|
|
|
openaiApiKey: '',
|
|
|
|
|
geminiApiKey: '',
|
|
|
|
|
ollamaUrl: 'http://localhost:11434'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const videoRef = useRef(null);
|
|
|
|
|
const canvasRef = useRef(null);
|
2025-08-01 18:41:42 -04:00
|
|
|
const detectionCanvasRef = useRef(null);
|
2025-07-29 15:19:48 -04:00
|
|
|
const streamRef = useRef(null);
|
|
|
|
|
const detectionIntervalRef = useRef(null);
|
2025-08-01 18:41:42 -04:00
|
|
|
const trackingIntervalRef = useRef(null);
|
|
|
|
|
|
|
|
|
|
// Card tracking state
|
|
|
|
|
const [trackedCards, setTrackedCards] = useState([]); // Array of tracked card objects
|
|
|
|
|
const trackedCardsRef = useRef([]);
|
|
|
|
|
const nextCardIdRef = useRef(1);
|
2025-07-29 15:19:48 -04:00
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
// Mana symbol settings
|
|
|
|
|
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
|
|
|
|
|
|
2026-05-27 09:40:00 -04:00
|
|
|
// Load OCR settings from localStorage only (never fetch server-side API keys)
|
2025-07-29 15:19:48 -04:00
|
|
|
useEffect(() => {
|
2026-05-27 09:40:00 -04:00
|
|
|
let settings = {
|
|
|
|
|
service: 'gemini',
|
|
|
|
|
openaiApiKey: '',
|
|
|
|
|
geminiApiKey: '',
|
|
|
|
|
ollamaUrl: 'http://localhost:11434'
|
|
|
|
|
};
|
2025-07-29 15:19:48 -04:00
|
|
|
|
2026-05-27 09:40:00 -04:00
|
|
|
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);
|
2025-07-29 15:19:48 -04:00
|
|
|
}
|
2026-05-27 09:40:00 -04:00
|
|
|
}
|
2025-07-29 15:19:48 -04:00
|
|
|
|
2026-05-27 09:40:00 -04:00
|
|
|
setOcrSettings(settings);
|
2025-07-29 15:19:48 -04:00
|
|
|
|
2026-05-27 09:40:00 -04:00
|
|
|
if (settings.openaiApiKey) {
|
|
|
|
|
aiCardOCR.setApiKey(settings.openaiApiKey);
|
|
|
|
|
}
|
|
|
|
|
if (settings.geminiApiKey) {
|
|
|
|
|
geminiCardOCR.setApiKey(settings.geminiApiKey);
|
|
|
|
|
}
|
|
|
|
|
if (settings.ollamaUrl) {
|
|
|
|
|
ollamaCardOCR.setBaseUrl(settings.ollamaUrl);
|
|
|
|
|
}
|
2025-07-29 15:19:48 -04:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 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 });
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
// Continuous shape detection for card-like rectangles
|
|
|
|
|
const detectCardShapes = () => {
|
|
|
|
|
if (!videoRef.current || !detectionCanvasRef.current || !isStreaming) return [];
|
2025-07-29 15:19:48 -04:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
// Convert to grayscale and detect edges
|
|
|
|
|
const grayscale = [];
|
|
|
|
|
const edges = [];
|
|
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
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]);
|
2025-07-29 15:19:48 -04:00
|
|
|
grayscale.push(gray);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
// 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;
|
2025-07-29 15:19:48 -04:00
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
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];
|
2025-07-29 15:19:48 -04:00
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
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];
|
2025-07-29 15:19:48 -04:00
|
|
|
|
|
|
|
|
const magnitude = Math.sqrt(gx * gx + gy * gy);
|
2025-08-01 18:41:42 -04:00
|
|
|
edges[idx] = magnitude > 100 ? 255 : 0; // Threshold for edge detection
|
2025-07-29 15:19:48 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
// Find rectangular regions that could be cards
|
|
|
|
|
const cardShapes = [];
|
|
|
|
|
const { width, height } = canvas;
|
2025-07-29 15:19:48 -04:00
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
// 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
|
|
|
|
|
|
|
|
|
|
// 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
|
2025-07-29 15:19:48 -04:00
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
// Check aspect ratio (cards are typically 0.65-0.75)
|
2025-07-29 15:19:48 -04:00
|
|
|
const aspectRatio = w / h;
|
2025-08-01 18:41:42 -04:00
|
|
|
if (aspectRatio < 0.63 || aspectRatio > 0.77) continue; // Stricter range
|
2025-07-29 15:19:48 -04:00
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
// Count edges in this region
|
2025-07-29 15:19:48 -04:00
|
|
|
let edgeCount = 0;
|
|
|
|
|
let totalPixels = 0;
|
2025-08-01 18:41:42 -04:00
|
|
|
let perimeterEdges = 0;
|
2025-07-29 15:19:48 -04:00
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
// 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
|
2025-07-29 15:19:48 -04:00
|
|
|
const idx = sy * width + sx;
|
|
|
|
|
if (edges[idx] === 255) {
|
|
|
|
|
edgeCount++;
|
|
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
// 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++;
|
|
|
|
|
}
|
2025-07-29 15:19:48 -04:00
|
|
|
}
|
|
|
|
|
totalPixels++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const edgeDensity = edgeCount / totalPixels;
|
2025-08-01 18:41:42 -04:00
|
|
|
const perimeterRatio = perimeterEdges / (edgeCount || 1);
|
2025-07-29 15:19:48 -04:00
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
// 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);
|
2025-07-29 15:19:48 -04:00
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
// 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()
|
2025-07-29 15:19:48 -04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
// 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
|
2025-07-29 15:19:48 -04:00
|
|
|
};
|
|
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
// Convert detection coordinates to video coordinates
|
|
|
|
|
const convertToVideoCoordinates = (detection, canvas, video) => {
|
|
|
|
|
const scaleX = video.videoWidth / canvas.width;
|
|
|
|
|
const scaleY = video.videoHeight / canvas.height;
|
2025-07-29 15:19:48 -04:00
|
|
|
|
|
|
|
|
return {
|
2025-08-01 18:41:42 -04:00
|
|
|
x: detection.x * scaleX,
|
|
|
|
|
y: detection.y * scaleY,
|
|
|
|
|
width: detection.width * scaleX,
|
|
|
|
|
height: detection.height * scaleY
|
2025-07-29 15:19:48 -04:00
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
// 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;
|
|
|
|
|
};
|
2025-07-29 15:19:48 -04:00
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
// 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);
|
2025-07-29 15:19:48 -04:00
|
|
|
}
|
|
|
|
|
}
|
2025-08-01 18:41:42 -04:00
|
|
|
|
|
|
|
|
// 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;
|
2025-07-29 15:19:48 -04:00
|
|
|
}
|
2025-08-01 18:41:42 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (matchedCard) {
|
|
|
|
|
// Update existing card position and timestamp
|
|
|
|
|
matchedCard.bounds = { ...shape };
|
|
|
|
|
matchedCard.lastSeen = currentTime;
|
|
|
|
|
matchedCard.stableCount = Math.min(matchedCard.stableCount + 1, 10);
|
2025-07-29 15:19:48 -04:00
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
// 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
|
2025-07-29 15:19:48 -04:00
|
|
|
};
|
|
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
updatedCards.push(newCard);
|
|
|
|
|
console.log(`🎯 New card shape detected: ${newCard.id}`);
|
2025-07-29 15:19:48 -04:00
|
|
|
}
|
2025-08-01 18:41:42 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
trackedCardsRef.current = updatedCards;
|
|
|
|
|
setTrackedCards(updatedCards);
|
2025-07-29 15:19:48 -04:00
|
|
|
};
|
|
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
// Quick card verification using AI
|
|
|
|
|
const verifyCardShape = async (cardTracker) => {
|
|
|
|
|
if (!videoRef.current || !canvasRef.current || cardTracker.status !== 'detecting') return;
|
|
|
|
|
|
2025-07-29 15:19:48 -04:00
|
|
|
try {
|
2025-08-01 18:41:42 -04:00
|
|
|
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);
|
2025-07-29 15:19:48 -04:00
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
// Quick AI verification
|
|
|
|
|
let ocrResult;
|
2025-07-29 15:19:48 -04:00
|
|
|
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');
|
|
|
|
|
}
|
2025-08-01 18:41:42 -04:00
|
|
|
|
|
|
|
|
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'}`);
|
2025-07-29 15:19:48 -04:00
|
|
|
}
|
2025-08-01 18:41:42 -04:00
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(`Error verifying card ${cardTracker.id}:`, error);
|
|
|
|
|
cardTracker.status = 'negative';
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-07-29 15:19:48 -04:00
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
// Process confirmed card through database lookup
|
|
|
|
|
const processConfirmedCard = async (cardTracker, imageData) => {
|
|
|
|
|
try {
|
|
|
|
|
const ocrResult = cardTracker.cardData;
|
|
|
|
|
|
|
|
|
|
// Database lookup
|
2025-07-29 15:19:48 -04:00
|
|
|
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();
|
2025-08-01 18:41:42 -04:00
|
|
|
|
|
|
|
|
// 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}`);
|
2025-07-29 15:19:48 -04:00
|
|
|
}
|
2025-08-01 18:41:42 -04:00
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(`Error processing card ${cardTracker.id}:`, error);
|
|
|
|
|
cardTracker.status = 'negative';
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-07-29 15:19:48 -04:00
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
// 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);
|
2025-07-29 15:19:48 -04:00
|
|
|
});
|
2025-08-01 18:41:42 -04:00
|
|
|
}, 1000); // Back to 1 second intervals
|
|
|
|
|
};
|
2025-07-29 15:19:48 -04:00
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
// 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([]);
|
|
|
|
|
};
|
2025-07-29 15:19:48 -04:00
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
// 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 }
|
|
|
|
|
}
|
2025-07-29 15:19:48 -04:00
|
|
|
});
|
|
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
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);
|
|
|
|
|
|
2025-07-29 15:19:48 -04:00
|
|
|
} else {
|
2025-08-01 18:41:42 -04:00
|
|
|
console.error('❌ Video element not available');
|
|
|
|
|
onError('Video element not available');
|
2025-07-29 15:19:48 -04:00
|
|
|
}
|
2025-08-01 18:41:42 -04:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('❌ Camera access error:', err);
|
|
|
|
|
onError(`Unable to access camera: ${err.message}`);
|
2025-07-29 15:19:48 -04:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
// 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]);
|
|
|
|
|
|
2025-07-29 15:19:48 -04:00
|
|
|
// Cleanup on unmount
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
return () => {
|
|
|
|
|
stopCamera();
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
return (
|
2025-08-01 18:41:42 -04:00
|
|
|
<div className="w-full h-full flex flex-col">
|
|
|
|
|
{/* Camera Feed Container */}
|
2025-07-29 15:19:48 -04:00
|
|
|
<div
|
2025-08-01 18:41:42 -04:00
|
|
|
className="flex-1 relative rounded-2xl overflow-hidden mb-4"
|
2025-07-29 15:19:48 -04:00
|
|
|
style={{
|
2025-08-01 18:41:42 -04:00
|
|
|
backgroundColor: 'var(--bg-tertiary)',
|
|
|
|
|
border: '2px solid var(--border)'
|
2025-07-29 15:19:48 -04:00
|
|
|
}}
|
|
|
|
|
>
|
2025-08-01 18:41:42 -04:00
|
|
|
{/* Video Element - Always rendered but visibility controlled */}
|
2025-07-29 15:19:48 -04:00
|
|
|
<video
|
|
|
|
|
ref={videoRef}
|
2025-08-01 18:41:42 -04:00
|
|
|
className={`absolute inset-0 w-full h-full object-cover ${isStreaming ? 'block' : 'hidden'}`}
|
2025-07-29 15:19:48 -04:00
|
|
|
autoPlay
|
|
|
|
|
playsInline
|
|
|
|
|
muted
|
|
|
|
|
/>
|
2025-08-01 18:41:42 -04:00
|
|
|
|
|
|
|
|
{/* Card Detection Overlays - Only when streaming */}
|
|
|
|
|
{isStreaming && trackedCards
|
|
|
|
|
.filter(card => card.status === 'confirmed' || card.status === 'scanned')
|
|
|
|
|
.map(card => (
|
|
|
|
|
<div
|
|
|
|
|
key={card.id}
|
|
|
|
|
className="absolute border-2 rounded-lg transition-all duration-200"
|
|
|
|
|
style={{
|
|
|
|
|
left: `${(card.bounds.x / videoRef.current?.videoWidth) * 100}%`,
|
|
|
|
|
top: `${(card.bounds.y / videoRef.current?.videoHeight) * 100}%`,
|
|
|
|
|
width: `${(card.bounds.width / videoRef.current?.videoWidth) * 100}%`,
|
|
|
|
|
height: `${(card.bounds.height / videoRef.current?.videoHeight) * 100}%`,
|
|
|
|
|
borderColor:
|
|
|
|
|
card.status === 'confirmed' ? '#10B981' : // Green for confirmed
|
|
|
|
|
card.status === 'scanned' ? '#3B82F6' : // Blue for scanned
|
|
|
|
|
'#10B981', // Default to green
|
|
|
|
|
borderWidth: '3px',
|
|
|
|
|
boxShadow: `0 0 15px ${
|
|
|
|
|
card.status === 'confirmed' ? '#10B98150' :
|
|
|
|
|
card.status === 'scanned' ? '#3B82F650' :
|
|
|
|
|
'#10B98150'
|
|
|
|
|
}`
|
2025-07-29 15:19:48 -04:00
|
|
|
}}
|
|
|
|
|
>
|
2025-08-01 18:41:42 -04:00
|
|
|
{/* Status Label */}
|
2025-07-29 15:19:48 -04:00
|
|
|
<div
|
2025-08-01 18:41:42 -04:00
|
|
|
className="absolute -top-8 left-0 px-3 py-1 rounded-full text-xs font-bold text-white shadow-lg"
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor:
|
|
|
|
|
card.status === 'confirmed' ? '#10B981' :
|
|
|
|
|
card.status === 'scanned' ? '#3B82F6' :
|
|
|
|
|
'#10B981'
|
2025-07-29 15:19:48 -04:00
|
|
|
}}
|
|
|
|
|
>
|
2025-08-01 18:41:42 -04:00
|
|
|
{card.status === 'confirmed' ? '✅ Card Found' :
|
|
|
|
|
card.status === 'scanned' ? '📸 Scanned' :
|
|
|
|
|
'✅ Card Found'}
|
2025-07-29 15:19:48 -04:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-08-01 18:41:42 -04:00
|
|
|
))}
|
2025-07-29 15:19:48 -04:00
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
{/* Top Status Bar - Only when streaming */}
|
2025-07-29 15:19:48 -04:00
|
|
|
{isStreaming && (
|
2025-08-01 18:41:42 -04:00
|
|
|
<div className="absolute top-4 left-4 right-4 flex justify-between items-center">
|
|
|
|
|
{/* Detection Status */}
|
|
|
|
|
{isDetecting && (
|
|
|
|
|
<div className="bg-black bg-opacity-80 text-white px-4 py-2 rounded-full text-sm font-medium shadow-lg backdrop-blur-sm">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
|
|
|
|
🎯 Scanning {trackedCards.filter(card => card.status === 'confirmed' || card.status === 'scanned').length > 0 &&
|
|
|
|
|
`(${trackedCards.filter(card => card.status === 'confirmed' || card.status === 'scanned').length} found)`}
|
2025-07-29 15:19:48 -04:00
|
|
|
</div>
|
2025-08-01 18:41:42 -04:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="flex-1"></div>
|
|
|
|
|
|
|
|
|
|
{/* Recording Indicator */}
|
|
|
|
|
<div className="bg-black bg-opacity-80 text-white px-4 py-2 rounded-full text-sm font-medium shadow-lg backdrop-blur-sm">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
|
|
|
|
|
LIVE
|
2025-07-29 15:19:48 -04:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-08-01 18:41:42 -04:00
|
|
|
|
|
|
|
|
{/* Bottom Controls Overlay - Only when streaming */}
|
|
|
|
|
{isStreaming && (
|
|
|
|
|
<div className="absolute bottom-6 left-1/2 transform -translate-x-1/2">
|
|
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
{/* Stop Button */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={stopCamera}
|
|
|
|
|
className="w-16 h-16 rounded-full flex items-center justify-center text-white shadow-2xl hover:scale-105 transition-all duration-200 backdrop-blur-sm"
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: 'rgba(239, 68, 68, 0.9)',
|
|
|
|
|
border: '3px solid rgba(255, 255, 255, 0.3)'
|
|
|
|
|
}}
|
|
|
|
|
title="Stop Camera"
|
|
|
|
|
>
|
|
|
|
|
<div className="w-6 h-6 bg-white rounded-sm"></div>
|
|
|
|
|
</button>
|
2025-07-29 15:19:48 -04:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
{/* Camera Placeholder with Centered Start Button - Only when not streaming */}
|
|
|
|
|
{!isStreaming && (
|
|
|
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
|
|
|
|
{/* Background Pattern */}
|
|
|
|
|
<div className="absolute inset-0 opacity-5">
|
|
|
|
|
<div className="w-full h-full" style={{
|
|
|
|
|
backgroundImage: `radial-gradient(circle at 25% 25%, var(--text-primary) 2px, transparent 2px),
|
|
|
|
|
radial-gradient(circle at 75% 75%, var(--text-primary) 2px, transparent 2px)`,
|
|
|
|
|
backgroundSize: '50px 50px'
|
|
|
|
|
}}></div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Camera Icon and Content */}
|
|
|
|
|
<div className="text-center z-10 mb-8">
|
|
|
|
|
<div className="font-semibold text-2xl mb-3" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
Camera Ready
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-lg opacity-75 mb-6" style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
Position trading cards in view for smart detection
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Centered Start Camera Button */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={startCamera}
|
|
|
|
|
className="w-20 h-20 rounded-full flex items-center justify-center text-white shadow-2xl hover:scale-110 transition-all duration-300 relative overflow-hidden group"
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: 'var(--accent-ember)',
|
|
|
|
|
border: '4px solid rgba(255, 255, 255, 0.2)'
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{/* Button Glow Effect */}
|
|
|
|
|
<div className="absolute inset-0 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
|
|
|
|
style={{
|
|
|
|
|
background: `radial-gradient(circle, rgba(255,255,255,0.3) 0%, transparent 70%)`
|
|
|
|
|
}}></div>
|
|
|
|
|
|
|
|
|
|
{/* Play Icon */}
|
|
|
|
|
<div className="relative z-10">
|
|
|
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
|
|
|
|
|
<path d="M8 5v14l11-7z"/>
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Pulse Ring */}
|
|
|
|
|
<div className="absolute inset-0 rounded-full border-2 border-white opacity-60 animate-ping"></div>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{/* Feature Hints */}
|
|
|
|
|
<div className="mt-8 text-center max-w-md">
|
|
|
|
|
<div className="grid grid-cols-2 gap-4 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: 'var(--accent-gold)' }}></div>
|
|
|
|
|
<span>AI Detection</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: 'var(--accent-ember)' }}></div>
|
|
|
|
|
<span>Real-time Tracking</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: 'var(--accent-flame)' }}></div>
|
|
|
|
|
<span>Smart Recognition</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div className="w-2 h-2 rounded-full bg-green-500"></div>
|
|
|
|
|
<span>Auto Scanning</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-07-29 15:19:48 -04:00
|
|
|
</div>
|
2025-08-01 18:41:42 -04:00
|
|
|
)}
|
|
|
|
|
</div>
|
2025-07-29 15:19:48 -04:00
|
|
|
|
|
|
|
|
{/* Hidden canvases for image processing */}
|
|
|
|
|
<canvas ref={canvasRef} className="hidden" />
|
|
|
|
|
<canvas ref={detectionCanvasRef} className="hidden" />
|
|
|
|
|
|
2025-08-01 18:41:42 -04:00
|
|
|
{/* Detection Info Panel - Only show when streaming */}
|
|
|
|
|
{isStreaming && (
|
|
|
|
|
<div className="rounded-xl p-3 border" style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}>
|
|
|
|
|
<div className="flex items-center gap-3 mb-2">
|
|
|
|
|
<div className="w-6 h-6 rounded-full flex items-center justify-center" style={{ backgroundColor: 'var(--accent-ember)' }}>
|
|
|
|
|
<span className="text-white text-xs">🎯</span>
|
2025-07-29 15:19:48 -04:00
|
|
|
</div>
|
2025-08-01 18:41:42 -04:00
|
|
|
<h4 className="font-medium text-sm" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
Smart Detection Active
|
|
|
|
|
</h4>
|
2025-07-29 15:19:48 -04:00
|
|
|
</div>
|
2025-08-01 18:41:42 -04:00
|
|
|
<div className="grid grid-cols-2 gap-2 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-green-500">●</span>
|
|
|
|
|
<span>Shape recognition</span>
|
2025-07-29 15:19:48 -04:00
|
|
|
</div>
|
2025-08-01 18:41:42 -04:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-blue-500">●</span>
|
|
|
|
|
<span>AI verification</span>
|
2025-07-29 15:19:48 -04:00
|
|
|
</div>
|
2025-08-01 18:41:42 -04:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-yellow-500">●</span>
|
|
|
|
|
<span>Position tracking</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-purple-500">●</span>
|
|
|
|
|
<span>Database lookup</span>
|
2025-07-29 15:19:48 -04:00
|
|
|
</div>
|
2025-08-01 18:41:42 -04:00
|
|
|
</div>
|
2025-07-29 15:19:48 -04:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|