import { useEffect, useRef, useState } from 'react'; import { rateLimitCooldownUntil, uploadScanCapture } from './scan-capture-upload.js'; import { buildScannedCardPayload, getScanAuthHeaders, identifyTrackedCardCapture, resolveDisambiguationRefineAction, resolveIdentifyOutcome, submitScanForReview, tryLayer0VisualIdentify, tryLayer1TextIdentify, VISION_RATE_LIMIT_MS, fetchVisionIdentify, } from './scanner-card-identify.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}`); } } /** * Card identification, disambiguation, and review-submission flow for the scanner. * Camera refs and verify-card wiring are supplied by the parent + useCameraScanner. */ function readFileToImageData(file) { return new Promise((resolve, reject) => { const img = new Image(); const url = URL.createObjectURL(file); img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; const ctx = canvas.getContext('2d', { willReadFrequently: true }); ctx.drawImage(img, 0, 0); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); URL.revokeObjectURL(url); resolve(imageData); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Failed to load image from gallery')); }; img.src = url; }); } export function useScannerIdentification({ onCardScanned, onError, onTrackerComplete, onTrackerReset, videoRef, canvasRef, onVerifyCardRef, verificationPausedRef, }) { const [disambiguation, setDisambiguation] = useState(null); const [isIdentifying, setIsIdentifying] = useState(false); const [scanNotice, setScanNotice] = useState(null); const [submittingReview, setSubmittingReview] = useState(false); const visionCooldownUntilRef = useRef(0); const activeVerificationRef = useRef(0); const lastErrorAtRef = useRef(0); const disambiguationRefineRef = useRef(null); useEffect(() => { if (verificationPausedRef) { verificationPausedRef.current = Boolean(disambiguation); } }, [disambiguation, verificationPausedRef]); const finishTrackedCard = (cardTracker) => { if (!cardTracker?.id) return; onTrackerComplete?.(cardTracker.id); }; const emitScannedCard = async (cardTracker, imageData, finalCard, ocrMeta = {}) => { cardTracker.status = 'scanned'; const originalTitle = document.title; document.title = `πŸ“Έ ${finalCard.name} - Card Scanner`; setTimeout(() => { document.title = originalTitle; }, 3000); // Show result immediately β€” don't block on upload onCardScanned(buildScannedCardPayload(finalCard, { imageData, scanImageUrl: null, ocrMeta })); finishTrackedCard(cardTracker); // Upload capture in background (fire-and-forget) if (imageData) { uploadScanCapture(imageData) .then((url) => { if (debugLog && isDebugMode) { debugLog('πŸ“€', `Background upload complete: ${url ? 'success' : '429'}`); } }) .catch((uploadError) => { console.warn('Scan image upload failed:', uploadError); }); } }; const showScanNotice = (message) => { setScanNotice(message); setTimeout(() => setScanNotice(null), 8000); }; const handleReviewSubmitted = (cardTracker, message) => { if (cardTracker) { cardTracker.status = 'confirmed'; finishTrackedCard(cardTracker); } setDisambiguation(null); disambiguationRefineRef.current = null; showScanNotice(message); }; const handleDisambiguationPick = async (candidate) => { if (!disambiguation) return; const { cardTracker, imageData, ocrMeta } = disambiguation; await emitScannedCard(cardTracker, imageData, candidate, ocrMeta); setDisambiguation(null); disambiguationRefineRef.current = null; }; const cancelDisambiguation = () => { const { cardTracker } = disambiguation ?? {}; if (cardTracker) { onTrackerReset?.(cardTracker.id); } setDisambiguation(null); disambiguationRefineRef.current = null; }; const reportScannerError = (message) => { if (disambiguation) return; const now = Date.now(); if (now - lastErrorAtRef.current < 4000) return; lastErrorAtRef.current = now; onError?.(message); }; const applyIdentifyOutcome = async (cardTracker, imageData, outcome) => { switch (outcome.type) { case 'emit': cardTracker.status = outcome.cardStatus; await emitScannedCard(cardTracker, imageData, outcome.card, outcome.ocrMeta); break; case 'disambiguation': cardTracker.status = 'verifying'; setDisambiguation({ cardTracker, imageData, candidates: outcome.matches, ocrMeta: outcome.ocrMeta, message: outcome.message, fromLayer1: Boolean(outcome.fromLayer1), fromLayer0: Boolean(outcome.fromLayer0), }); break; case 'notice': cardTracker.status = outcome.cardStatus; showScanNotice(outcome.message); finishTrackedCard(cardTracker); break; case 'error': cardTracker.status = outcome.cardStatus; cardTracker.negativeAt = Date.now(); reportScannerError(outcome.message); break; default: break; } }; const handleNotInCatalog = async () => { if (!disambiguation || submittingReview) return; setSubmittingReview(true); const { cardTracker, imageData, candidates, ocrMeta } = disambiguation; const guessedName = ocrMeta?.cardName || ocrMeta?.query || candidates?.[0]?.name || null; try { const result = await submitScanForReview({ imageData, name: guessedName, candidateCardIds: (candidates || []).map((c) => c.id), authHeaders: getScanAuthHeaders(), }); if (result.rateLimited) { visionCooldownUntilRef.current = rateLimitCooldownUntil(VISION_RATE_LIMIT_MS); reportScannerError('Too many scan attempts. Please wait a moment and try again.'); return; } handleReviewSubmitted(cardTracker, result.message); } catch (error) { reportScannerError(error.message || 'Failed to submit scan for review'); } finally { setSubmittingReview(false); } }; useEffect(() => { if (!disambiguation?.imageData) return; if (disambiguation.fromLayer1 || disambiguation.fromLayer0) return; if (Date.now() < visionCooldownUntilRef.current) return; const refineKey = disambiguation.cardTracker?.id ?? 'modal'; if (disambiguationRefineRef.current === refineKey) return; disambiguationRefineRef.current = refineKey; let cancelled = false; (async () => { try { const authHeaders = getScanAuthHeaders(); const vision = await fetchVisionIdentify(disambiguation.imageData, authHeaders); if (cancelled) return; if (vision.rateLimited) { visionCooldownUntilRef.current = Date.now() + VISION_RATE_LIMIT_MS; return; } const action = resolveDisambiguationRefineAction(vision.result, { candidates: disambiguation.candidates, }); if (cancelled) return; switch (action.type) { case 'review_submitted': handleReviewSubmitted(disambiguation.cardTracker, action.message); break; case 'emit': await emitScannedCard( disambiguation.cardTracker, disambiguation.imageData, action.card, action.ocrMeta ); setDisambiguation(null); disambiguationRefineRef.current = null; break; case 'pick': await handleDisambiguationPick(action.candidate); break; case 'catalog_gap': try { const submitResult = await submitScanForReview({ imageData: disambiguation.imageData, ...action.submitPayload, authHeaders, }); if (submitResult.ok) { handleReviewSubmitted(disambiguation.cardTracker, submitResult.message); } } catch { setDisambiguation((current) => current ? { ...current, visionHint: action.setHint, message: action.hintMessage, } : current ); } break; case 'narrow': setDisambiguation((current) => current ? { ...current, candidates: action.filtered, message: action.message, visionHint: action.visionHint, } : current ); break; default: break; } } catch (error) { console.warn('Disambiguation vision refine failed:', error); } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps -- refine runs per disambiguation session, not per handler identity }, [disambiguation?.cardTracker?.id, disambiguation?.imageData]); const verifyCardShape = async (cardTracker) => { if (!videoRef.current || !canvasRef.current || cardTracker.status !== 'detecting') return; if (disambiguation) return; if (activeVerificationRef.current >= 1) return; const verifyStartMs = Date.now(); debugLog('🎯', `Shutter pressed (tracker-${cardTracker.id}, attempt ${cardTracker.scanAttempts + 1})`); activeVerificationRef.current += 1; cardTracker.status = 'verifying'; setIsIdentifying(true); try { cardTracker.scanAttempts++; const identification = await identifyTrackedCardCapture({ video: videoRef.current, canvas: canvasRef.current, cardTracker, authHeaders: getScanAuthHeaders(), visionCooldownUntilMs: visionCooldownUntilRef.current, }); if (identification.retry) { debugLog('⏸️', `Verification skipped (cooldown active) β†’ ${Date.now() - verifyStartMs}ms`); cardTracker.status = 'detecting'; return; } if (identification.rateLimited) { visionCooldownUntilRef.current = Date.now() + VISION_RATE_LIMIT_MS; const cooldownUntil = new Date(visionCooldownUntilRef.current).toISOString().split('T')[1].slice(0, 8); debugLog('🚫', `Rate limit hit (15/min) β€” cooldown until ${cooldownUntil} β†’ ${Date.now() - verifyStartMs}ms`); reportScannerError('Too many scan attempts. Please wait a moment and try again.'); cardTracker.status = 'negative'; cardTracker.negativeAt = Date.now(); return; } if (identification.handled && identification.outcome) { cardTracker.status = 'confirmed'; debugLog('βœ…', `Card verified successfully β†’ ${Date.now() - verifyStartMs}ms total`, { outcome: identification.outcome.type }); await applyIdentifyOutcome(cardTracker, identification.imageData, identification.outcome); } else { debugLog('❌', `Verification failed (no outcome) β†’ ${Date.now() - verifyStartMs}ms`); cardTracker.status = 'negative'; cardTracker.negativeAt = Date.now(); } } catch (error) { console.error(`Error verifying card ${cardTracker.id}:`, error); debugLog('πŸ’₯', `Verification exception β†’ ${Date.now() - verifyStartMs}ms`, { error: error.message }); cardTracker.status = 'negative'; cardTracker.negativeAt = Date.now(); reportScannerError(error.message || 'Scan failed'); } finally { activeVerificationRef.current = Math.max(0, activeVerificationRef.current - 1); setIsIdentifying(activeVerificationRef.current > 0); } }; useEffect(() => { if (onVerifyCardRef) { onVerifyCardRef.current = verifyCardShape; } }); const identifyFromGalleryFile = async (file) => { if (!file || verificationPausedRef?.current) return; setIsIdentifying(true); try { const imageData = await readFileToImageData(file); const authHeaders = getScanAuthHeaders(); const syntheticTracker = { id: `gallery-${Date.now()}`, status: 'verifying' }; const l0 = await tryLayer0VisualIdentify(imageData, authHeaders); if (l0.handled && l0.outcome) { await applyIdentifyOutcome(syntheticTracker, imageData, l0.outcome); return; } const result = await tryLayer1TextIdentify(imageData, authHeaders); if (result.handled && result.outcome) { await applyIdentifyOutcome(syntheticTracker, imageData, result.outcome); return; } if (Date.now() < visionCooldownUntilRef.current) { reportScannerError('Too many scan attempts. Please wait a moment and try again.'); return; } const vision = await fetchVisionIdentify(imageData, authHeaders); if (vision.rateLimited) { visionCooldownUntilRef.current = Date.now() + VISION_RATE_LIMIT_MS; reportScannerError('Too many scan attempts. Please wait a moment and try again.'); return; } if (vision.ok && vision.result) { await applyIdentifyOutcome( syntheticTracker, imageData, resolveIdentifyOutcome(vision.result) ); return; } reportScannerError('Could not identify card from gallery image'); } catch (error) { reportScannerError(error.message || 'Gallery identify failed'); } finally { setIsIdentifying(activeVerificationRef.current > 0); } }; return { disambiguation, isIdentifying, scanNotice, submittingReview, handleDisambiguationPick, handleNotInCatalog, cancelDisambiguation, identifyFromGalleryFile, }; }