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 { useRef } 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 { useCameraScanner } from '../lib/use-camera-scanner.js';
|
import { useCameraScanner } from '../lib/use-camera-scanner.js';
|
||||||
|
import { useScannerIdentification } from '../lib/use-scanner-identification.js';
|
||||||
import ScanDisambiguationDialog from './ScanDisambiguationDialog.js';
|
import ScanDisambiguationDialog from './ScanDisambiguationDialog.js';
|
||||||
|
|
||||||
export default function CameraScanner({ onCardScanned, onError }) {
|
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 verificationPausedRef = useRef(false);
|
||||||
const onVerifyCardRef = useRef(() => {});
|
const onVerifyCardRef = useRef(() => {});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
verificationPausedRef.current = Boolean(disambiguation);
|
|
||||||
}, [disambiguation]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
videoRef,
|
videoRef,
|
||||||
canvasRef,
|
canvasRef,
|
||||||
|
|
@ -44,252 +23,20 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
onVerifyCard: (card) => onVerifyCardRef.current(card),
|
onVerifyCard: (card) => onVerifyCardRef.current(card),
|
||||||
});
|
});
|
||||||
|
|
||||||
const emitScannedCard = async (cardTracker, imageData, finalCard, ocrMeta = {}) => {
|
const {
|
||||||
cardTracker.status = 'scanned';
|
disambiguation,
|
||||||
|
scanNotice,
|
||||||
const originalTitle = document.title;
|
submittingReview,
|
||||||
document.title = `📸 ${finalCard.name} - Card Scanner`;
|
handleDisambiguationPick,
|
||||||
setTimeout(() => {
|
handleNotInCatalog,
|
||||||
document.title = originalTitle;
|
cancelDisambiguation,
|
||||||
}, 3000);
|
} = useScannerIdentification({
|
||||||
|
onCardScanned,
|
||||||
let scanImageUrl = null;
|
onError,
|
||||||
if (imageData) {
|
videoRef,
|
||||||
try {
|
canvasRef,
|
||||||
scanImageUrl = await uploadScanCapture(imageData);
|
onVerifyCardRef,
|
||||||
} catch (uploadError) {
|
verificationPausedRef,
|
||||||
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 foundCardCount = trackedCards.filter(
|
const foundCardCount = trackedCards.filter(
|
||||||
|
|
@ -530,10 +277,7 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
submittingReview={submittingReview}
|
submittingReview={submittingReview}
|
||||||
onPick={handleDisambiguationPick}
|
onPick={handleDisambiguationPick}
|
||||||
onNotInCatalog={handleNotInCatalog}
|
onNotInCatalog={handleNotInCatalog}
|
||||||
onCancel={() => {
|
onCancel={cancelDisambiguation}
|
||||||
setDisambiguation(null);
|
|
||||||
disambiguationRefineRef.current = null;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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