import { isValidCardQuad, warpCardCaptureFromVideo } from './scanner-card-warp.js'; /** Debug mode flag β€” set via localStorage or window global */ function isDebugMode() { if (typeof window === 'undefined') return false; return ( window.__SCANNER_DEBUG === true || localStorage.getItem('SCANNER_DEBUG') === 'true' ); } function debugLog(emoji, message, data) { if (!isDebugMode()) return; const timestamp = new Date().toISOString().split('T')[1].slice(0, 12); if (data !== undefined) { console.log(`[Scanner Debug ${timestamp}] ${emoji} ${message}`, data); } else { console.log(`[Scanner Debug ${timestamp}] ${emoji} ${message}`); } } /** 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, fromLayer0: result.layer === 0, }; } 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, corners = null ) { if (corners?.length === 4 && typeof document !== 'undefined' && isValidCardQuad(corners)) { try { return warpCardCaptureFromVideo(video, canvas, corners, { jpegQuality: OCR_CAPTURE_JPEG_QUALITY, }); } catch (warpError) { console.warn('Perspective warp failed, falling back to axis-aligned crop:', warpError); } } 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 fetchIdentifyByImage(imageData, authHeaders, game) { const response = await fetch('/api/scan/identify-by-image', { method: 'POST', headers: authHeaders, body: JSON.stringify({ imageData, game }), }); if (response.status === 429) { return { ok: false, rateLimited: true }; } if (!response.ok) { return { ok: false }; } const result = await response.json(); return { ok: true, result }; } /** * Layer 0: visual kNN against precomputed catalog embeddings. */ export async function tryLayer0VisualIdentify(imageData, authHeaders, game) { const startMs = Date.now(); debugLog('πŸ”', 'Layer 0 (pgvector visual) started'); const l0 = await fetchIdentifyByImage(imageData, authHeaders, game); const durationMs = Date.now() - startMs; if (!l0.ok) { debugLog('⚠️', `Layer 0 failed β†’ ${durationMs}ms`, { rateLimited: l0.rateLimited }); return { handled: false }; } if (l0.result.escalate) { debugLog('⬆️', `Layer 0 escalating β†’ ${durationMs}ms`, { reason: l0.result.escalateReason || 'low confidence' }); return { handled: false }; } debugLog('βœ…', `Layer 0 resolved β†’ ${durationMs}ms`, { card: l0.result.card?.name, matches: l0.result.matches?.length }); return { handled: true, outcome: resolveIdentifyOutcome(l0.result), }; } export async function fetchVisionIdentify(imageData, authHeaders) { const startMs = Date.now(); debugLog('πŸ€–', 'Layer 2 (Vision API) call started'); const response = await fetch('/api/scan/identify', { method: 'POST', headers: authHeaders, body: JSON.stringify({ imageData }), }); const durationMs = Date.now() - startMs; if (response.status === 429) { debugLog('🚫', `Vision API rate limited β†’ ${durationMs}ms`); return { ok: false, rateLimited: true }; } if (!response.ok) { const errBody = await response.json().catch(() => ({})); debugLog('❌', `Vision API failed (${response.status}) β†’ ${durationMs}ms`, { error: errBody.error }); throw new Error(errBody.error || `Scan identify failed: ${response.status}`); } const result = await response.json(); debugLog('βœ…', `Vision API success β†’ ${durationMs}ms`, { card: result.card?.name, needsUserSelection: result.needsUserSelection }); 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 startMs = Date.now(); debugLog('πŸ“', 'Layer 1 (Tesseract OCR + pg_trgm) started'); const { recognizeCardFields } = await import('./ocr-worker.js'); const ocrStartMs = Date.now(); const ocr = await recognizeCardFields(imageData); const ocrDurationMs = Date.now() - ocrStartMs; debugLog('πŸ”€', `OCR completed β†’ ${ocrDurationMs}ms`, { nameText: ocr.nameText, confidence: ocr.nameConfidence }); if (ocr.nameText.length < 3) { debugLog('⚠️', `Layer 1 skipped (name too short: "${ocr.nameText}") β†’ ${Date.now() - startMs}ms`); return { handled: false }; } const l1 = await fetchIdentifyByText({ ocrText: ocr.nameText, ocrConfidence: ocr.nameConfidence, cardNumber: ocr.cardNumber || undefined, authHeaders, }); const durationMs = Date.now() - startMs; if (!l1.ok || l1.result.escalate) { debugLog('⬆️', `Layer 1 escalating β†’ ${durationMs}ms`, { escalate: l1.result?.escalate }); return { handled: false }; } debugLog('βœ…', `Layer 1 resolved β†’ ${durationMs}ms`, { card: l1.result.card?.name, matches: l1.result.matches?.length }); 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 pipelineStartMs = Date.now(); debugLog('🎯', `Card verification started (tracker-${cardTracker.id})`); const imageData = captureCardRegionFromVideo( video, canvas, cardTracker.bounds, CAPTURE_MARGIN_PX, cardTracker.corners ); try { const l0 = await tryLayer0VisualIdentify(imageData, authHeaders); if (l0.handled) { debugLog('πŸŽ‰', `Pipeline complete (L0) β†’ ${Date.now() - pipelineStartMs}ms total`); return { imageData, ...l0 }; } } catch (l0Error) { console.warn('Layer-0 visual path failed, falling back to text/vision:', l0Error); debugLog('⚠️', 'Layer 0 exception', { error: l0Error.message }); } try { const l1 = await tryLayer1TextIdentify(imageData, authHeaders); if (l1.handled) { debugLog('πŸŽ‰', `Pipeline complete (L1) β†’ ${Date.now() - pipelineStartMs}ms total`); return { imageData, ...l1 }; } } catch (l1Error) { console.warn('Layer-1 OCR path failed, escalating to vision:', l1Error); debugLog('⚠️', 'Layer 1 exception', { error: l1Error.message }); } if (Date.now() < visionCooldownUntilMs) { const cooldownSecondsRemaining = Math.ceil((visionCooldownUntilMs - Date.now()) / 1000); debugLog('⏸️', `Vision cooldown active β€” retry in ${cooldownSecondsRemaining}s`); return { imageData, retry: true }; } const vision = await fetchVisionIdentify(imageData, authHeaders); if (vision.rateLimited) { debugLog('🚫', `Pipeline halted (rate limited) β†’ ${Date.now() - pipelineStartMs}ms total`); return { imageData, rateLimited: true }; } debugLog('πŸŽ‰', `Pipeline complete (L2) β†’ ${Date.now() - pipelineStartMs}ms total`); return { imageData, handled: true, outcome: resolveIdentifyOutcome(vision.result), }; }