const GATEWAY_URL = 'https://ai-gateway.vercel.sh/v1/chat/completions'; const DEFAULT_VISION_MODEL = process.env.SCAN_VISION_MODEL || 'google/gemini-2.5-flash-lite'; const CARD_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.). HOLOGRAPHIC / FOIL CARDS: Many cards have reflective foil surfaces with glare or rainbow streaks. Do NOT reject these as "not a card" — read through glare when possible and extract any visible name, set, and collector number. 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, and non-card gaming items. Blurry images with no readable card frame should be rejected. 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, or lorcana (lowercase)", "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.`; function parseVisionJson(content) { const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim(); try { return JSON.parse(cleanContent); } catch { const cardNameMatch = content.match(/card.*?name.*?[:"]\s*([^"'\n,}]+)/i); return { isCard: !!cardNameMatch, cardName: cardNameMatch ? cardNameMatch[1].trim() : null, confidence: 30, rawText: content, reason: 'Failed to parse structured response', }; } } function normalizeGame(game) { if (!game) return null; const value = String(game).trim().toLowerCase(); if (value === 'mtg' || value.includes('magic')) return 'mtg'; if (value.includes('pokemon') || value.includes('pokémon')) return 'pokemon'; if (value.includes('lorcana')) return 'lorcana'; return value; } /** * Server-side vision analysis via Vercel AI Gateway. Requires AI_GATEWAY_API_KEY. * @param {string} imageDataUrl - data:image/jpeg;base64,... capture from scanner */ export async function analyzeCardImage(imageDataUrl) { const apiKey = process.env.AI_GATEWAY_API_KEY; if (!apiKey) { throw new Error('AI_GATEWAY_API_KEY is not configured on the server'); } if (!imageDataUrl?.includes(',')) { throw new Error('Invalid image data format'); } const response = await fetch(GATEWAY_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ model: DEFAULT_VISION_MODEL, temperature: 0.1, messages: [{ role: 'user', content: [ { type: 'text', text: CARD_PROMPT }, { type: 'image_url', image_url: { url: imageDataUrl } }, ], }], }), }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); const message = errorData.error?.message || errorData.message || 'Unknown error'; throw new Error(`Vision API error: ${response.status} - ${message}`); } const data = await response.json(); const content = data.choices?.[0]?.message?.content; if (!content) { throw new Error('No response from vision model'); } const result = parseVisionJson(content); return { isCard: result.isCard || false, cardName: result.cardName || null, setName: result.setName || null, setCode: result.setCode || null, cardNumber: result.cardNumber || null, game: normalizeGame(result.game), cardType: result.cardType || null, rarity: result.rarity || null, manaCost: result.manaCost || null, hp: result.hp || null, abilities: result.abilities || [], confidence: result.confidence || 0, rawText: result.rawText || content, reason: result.reason || null, }; }