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. Respond with JSON only (no markdown fences) using this shape: { "isCard": true, "cardName": "exact card name", "setName": "set name or null", "setCode": "set code or null", "cardNumber": "collector number or null", "game": "mtg, pokemon, or lorcana", "cardType": "creature, instant, etc.", "rarity": "common, uncommon, rare, mythic, etc.", "manaCost": "mana cost or null", "hp": "HP or power or null", "abilities": ["ability strings"], "confidence": 85, "rawText": "all visible text", "reason": null } If NO trading card is clearly visible, respond with: { "isCard": false, "cardName": null, "setName": null, "setCode": null, "cardNumber": null, "game": null, "cardType": null, "rarity": null, "manaCost": null, "hp": null, "abilities": [], "confidence": 0, "rawText": null, "reason": "No trading card detected in image" } Be conservative — only extract data you can clearly read. Quality over quantity.`; const CARD_VISION_SCHEMA = { name: 'card_scan_result', strict: true, schema: { type: 'object', additionalProperties: false, properties: { isCard: { type: 'boolean' }, cardName: { type: ['string', 'null'] }, setName: { type: ['string', 'null'] }, setCode: { type: ['string', 'null'] }, cardNumber: { type: ['string', 'null'] }, game: { type: ['string', 'null'] }, cardType: { type: ['string', 'null'] }, rarity: { type: ['string', 'null'] }, manaCost: { type: ['string', 'null'] }, hp: { type: ['string', 'null'] }, abilities: { type: 'array', items: { type: 'string' }, }, confidence: { type: 'number' }, rawText: { type: ['string', 'null'] }, reason: { type: ['string', 'null'] }, }, required: [ 'isCard', 'cardName', 'setName', 'setCode', 'cardNumber', 'game', 'cardType', 'rarity', 'manaCost', 'hp', 'abilities', 'confidence', 'rawText', 'reason', ], }, }; export class VisionParseError extends Error { constructor(message) { super(message); this.name = 'VisionParseError'; } } function parseVisionJson(content) { if (!content || typeof content !== 'string') { throw new VisionParseError('Empty vision model response'); } const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim(); try { return JSON.parse(cleanContent); } catch { throw new VisionParseError('Failed to parse structured vision 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; } function buildGatewayBody(imageDataUrl, { structured = true } = {}) { const body = { model: DEFAULT_VISION_MODEL, temperature: 0.1, messages: [{ role: 'user', content: [ { type: 'text', text: CARD_PROMPT }, { type: 'image_url', image_url: { url: imageDataUrl } }, ], }], }; if (structured) { body.response_format = { type: 'json_schema', json_schema: CARD_VISION_SCHEMA, }; } return body; } /** * 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'); } let response = await fetch(GATEWAY_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify(buildGatewayBody(imageDataUrl)), }); if (!response.ok && response.status === 400) { response = await fetch(GATEWAY_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify(buildGatewayBody(imageDataUrl, { structured: false })), }); } 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 VisionParseError('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, }; }