2026-05-27 13:59:59 -04:00
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' ;
2026-05-27 09:47:05 -04:00
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.).
2026-05-27 14:42:51 -04:00
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 .
2026-05-27 09:47:05 -04:00
2026-08-14 21:21:20 -04:00
Respond with JSON only ( no markdown fences ) using this shape :
2026-05-27 09:47:05 -04:00
{
"isCard" : true ,
2026-08-14 21:21:20 -04:00
"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." ,
2026-05-27 09:47:05 -04:00
"rarity" : "common, uncommon, rare, mythic, etc." ,
2026-08-14 21:21:20 -04:00
"manaCost" : "mana cost or null" ,
"hp" : "HP or power or null" ,
"abilities" : [ "ability strings" ] ,
2026-05-27 09:47:05 -04:00
"confidence" : 85 ,
2026-08-14 21:21:20 -04:00
"rawText" : "all visible text" ,
"reason" : null
2026-05-27 09:47:05 -04:00
}
If NO trading card is clearly visible , respond with :
{
"isCard" : false ,
2026-08-14 21:21:20 -04:00
"cardName" : null ,
"setName" : null ,
"setCode" : null ,
"cardNumber" : null ,
"game" : null ,
"cardType" : null ,
"rarity" : null ,
"manaCost" : null ,
"hp" : null ,
"abilities" : [ ] ,
2026-05-27 09:47:05 -04:00
"confidence" : 0 ,
2026-08-14 21:21:20 -04:00
"rawText" : null ,
2026-05-27 09:47:05 -04:00
"reason" : "No trading card detected in image"
}
Be conservative — only extract data you can clearly read . Quality over quantity . ` ;
2026-08-14 21:21:20 -04:00
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' ;
}
}
2026-05-27 13:59:59 -04:00
function parseVisionJson ( content ) {
2026-08-14 21:21:20 -04:00
if ( ! content || typeof content !== 'string' ) {
throw new VisionParseError ( 'Empty vision model response' ) ;
}
2026-05-27 09:47:05 -04:00
const cleanContent = content . replace ( /```json\n?/g , '' ) . replace ( /```\n?/g , '' ) . trim ( ) ;
try {
return JSON . parse ( cleanContent ) ;
} catch {
2026-08-14 21:21:20 -04:00
throw new VisionParseError ( 'Failed to parse structured vision response' ) ;
2026-05-27 09:47:05 -04:00
}
}
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 ;
}
2026-08-14 21:21:20 -04:00
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 ;
}
2026-05-27 09:47:05 -04:00
/ * *
2026-05-27 13:59:59 -04:00
* Server - side vision analysis via Vercel AI Gateway . Requires AI _GATEWAY _API _KEY .
2026-05-27 09:47:05 -04:00
* @ param { string } imageDataUrl - data : image / jpeg ; base64 , ... capture from scanner
* /
export async function analyzeCardImage ( imageDataUrl ) {
2026-05-27 13:59:59 -04:00
const apiKey = process . env . AI _GATEWAY _API _KEY ;
2026-05-27 09:47:05 -04:00
if ( ! apiKey ) {
2026-05-27 13:59:59 -04:00
throw new Error ( 'AI_GATEWAY_API_KEY is not configured on the server' ) ;
2026-05-27 09:47:05 -04:00
}
2026-05-27 13:59:59 -04:00
if ( ! imageDataUrl ? . includes ( ',' ) ) {
2026-05-27 09:47:05 -04:00
throw new Error ( 'Invalid image data format' ) ;
}
2026-08-14 21:21:20 -04:00
let response = await fetch ( GATEWAY _URL , {
2026-05-27 09:47:05 -04:00
method : 'POST' ,
headers : {
'Content-Type' : 'application/json' ,
2026-05-27 13:59:59 -04:00
Authorization : ` Bearer ${ apiKey } ` ,
2026-05-27 09:47:05 -04:00
} ,
2026-08-14 21:21:20 -04:00
body : JSON . stringify ( buildGatewayBody ( imageDataUrl ) ) ,
2026-05-27 09:47:05 -04:00
} ) ;
2026-08-14 21:21:20 -04:00
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 } ) ) ,
} ) ;
}
2026-05-27 09:47:05 -04:00
if ( ! response . ok ) {
const errorData = await response . json ( ) . catch ( ( ) => ( { } ) ) ;
2026-05-27 13:59:59 -04:00
const message = errorData . error ? . message || errorData . message || 'Unknown error' ;
throw new Error ( ` Vision API error: ${ response . status } - ${ message } ` ) ;
2026-05-27 09:47:05 -04:00
}
const data = await response . json ( ) ;
2026-05-27 13:59:59 -04:00
const content = data . choices ? . [ 0 ] ? . message ? . content ;
2026-05-27 09:47:05 -04:00
if ( ! content ) {
2026-08-14 21:21:20 -04:00
throw new VisionParseError ( 'No response from vision model' ) ;
2026-05-27 09:47:05 -04:00
}
2026-05-27 13:59:59 -04:00
const result = parseVisionJson ( content ) ;
2026-05-27 09:47:05 -04:00
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 ,
} ;
}