From e474a0ae32305a8d6b561ba5853b8d9eb2fce816 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Tue, 2 Jun 2026 15:45:53 -0500 Subject: [PATCH] refactor(scanner): extract identification hook (Brief 5) Move verify/disambiguation/review flow into lib/use-scanner-identification.js. CameraScanner is now a thin composition of two hooks plus view markup. Co-authored-by: Cursor --- components/CameraScanner.js | 290 ++-------------------------- lib/use-scanner-identification.js | 303 ++++++++++++++++++++++++++++++ 2 files changed, 320 insertions(+), 273 deletions(-) create mode 100644 lib/use-scanner-identification.js diff --git a/components/CameraScanner.js b/components/CameraScanner.js index f4f4e91..1f8a088 100644 --- a/components/CameraScanner.js +++ b/components/CameraScanner.js @@ -1,33 +1,12 @@ -import { useEffect, useRef, useState } from 'react'; -import { rateLimitCooldownUntil, uploadScanCapture } from '../lib/scan-capture-upload.js'; -import { - buildScannedCardPayload, - getScanAuthHeaders, - identifyTrackedCardCapture, - resolveDisambiguationRefineAction, - submitScanForReview, - VISION_RATE_LIMIT_MS, - fetchVisionIdentify, -} from '../lib/scanner-card-identify.js'; +import { useRef } from 'react'; import { useCameraScanner } from '../lib/use-camera-scanner.js'; +import { useScannerIdentification } from '../lib/use-scanner-identification.js'; import ScanDisambiguationDialog from './ScanDisambiguationDialog.js'; export default function CameraScanner({ onCardScanned, onError }) { - const [disambiguation, setDisambiguation] = useState(null); - 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); const verificationPausedRef = useRef(false); const onVerifyCardRef = useRef(() => {}); - useEffect(() => { - verificationPausedRef.current = Boolean(disambiguation); - }, [disambiguation]); - const { videoRef, canvasRef, @@ -44,252 +23,20 @@ export default function CameraScanner({ onCardScanned, onError }) { onVerifyCard: (card) => onVerifyCardRef.current(card), }); - 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); - - let scanImageUrl = null; - if (imageData) { - try { - scanImageUrl = await uploadScanCapture(imageData); - } catch (uploadError) { - console.warn('Scan image upload failed:', uploadError); - } - } - - onCardScanned(buildScannedCardPayload(finalCard, { imageData, scanImageUrl, ocrMeta })); - }; - - const handleDisambiguationPick = async (candidate) => { - if (!disambiguation) return; - const { cardTracker, imageData, ocrMeta } = disambiguation; - await emitScannedCard(cardTracker, imageData, candidate, ocrMeta); - setDisambiguation(null); - disambiguationRefineRef.current = null; - }; - - const showScanNotice = (message) => { - setScanNotice(message); - setTimeout(() => setScanNotice(null), 8000); - }; - - const handleReviewSubmitted = (cardTracker, message) => { - if (cardTracker) cardTracker.status = 'confirmed'; - setDisambiguation(null); - disambiguationRefineRef.current = null; - showScanNotice(message); - }; - - 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); - } - }; - - const applyIdentifyOutcome = async (cardTracker, imageData, outcome) => { - cardTracker.status = outcome.cardStatus; - - switch (outcome.type) { - case 'emit': - await emitScannedCard(cardTracker, imageData, outcome.card, outcome.ocrMeta); - break; - case 'disambiguation': - setDisambiguation({ - cardTracker, - imageData, - candidates: outcome.matches, - ocrMeta: outcome.ocrMeta, - message: outcome.message, - }); - break; - case 'notice': - showScanNotice(outcome.message); - break; - case 'error': - reportScannerError(outcome.message); - break; - default: - break; - } - }; - - const reportScannerError = (message) => { - if (disambiguation) return; - const now = Date.now(); - if (now - lastErrorAtRef.current < 4000) return; - lastErrorAtRef.current = now; - onError?.(message); - }; - - useEffect(() => { - if (!disambiguation?.imageData) 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; - - activeVerificationRef.current += 1; - cardTracker.status = 'verifying'; - - try { - cardTracker.scanAttempts++; - - const identification = await identifyTrackedCardCapture({ - video: videoRef.current, - canvas: canvasRef.current, - cardTracker, - authHeaders: getScanAuthHeaders(), - visionCooldownUntilMs: visionCooldownUntilRef.current, - }); - - if (identification.retry) { - cardTracker.status = 'detecting'; - return; - } - - if (identification.rateLimited) { - visionCooldownUntilRef.current = Date.now() + VISION_RATE_LIMIT_MS; - reportScannerError('Too many scan attempts. Please wait a moment and try again.'); - cardTracker.status = 'negative'; - return; - } - - if (identification.handled && identification.outcome) { - cardTracker.status = 'confirmed'; - await applyIdentifyOutcome(cardTracker, identification.imageData, identification.outcome); - } - } catch (error) { - console.error(`Error verifying card ${cardTracker.id}:`, error); - cardTracker.status = 'negative'; - reportScannerError(error.message || 'Scan failed'); - } finally { - activeVerificationRef.current = Math.max(0, activeVerificationRef.current - 1); - } - }; - - useEffect(() => { - onVerifyCardRef.current = verifyCardShape; + const { + disambiguation, + scanNotice, + submittingReview, + handleDisambiguationPick, + handleNotInCatalog, + cancelDisambiguation, + } = useScannerIdentification({ + onCardScanned, + onError, + videoRef, + canvasRef, + onVerifyCardRef, + verificationPausedRef, }); const foundCardCount = trackedCards.filter( @@ -530,10 +277,7 @@ export default function CameraScanner({ onCardScanned, onError }) { submittingReview={submittingReview} onPick={handleDisambiguationPick} onNotInCatalog={handleNotInCatalog} - onCancel={() => { - setDisambiguation(null); - disambiguationRefineRef.current = null; - }} + onCancel={cancelDisambiguation} /> ); diff --git a/lib/use-scanner-identification.js b/lib/use-scanner-identification.js new file mode 100644 index 0000000..28c1c38 --- /dev/null +++ b/lib/use-scanner-identification.js @@ -0,0 +1,303 @@ +import { useEffect, useRef, useState } from 'react'; +import { rateLimitCooldownUntil, uploadScanCapture } from './scan-capture-upload.js'; +import { + buildScannedCardPayload, + getScanAuthHeaders, + identifyTrackedCardCapture, + resolveDisambiguationRefineAction, + submitScanForReview, + VISION_RATE_LIMIT_MS, + fetchVisionIdentify, +} from './scanner-card-identify.js'; + +/** + * Card identification, disambiguation, and review-submission flow for the scanner. + * Camera refs and verify-card wiring are supplied by the parent + useCameraScanner. + */ +export function useScannerIdentification({ + onCardScanned, + onError, + videoRef, + canvasRef, + onVerifyCardRef, + verificationPausedRef, +}) { + const [disambiguation, setDisambiguation] = useState(null); + 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 emitScannedCard = async (cardTracker, imageData, finalCard, ocrMeta = {}) => { + cardTracker.status = 'scanned'; + + const originalTitle = document.title; + document.title = `📸 ${finalCard.name} - Card Scanner`; + setTimeout(() => { + document.title = originalTitle; + }, 3000); + + let scanImageUrl = null; + if (imageData) { + try { + scanImageUrl = await uploadScanCapture(imageData); + } catch (uploadError) { + console.warn('Scan image upload failed:', uploadError); + } + } + + onCardScanned(buildScannedCardPayload(finalCard, { imageData, scanImageUrl, ocrMeta })); + }; + + const showScanNotice = (message) => { + setScanNotice(message); + setTimeout(() => setScanNotice(null), 8000); + }; + + const handleReviewSubmitted = (cardTracker, message) => { + if (cardTracker) cardTracker.status = 'confirmed'; + 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 = () => { + 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) => { + cardTracker.status = outcome.cardStatus; + + switch (outcome.type) { + case 'emit': + await emitScannedCard(cardTracker, imageData, outcome.card, outcome.ocrMeta); + break; + case 'disambiguation': + setDisambiguation({ + cardTracker, + imageData, + candidates: outcome.matches, + ocrMeta: outcome.ocrMeta, + message: outcome.message, + }); + break; + case 'notice': + showScanNotice(outcome.message); + break; + case 'error': + 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 (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; + + activeVerificationRef.current += 1; + cardTracker.status = 'verifying'; + + try { + cardTracker.scanAttempts++; + + const identification = await identifyTrackedCardCapture({ + video: videoRef.current, + canvas: canvasRef.current, + cardTracker, + authHeaders: getScanAuthHeaders(), + visionCooldownUntilMs: visionCooldownUntilRef.current, + }); + + if (identification.retry) { + cardTracker.status = 'detecting'; + return; + } + + if (identification.rateLimited) { + visionCooldownUntilRef.current = Date.now() + VISION_RATE_LIMIT_MS; + reportScannerError('Too many scan attempts. Please wait a moment and try again.'); + cardTracker.status = 'negative'; + return; + } + + if (identification.handled && identification.outcome) { + cardTracker.status = 'confirmed'; + await applyIdentifyOutcome(cardTracker, identification.imageData, identification.outcome); + } + } catch (error) { + console.error(`Error verifying card ${cardTracker.id}:`, error); + cardTracker.status = 'negative'; + reportScannerError(error.message || 'Scan failed'); + } finally { + activeVerificationRef.current = Math.max(0, activeVerificationRef.current - 1); + } + }; + + useEffect(() => { + if (onVerifyCardRef) { + onVerifyCardRef.current = verifyCardShape; + } + }); + + return { + disambiguation, + scanNotice, + submittingReview, + handleDisambiguationPick, + handleNotInCatalog, + cancelDisambiguation, + }; +} -- 2.45.2