/** Default margin (px) around tracked bounds when cropping a card capture. */ export const CAPTURE_MARGIN_PX = 20; /** JPEG quality for card crops sent to OCR / vision. */ export const OCR_CAPTURE_JPEG_QUALITY = 0.92; /** Vision rate-limit backoff duration (ms). */ export const VISION_RATE_LIMIT_MS = 60_000; export function getScanAuthHeaders() { return { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('auth_token')}`, }; } export function candidateMatchesSetHint(candidate, setName, setCode) { if (!setName && !setCode) return true; const hint = (setName || setCode || '').toLowerCase(); const setNameLower = (candidate.set_name || '').toLowerCase(); const setCodeLower = (candidate.set_code || '').toLowerCase(); return setNameLower.includes(hint) || hint.includes(setNameLower) || setCodeLower === hint; } export function buildOcrMetaFromIdentifyResult(result) { return { confidence: result.ocr?.confidence ?? result.card?.ocr?.confidence, rawText: result.ocr?.rawText ?? result.card?.ocr?.rawText, abilities: result.card?.ocr?.abilities || [], hp: result.card?.hp, manaCost: result.card?.mana_cost, cardName: result.ocr?.cardName, query: result.ocr?.query, }; } export function buildScannedCardPayload(finalCard, { imageData, scanImageUrl, ocrMeta = {} }) { return { name: finalCard.name, set: finalCard.set_name, setCode: finalCard.set_code, cardNumber: finalCard.card_number, game: finalCard.game, cardType: finalCard.card_type, rarity: finalCard.rarity, hp: finalCard.hp || ocrMeta.hp, manaCost: finalCard.mana_cost || ocrMeta.manaCost, abilities: ocrMeta.abilities || [], ocrText: ocrMeta.rawText, confidence: ocrMeta.confidence, capturedImage: imageData, scanImageUrl, image_url: finalCard.image_url, databaseId: finalCard.id, isExisting: true, }; } /** * Map a server identify response to a UI action the scanner component applies. * @returns {{ type: string, cardStatus: string, [key: string]: unknown }} */ export function resolveIdentifyOutcome(result) { if (!result.isCard) { return { type: 'error', cardStatus: 'negative', message: result.reason || 'No trading card detected', }; } const ocrMeta = buildOcrMetaFromIdentifyResult(result); if (result.card) { return { type: 'emit', cardStatus: 'confirmed', card: result.card, ocrMeta }; } if (result.needsUserSelection && result.matches?.length) { return { type: 'disambiguation', cardStatus: 'confirmed', matches: result.matches, ocrMeta, message: result.message, fromLayer1: result.layer === 1, }; } if (result.needsReview) { return { type: 'notice', cardStatus: 'confirmed', message: result.message || 'Scan saved for admin review.', }; } if (result.needsUserInput) { return { type: 'error', cardStatus: 'negative', message: result.message || 'Could not identify card — try again or submit for review.', }; } return { type: 'error', cardStatus: 'negative', message: 'Could not identify card from scan.', }; } /** * After a vision refine call during disambiguation, decide the next UI step. */ export function resolveDisambiguationRefineAction(result, { candidates }) { if (result.needsReview) { return { type: 'review_submitted', message: result.message }; } if (result.card) { return { type: 'emit', card: result.card, ocrMeta: { confidence: result.ocr?.confidence, rawText: result.ocr?.rawText, abilities: result.card?.ocr?.abilities || [], }, }; } const setHint = result.ocr?.setName || result.ocr?.setCode; if (!result.matches?.length || !setHint) { return { type: 'noop' }; } const filtered = candidates.filter((candidate) => candidateMatchesSetHint(candidate, result.ocr.setName, result.ocr.setCode) ); if (filtered.length === 0) { return { type: 'catalog_gap', setHint, submitPayload: { name: result.ocr?.cardName || candidates[0]?.name, candidateCardIds: candidates.map((c) => c.id), }, hintMessage: `No "${setHint}" printing in our catalog. Tap "My card isn't listed" to save for admin review.`, }; } if (filtered.length === 1) { return { type: 'pick', candidate: filtered[0] }; } if (filtered.length > 1 && filtered.length < candidates.length) { return { type: 'narrow', filtered, visionHint: setHint, message: `Narrowed to ${filtered.length} printings matching "${setHint}".`, }; } return { type: 'noop' }; } /** Crop a tracked card region from the live video frame; returns a JPEG data URL. */ export function captureCardRegionFromVideo(video, canvas, bounds, margin = CAPTURE_MARGIN_PX) { const ctx = canvas.getContext('2d'); const { x, y, width, height } = bounds; canvas.width = width + margin * 2; canvas.height = height + margin * 2; ctx.drawImage( video, Math.max(0, x - margin), Math.max(0, y - margin), width + margin * 2, height + margin * 2, 0, 0, canvas.width, canvas.height ); return canvas.toDataURL('image/jpeg', OCR_CAPTURE_JPEG_QUALITY); } export async function submitScanForReview({ imageData, name, candidateCardIds, authHeaders }) { const response = await fetch('/api/scan/submit-for-review', { method: 'POST', headers: authHeaders, body: JSON.stringify({ imageData, name, candidateCardIds }), }); if (response.status === 429) { return { ok: false, rateLimited: true }; } if (!response.ok) { const errBody = await response.json().catch(() => ({})); throw new Error(errBody.error || 'Failed to submit scan for review'); } const result = await response.json(); return { ok: true, message: result.message }; } export async function fetchIdentifyByText({ ocrText, ocrConfidence, cardNumber, authHeaders }) { const response = await fetch('/api/cards/identify-by-text', { method: 'POST', headers: authHeaders, body: JSON.stringify({ ocrText, ocrConfidence, cardNumber }), }); if (!response.ok) { return { ok: false }; } const result = await response.json(); return { ok: true, result }; } export async function fetchVisionIdentify(imageData, authHeaders) { const response = await fetch('/api/scan/identify', { method: 'POST', headers: authHeaders, body: JSON.stringify({ imageData }), }); if (response.status === 429) { return { ok: false, rateLimited: true }; } if (!response.ok) { const errBody = await response.json().catch(() => ({})); throw new Error(errBody.error || `Scan identify failed: ${response.status}`); } const result = await response.json(); return { ok: true, result }; } /** * Layer 1: local OCR name strip + pg_trgm catalog match. * Returns { handled: true, outcome } when L1 resolves without escalation. */ export async function tryLayer1TextIdentify(imageData, authHeaders) { const { recognizeCardFields } = await import('./ocr-worker.js'); const ocr = await recognizeCardFields(imageData); if (ocr.nameText.length < 3) { return { handled: false }; } const l1 = await fetchIdentifyByText({ ocrText: ocr.nameText, ocrConfidence: ocr.nameConfidence, cardNumber: ocr.cardNumber || undefined, authHeaders, }); if (!l1.ok || l1.result.escalate) { return { handled: false }; } return { handled: true, outcome: resolveIdentifyOutcome(l1.result), }; } /** * Run Layer 1 then Layer 2 identification for a tracked card capture. */ export async function identifyTrackedCardCapture({ video, canvas, cardTracker, authHeaders, visionCooldownUntilMs = 0, }) { const imageData = captureCardRegionFromVideo(video, canvas, cardTracker.bounds); try { const l1 = await tryLayer1TextIdentify(imageData, authHeaders); if (l1.handled) { return { imageData, ...l1 }; } } catch (l1Error) { console.warn('Layer-1 OCR path failed, escalating to vision:', l1Error); } if (Date.now() < visionCooldownUntilMs) { return { imageData, retry: true }; } const vision = await fetchVisionIdentify(imageData, authHeaders); if (vision.rateLimited) { return { imageData, rateLimited: true }; } return { imageData, handled: true, outcome: resolveIdentifyOutcome(vision.result), }; }