130 lines
4.1 KiB
JavaScript
130 lines
4.1 KiB
JavaScript
|
|
const GEMINI_MODEL =
|
||
|
|
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
|
||
|
|
|
||
|
|
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.).
|
||
|
|
|
||
|
|
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, blurry images, and non-card gaming items.
|
||
|
|
|
||
|
|
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 parseGeminiJson(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 Gemini Vision analysis. Requires GEMINI_AI_API_KEY in env.
|
||
|
|
* @param {string} imageDataUrl - data:image/jpeg;base64,... capture from scanner
|
||
|
|
*/
|
||
|
|
export async function analyzeCardImage(imageDataUrl) {
|
||
|
|
const apiKey = process.env.GEMINI_AI_API_KEY;
|
||
|
|
if (!apiKey) {
|
||
|
|
throw new Error('GEMINI_AI_API_KEY is not configured on the server');
|
||
|
|
}
|
||
|
|
|
||
|
|
const base64Data = imageDataUrl.split(',')[1];
|
||
|
|
if (!base64Data) {
|
||
|
|
throw new Error('Invalid image data format');
|
||
|
|
}
|
||
|
|
|
||
|
|
const response = await fetch(GEMINI_MODEL, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: {
|
||
|
|
'Content-Type': 'application/json',
|
||
|
|
'x-goog-api-key': apiKey,
|
||
|
|
},
|
||
|
|
body: JSON.stringify({
|
||
|
|
contents: [{
|
||
|
|
parts: [
|
||
|
|
{ text: CARD_PROMPT },
|
||
|
|
{
|
||
|
|
inline_data: {
|
||
|
|
mime_type: 'image/jpeg',
|
||
|
|
data: base64Data,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
],
|
||
|
|
}],
|
||
|
|
generationConfig: {
|
||
|
|
temperature: 0.1,
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
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');
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = parseGeminiJson(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,
|
||
|
|
};
|
||
|
|
}
|