🔧 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
527 lines
No EOL
17 KiB
JavaScript
527 lines
No EOL
17 KiB
JavaScript
// AI OCR Service for Trading Card Recognition
|
|
|
|
export class AICardOCR {
|
|
constructor() {
|
|
this.apiKey = null;
|
|
this.baseUrl = 'https://api.openai.com/v1';
|
|
}
|
|
|
|
setApiKey(apiKey) {
|
|
this.apiKey = apiKey;
|
|
}
|
|
|
|
async analyzeCard(imageDataUrl) {
|
|
if (!this.apiKey) {
|
|
throw new Error('OpenAI API key not configured');
|
|
}
|
|
|
|
const prompt = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
|
|
|
|
IMPORTANT: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore:
|
|
- Random objects, books, papers
|
|
- Screenshots of websites or apps
|
|
- Blurry or unclear images
|
|
- Non-card gaming items
|
|
|
|
If you detect a trading card, extract the following information in JSON format:
|
|
{
|
|
"isCard": true,
|
|
"cardName": "exact card name as printed",
|
|
"setName": "set name if visible",
|
|
"setCode": "set code/symbol if visible",
|
|
"cardNumber": "collector number if visible",
|
|
"game": "MTG, Pokemon, YuGiOh, Lorcana, etc.",
|
|
"cardType": "creature, instant, trainer, etc.",
|
|
"rarity": "common, uncommon, rare, mythic, etc.",
|
|
"manaCost": "mana cost if visible",
|
|
"hp": "HP or power if visible",
|
|
"abilities": ["list of abilities/attacks if visible"],
|
|
"flavorText": "flavor text if clearly readable",
|
|
"artist": "artist name if visible",
|
|
"confidence": 85,
|
|
"rawText": "all visible text on the card"
|
|
}
|
|
|
|
If NO trading card is detected, respond with:
|
|
{
|
|
"isCard": false,
|
|
"confidence": 0,
|
|
"reason": "No trading card detected in image"
|
|
}
|
|
|
|
Focus on accuracy over speed. Only extract data you can clearly read.`;
|
|
|
|
try {
|
|
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${this.apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
model: 'gpt-4o-mini',
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: prompt
|
|
},
|
|
{
|
|
type: 'image_url',
|
|
image_url: {
|
|
url: imageDataUrl,
|
|
detail: 'high'
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
max_tokens: 1000,
|
|
temperature: 0.1
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`OpenAI API error: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
const content = data.choices[0]?.message?.content;
|
|
|
|
if (!content) {
|
|
throw new Error('No response from OpenAI');
|
|
}
|
|
|
|
// Parse JSON response
|
|
let result;
|
|
try {
|
|
// Clean up the response - remove markdown code blocks if present
|
|
const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
|
|
result = JSON.parse(cleanContent);
|
|
} catch (parseError) {
|
|
console.error('Failed to parse OpenAI JSON response:', content);
|
|
// Fallback: try to extract card name from raw text
|
|
const cardNameMatch = content.match(/card.*?name.*?[:"]\s*([^"'\n]+)/i);
|
|
result = {
|
|
isCard: !!cardNameMatch,
|
|
cardName: cardNameMatch ? cardNameMatch[1].trim() : null,
|
|
confidence: 30,
|
|
rawText: content,
|
|
reason: 'Failed to parse structured response'
|
|
};
|
|
}
|
|
|
|
// Ensure we have the required structure
|
|
return {
|
|
isCard: result.isCard || false,
|
|
cardName: result.cardName || null,
|
|
setName: result.setName || null,
|
|
setCode: result.setCode || null,
|
|
cardNumber: result.cardNumber || null,
|
|
game: result.game || null,
|
|
cardType: result.cardType || null,
|
|
rarity: result.rarity || null,
|
|
manaCost: result.manaCost || null,
|
|
hp: result.hp || null,
|
|
abilities: result.abilities || [],
|
|
flavorText: result.flavorText || null,
|
|
artist: result.artist || null,
|
|
confidence: result.confidence || 0,
|
|
rawText: result.rawText || content,
|
|
reason: result.reason || null
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('OpenAI Vision API error:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
export class OllamaVisionOCR {
|
|
constructor() {
|
|
this.baseUrl = 'http://localhost:11434';
|
|
}
|
|
|
|
setBaseUrl(url) {
|
|
this.baseUrl = url;
|
|
}
|
|
|
|
async analyzeCard(imageDataUrl) {
|
|
const prompt = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
|
|
|
|
IMPORTANT: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore random objects, books, papers, screenshots, or blurry images.
|
|
|
|
If you detect a trading card, extract this information in JSON format:
|
|
{
|
|
"isCard": true,
|
|
"cardName": "exact card name as printed",
|
|
"setName": "set name if visible",
|
|
"setCode": "set code if visible",
|
|
"cardNumber": "collector number if visible",
|
|
"game": "MTG, Pokemon, YuGiOh, Lorcana, etc.",
|
|
"cardType": "creature, instant, trainer, etc.",
|
|
"rarity": "common, uncommon, rare, mythic, etc.",
|
|
"confidence": 85,
|
|
"rawText": "all visible text"
|
|
}
|
|
|
|
If NO trading card detected, respond: {"isCard": false, "confidence": 0, "reason": "No trading card detected"}`;
|
|
|
|
try {
|
|
// Convert data URL to base64
|
|
const base64Data = imageDataUrl.split(',')[1];
|
|
|
|
const response = await fetch(`${this.baseUrl}/api/generate`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
model: 'llava:latest',
|
|
prompt: prompt,
|
|
images: [base64Data],
|
|
stream: false,
|
|
options: {
|
|
temperature: 0.1,
|
|
top_p: 0.9
|
|
}
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Ollama API error: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
const content = data.response;
|
|
|
|
if (!content) {
|
|
throw new Error('No response from Ollama');
|
|
}
|
|
|
|
// Parse JSON response
|
|
let result;
|
|
try {
|
|
const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
|
|
result = JSON.parse(cleanContent);
|
|
} catch (parseError) {
|
|
console.error('Failed to parse Ollama JSON response:', content);
|
|
result = {
|
|
isCard: false,
|
|
confidence: 0,
|
|
rawText: content,
|
|
reason: 'Failed to parse response'
|
|
};
|
|
}
|
|
|
|
return {
|
|
isCard: result.isCard || false,
|
|
cardName: result.cardName || null,
|
|
setName: result.setName || null,
|
|
setCode: result.setCode || null,
|
|
cardNumber: result.cardNumber || null,
|
|
game: result.game || null,
|
|
cardType: result.cardType || null,
|
|
rarity: result.rarity || null,
|
|
manaCost: result.manaCost || null,
|
|
hp: result.hp || null,
|
|
abilities: result.abilities || [],
|
|
flavorText: result.flavorText || null,
|
|
artist: result.artist || null,
|
|
confidence: result.confidence || 0,
|
|
rawText: result.rawText || content,
|
|
reason: result.reason || null
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('Ollama Vision API error:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
export class PuterVisionOCR {
|
|
constructor() {
|
|
this.puterLoaded = false;
|
|
this.authFailed = false;
|
|
}
|
|
|
|
async loadPuterJS() {
|
|
if (this.puterLoaded || typeof window === 'undefined') return;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const script = document.createElement('script');
|
|
script.src = 'https://js.puter.com/v2/';
|
|
script.onload = () => {
|
|
this.puterLoaded = true;
|
|
resolve();
|
|
};
|
|
script.onerror = reject;
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
async analyzeCard(imageDataUrl) {
|
|
// If we've already failed auth, don't try again
|
|
if (this.authFailed) {
|
|
throw new Error('Puter.js authentication failed. Please use OpenAI or Ollama instead.');
|
|
}
|
|
|
|
try {
|
|
await this.loadPuterJS();
|
|
|
|
if (!window.puter) {
|
|
throw new Error('Puter.js not loaded');
|
|
}
|
|
|
|
// Check if user is authenticated with Puter
|
|
try {
|
|
await window.puter.auth.getUser();
|
|
} catch (authError) {
|
|
console.warn('Puter.js authentication required. Please sign in to Puter.com first.');
|
|
this.authFailed = true;
|
|
throw new Error('Puter.js requires authentication. Please use OpenAI or Ollama instead, or sign in to Puter.com first.');
|
|
}
|
|
|
|
const prompt = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
|
|
|
|
CRITICAL: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore:
|
|
- Random objects, books, papers, phone screens
|
|
- Screenshots of websites or digital interfaces
|
|
- Blurry, unclear, or dark images
|
|
- Non-card gaming items or accessories
|
|
|
|
If you detect a trading card, extract information in this JSON format:
|
|
{
|
|
"isCard": true,
|
|
"cardName": "exact card name as printed on the card",
|
|
"setName": "set name if visible",
|
|
"setCode": "set code/symbol if visible",
|
|
"cardNumber": "collector number if visible",
|
|
"game": "MTG, Pokemon, YuGiOh, Lorcana, etc.",
|
|
"cardType": "creature, instant, sorcery, trainer, etc.",
|
|
"rarity": "common, uncommon, rare, mythic, etc.",
|
|
"manaCost": "mana cost if visible",
|
|
"hp": "HP or power if visible",
|
|
"abilities": ["list of abilities or attacks if clearly readable"],
|
|
"confidence": 85,
|
|
"rawText": "all text visible on the card"
|
|
}
|
|
|
|
If NO trading card is clearly visible, respond with:
|
|
{
|
|
"isCard": false,
|
|
"confidence": 0,
|
|
"reason": "No trading card detected in image"
|
|
}
|
|
|
|
Be conservative - only extract data you can clearly read. Quality over quantity.`;
|
|
|
|
const response = await window.puter.ai.chat(prompt, imageDataUrl, {
|
|
model: "gpt-4o"
|
|
});
|
|
|
|
if (!response) {
|
|
throw new Error('No response from Puter.js');
|
|
}
|
|
|
|
// Parse JSON response
|
|
let result;
|
|
try {
|
|
// Clean up the response - remove markdown code blocks if present
|
|
const cleanContent = response.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
|
|
result = JSON.parse(cleanContent);
|
|
} catch (parseError) {
|
|
console.error('Failed to parse Puter JSON response:', response);
|
|
// Try to extract card name from raw response
|
|
const cardNameMatch = response.match(/card.*?name.*?[:"]\s*([^"'\n,}]+)/i);
|
|
result = {
|
|
isCard: !!cardNameMatch,
|
|
cardName: cardNameMatch ? cardNameMatch[1].trim() : null,
|
|
confidence: 30,
|
|
rawText: response,
|
|
reason: 'Failed to parse structured response'
|
|
};
|
|
}
|
|
|
|
// Ensure we have the required structure
|
|
return {
|
|
isCard: result.isCard || false,
|
|
cardName: result.cardName || null,
|
|
setName: result.setName || null,
|
|
setCode: result.setCode || null,
|
|
cardNumber: result.cardNumber || null,
|
|
game: result.game || null,
|
|
cardType: result.cardType || null,
|
|
rarity: result.rarity || null,
|
|
manaCost: result.manaCost || null,
|
|
hp: result.hp || null,
|
|
abilities: result.abilities || [],
|
|
flavorText: result.flavorText || null,
|
|
artist: result.artist || null,
|
|
confidence: result.confidence || 0,
|
|
rawText: result.rawText || response,
|
|
reason: result.reason || null
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('Puter.js Vision API error:', error);
|
|
|
|
// Mark auth as failed if it's an auth-related error
|
|
if (error.message.includes('authentication') || error.message.includes('auth') || error.message.includes('401')) {
|
|
this.authFailed = true;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Gemini Vision OCR using Google's Gemini API
|
|
export class GeminiVisionOCR {
|
|
constructor() {
|
|
this.apiKey = null;
|
|
}
|
|
|
|
setApiKey(apiKey) {
|
|
this.apiKey = apiKey;
|
|
}
|
|
|
|
async analyzeCard(imageDataUrl) {
|
|
if (!this.apiKey) {
|
|
throw new Error('Gemini API key not configured');
|
|
}
|
|
|
|
// Convert data URL to base64
|
|
const base64Data = imageDataUrl.split(',')[1];
|
|
if (!base64Data) {
|
|
throw new Error('Invalid image data format');
|
|
}
|
|
|
|
const prompt = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
|
|
|
|
CRITICAL: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore:
|
|
- Random objects, books, papers, phone screens
|
|
- Screenshots of websites or digital interfaces
|
|
- Blurry, unclear, or dark images
|
|
- Non-card gaming items or accessories
|
|
|
|
If you detect a trading card, extract information in this JSON format:
|
|
{
|
|
"isCard": true,
|
|
"cardName": "exact card name as printed on the card",
|
|
"setName": "set name if visible",
|
|
"setCode": "set code/symbol if visible",
|
|
"cardNumber": "collector number if visible",
|
|
"game": "MTG, Pokemon, YuGiOh, Lorcana, etc.",
|
|
"cardType": "creature, instant, sorcery, trainer, etc.",
|
|
"rarity": "common, uncommon, rare, mythic, etc.",
|
|
"manaCost": "mana cost if visible",
|
|
"hp": "HP or power if visible",
|
|
"abilities": ["list of abilities or attacks if clearly readable"],
|
|
"confidence": 85,
|
|
"rawText": "all text visible on the card"
|
|
}
|
|
|
|
If NO trading card is clearly visible, respond with:
|
|
{
|
|
"isCard": false,
|
|
"confidence": 0,
|
|
"reason": "No trading card detected in image"
|
|
}
|
|
|
|
Be conservative - only extract data you can clearly read. Quality over quantity.`;
|
|
|
|
try {
|
|
const response = await fetch('https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-goog-api-key': this.apiKey
|
|
},
|
|
body: JSON.stringify({
|
|
contents: [{
|
|
parts: [
|
|
{ text: prompt },
|
|
{
|
|
inline_data: {
|
|
mime_type: 'image/jpeg',
|
|
data: base64Data
|
|
}
|
|
}
|
|
]
|
|
}],
|
|
generationConfig: {
|
|
thinkingConfig: {
|
|
thinkingBudget: 0 // Disable thinking for faster response
|
|
}
|
|
}
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
throw new Error(`Gemini API error: ${response.status} - ${errorData.error?.message || 'Unknown error'}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
const content = data.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
|
|
if (!content) {
|
|
throw new Error('No response from Gemini API');
|
|
}
|
|
|
|
// Parse JSON response
|
|
let result;
|
|
try {
|
|
// Clean up the response - remove markdown code blocks if present
|
|
const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
|
|
result = JSON.parse(cleanContent);
|
|
} catch (parseError) {
|
|
console.error('Failed to parse Gemini JSON response:', content);
|
|
// Try to extract card name from raw response
|
|
const cardNameMatch = content.match(/card.*?name.*?[:"]\s*([^"'\n,}]+)/i);
|
|
result = {
|
|
isCard: !!cardNameMatch,
|
|
cardName: cardNameMatch ? cardNameMatch[1].trim() : null,
|
|
confidence: 30,
|
|
rawText: content,
|
|
reason: 'Failed to parse structured response'
|
|
};
|
|
}
|
|
|
|
// Ensure we have the required structure
|
|
return {
|
|
isCard: result.isCard || false,
|
|
cardName: result.cardName || null,
|
|
setName: result.setName || null,
|
|
setCode: result.setCode || null,
|
|
cardNumber: result.cardNumber || null,
|
|
game: result.game || null,
|
|
cardType: result.cardType || null,
|
|
rarity: result.rarity || null,
|
|
manaCost: result.manaCost || null,
|
|
hp: result.hp || null,
|
|
abilities: result.abilities || [],
|
|
flavorText: result.flavorText || null,
|
|
artist: result.artist || null,
|
|
confidence: result.confidence || 0,
|
|
rawText: result.rawText || content,
|
|
reason: result.reason || null
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('Gemini Vision API error:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Export singleton instances
|
|
export const aiCardOCR = new AICardOCR();
|
|
export const ollamaCardOCR = new OllamaVisionOCR();
|
|
export const puterCardOCR = new PuterVisionOCR();
|
|
export const geminiCardOCR = new GeminiVisionOCR();
|