refactor(scanner): extract identification hook (Brief 5) #72
2 changed files with 320 additions and 273 deletions
|
|
@ -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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
303
lib/use-scanner-identification.js
Normal file
303
lib/use-scanner-identification.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue