Fix scanner multi-card flow, frame overlay, and rate limits. (#166)

Allow consecutive scans without refresh by resetting trackers and counting vision rate limits once per card. Add a fixed card guide, widen detection bounds, and correct object-cover overlay math.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
varutasu 2026-08-15 18:48:05 -05:00 committed by GitHub
parent 938c161a26
commit 28bdd6aa5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 470 additions and 160 deletions

View file

@ -6,27 +6,63 @@ import ScannerScanPeek from './ScannerScanPeek.js';
import ScannerDisambiguation from './ScannerDisambiguation.js'; import ScannerDisambiguation from './ScannerDisambiguation.js';
import { useScannerSound } from '../../lib/use-scanner-sound.js'; import { useScannerSound } from '../../lib/use-scanner-sound.js';
import { useScannerFlash } from '../../lib/use-scanner-flash.js'; import { useScannerFlash } from '../../lib/use-scanner-flash.js';
import {
cardGuideToContainerStyle,
computeObjectCoverLayout,
videoBoundsToContainerStyle,
} from '../../lib/scanner-video-layout.js';
const TOAST_DURATION_MS = 2500; const TOAST_DURATION_MS = 2500;
function DetectionFrame({ card, videoMetrics }) { function CardGuideFrame({ layout }) {
if (!layout) return null;
const style = cardGuideToContainerStyle(layout);
return (
<div
className="absolute pointer-events-none scan-guide"
aria-hidden="true"
style={style}
>
<span className="scan-bracket scan-bracket-tl" />
<span className="scan-bracket scan-bracket-tr" />
<span className="scan-bracket scan-bracket-bl" />
<span className="scan-bracket scan-bracket-br" />
<style jsx>{`
.scan-bracket {
position: absolute;
width: 28px;
height: 28px;
border-color: color-mix(in srgb, var(--text-primary) 70%, transparent);
border-style: solid;
}
.scan-bracket-tl { top: 0; left: 0; border-width: 2px 0 0 2px; }
.scan-bracket-tr { top: 0; right: 0; border-width: 2px 2px 0 0; }
.scan-bracket-bl { bottom: 0; left: 0; border-width: 0 0 2px 2px; }
.scan-bracket-br { bottom: 0; right: 0; border-width: 0 2px 2px 0; }
`}</style>
</div>
);
}
function DetectionFrame({ card, layout }) {
const label = const label =
card.status === 'scanned' card.status === 'scanned'
? `Card ${card.id} scanned` ? `Card ${card.id} scanned`
: `Card ${card.id} identified`; : `Card ${card.id} identified`;
if (!layout) return null;
const style = videoBoundsToContainerStyle(card.bounds, layout);
return ( return (
<div <div
className="absolute pointer-events-none scan-frame" className="absolute pointer-events-none scan-frame"
role="status" role="status"
aria-live="polite" aria-live="polite"
aria-label={label} aria-label={label}
style={{ style={style}
left: `${(card.bounds.x / videoMetrics.width) * 100}%`,
top: `${(card.bounds.y / videoMetrics.height) * 100}%`,
width: `${(card.bounds.width / videoMetrics.width) * 100}%`,
height: `${(card.bounds.height / videoMetrics.height) * 100}%`,
}}
> >
<span className="scan-bracket scan-bracket-tl" aria-hidden="true" /> <span className="scan-bracket scan-bracket-tl" aria-hidden="true" />
<span className="scan-bracket scan-bracket-tr" aria-hidden="true" /> <span className="scan-bracket scan-bracket-tr" aria-hidden="true" />
@ -118,6 +154,58 @@ function ChromeIconButton({
); );
} }
function CameraViewport({
viewportRef,
videoRef,
isStreaming,
displayLayout,
foundCards,
}) {
return (
<div
ref={viewportRef}
className="absolute inset-0"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
>
<video
ref={videoRef}
className="absolute inset-0 w-full h-full object-cover"
style={{ display: isStreaming ? 'block' : 'none' }}
autoPlay
playsInline
muted
aria-label="Card scanner camera feed"
/>
{isStreaming && displayLayout && <CardGuideFrame layout={displayLayout} />}
{isStreaming &&
displayLayout &&
foundCards.map((card) => (
<DetectionFrame key={card.id} card={card} layout={displayLayout} />
))}
{!isStreaming && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div
className="w-10 h-10 rounded-full border-2 border-t-transparent animate-spin"
style={{ borderColor: 'var(--accent-ember)', borderTopColor: 'transparent' }}
aria-hidden="true"
/>
<span
className="text-sm font-medium"
style={{ color: 'var(--text-secondary)' }}
>
Starting camera
</span>
</div>
</div>
)}
</div>
);
}
export default function ScannerCamera({ export default function ScannerCamera({
queue, queue,
camera, camera,
@ -138,6 +226,8 @@ export default function ScannerCamera({
const toastTimerRef = useRef(null); const toastTimerRef = useRef(null);
const prevCountRef = useRef(queue.scannedCards?.length ?? 0); const prevCountRef = useRef(queue.scannedCards?.length ?? 0);
const galleryInputRef = useRef(null); const galleryInputRef = useRef(null);
const viewportRef = useRef(null);
const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 });
const { const {
videoRef, videoRef,
@ -162,6 +252,24 @@ export default function ScannerCamera({
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot mount // eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot mount
}, []); }, []);
useEffect(() => {
const node = viewportRef.current;
if (!node) return undefined;
const syncSize = () => {
setViewportSize({
width: node.clientWidth,
height: node.clientHeight,
});
};
syncSize();
const observer = new ResizeObserver(syncSize);
observer.observe(node);
return () => observer.disconnect();
}, []);
const currentCount = queue.scannedCards?.length ?? 0; const currentCount = queue.scannedCards?.length ?? 0;
useEffect(() => { useEffect(() => {
if (currentCount > prevCountRef.current) { if (currentCount > prevCountRef.current) {
@ -222,6 +330,15 @@ export default function ScannerCamera({
); );
const hasMetrics = videoMetrics.width > 0 && videoMetrics.height > 0; const hasMetrics = videoMetrics.width > 0 && videoMetrics.height > 0;
const displayLayout =
hasMetrics && viewportSize.width > 0 && viewportSize.height > 0
? computeObjectCoverLayout(
videoMetrics.width,
videoMetrics.height,
viewportSize.width,
viewportSize.height
)
: null;
const showFlash = flash.flashSupported && facingMode === 'environment'; const showFlash = flash.flashSupported && facingMode === 'environment';
const scanStatus = isCheckoutOpen const scanStatus = isCheckoutOpen
? 'Checkout open' ? 'Checkout open'
@ -248,143 +365,81 @@ export default function ScannerCamera({
className="absolute inset-0 md:rounded-xl overflow-hidden" className="absolute inset-0 md:rounded-xl overflow-hidden"
style={{ boxShadow: 'var(--rim-light-inner), var(--ember-rim-subtle)' }} style={{ boxShadow: 'var(--rim-light-inner), var(--ember-rim-subtle)' }}
> >
<div <CameraViewport
className="absolute inset-0" viewportRef={viewportRef}
style={{ backgroundColor: 'var(--bg-tertiary)' }} videoRef={videoRef}
> isStreaming={isStreaming}
<video displayLayout={displayLayout}
ref={videoRef} foundCards={foundCards}
className="absolute inset-0 w-full h-full object-cover" />
style={{ display: isStreaming ? 'block' : 'none' }}
autoPlay
playsInline
muted
aria-label="Card scanner camera feed"
/>
{isStreaming &&
hasMetrics &&
foundCards.map((card) => (
<DetectionFrame key={card.id} card={card} videoMetrics={videoMetrics} />
))}
{!isStreaming && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div
className="w-10 h-10 rounded-full border-2 border-t-transparent animate-spin"
style={{ borderColor: 'var(--accent-ember)', borderTopColor: 'transparent' }}
aria-hidden="true"
/>
<span
className="text-sm font-medium"
style={{ color: 'var(--text-secondary)' }}
>
Starting camera
</span>
</div>
</div>
)}
</div>
</GlassSurface> </GlassSurface>
) : ( ) : (
<div <CameraViewport
className="absolute inset-0" viewportRef={viewportRef}
style={{ backgroundColor: 'var(--bg-tertiary)' }} videoRef={videoRef}
> isStreaming={isStreaming}
<video displayLayout={displayLayout}
ref={videoRef} foundCards={foundCards}
className="absolute inset-0 w-full h-full object-cover" />
style={{ display: isStreaming ? 'block' : 'none' }} )}
autoPlay
playsInline
muted
aria-label="Card scanner camera feed"
/>
{isStreaming && {isStreaming && (
hasMetrics && <div className={`absolute top-3 right-3 z-10 ${isWorkstation ? 'md:hidden' : ''}`}>
foundCards.map((card) => ( <GlassSurface
<DetectionFrame key={card.id} card={card} videoMetrics={videoMetrics} /> tint="mid"
))} rim="subtle"
blur="mid"
{!isStreaming && ( className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold shadow-lg"
<div className="absolute inset-0 flex items-center justify-center"> style={{ color: 'var(--text-primary)' }}
<div className="flex flex-col items-center gap-3"> >
<div <span
className="w-10 h-10 rounded-full border-2 border-t-transparent animate-spin" className="w-1.5 h-1.5 rounded-full animate-pulse"
style={{ borderColor: 'var(--accent-ember)', borderTopColor: 'transparent' }} style={{ backgroundColor: 'var(--color-error)' }}
aria-hidden="true" aria-hidden="true"
/> />
<span LIVE
className="text-sm font-medium" </GlassSurface>
style={{ color: 'var(--text-secondary)' }}
>
Starting camera
</span>
</div>
</div>
)}
</div> </div>
)} )}
{isStreaming && ( {isWorkstation && isStreaming && (
<div className={`absolute top-3 right-3 z-10 ${isWorkstation ? 'md:hidden' : ''}`}> <div className="absolute top-3 left-3 z-10 hidden md:block">
<GlassSurface <GlassSurface
tint="mid" tint="mid"
rim="subtle" rim="subtle"
blur="mid" blur="mid"
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold shadow-lg" className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold shadow-lg"
style={{ color: 'var(--text-primary)' }} style={{ color: 'var(--text-primary)' }}
> role="status"
<span aria-live="polite"
className="w-1.5 h-1.5 rounded-full animate-pulse" >
style={{ backgroundColor: 'var(--color-error)' }} <span
aria-hidden="true" className={`w-1.5 h-1.5 rounded-full ${autoDetectOn ? 'motion-safe:animate-pulse' : ''}`}
/> style={{
LIVE backgroundColor: autoDetectOn
</GlassSurface> ? 'var(--color-success)'
</div> : 'var(--text-secondary)',
)} }}
aria-hidden="true"
{isWorkstation && isStreaming && ( />
<div className="absolute top-3 left-3 z-10 hidden md:block"> Auto-detect {autoDetectOn ? 'ON' : 'OFF'}
<GlassSurface </GlassSurface>
tint="mid"
rim="subtle"
blur="mid"
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold shadow-lg"
style={{ color: 'var(--text-primary)' }}
role="status"
aria-live="polite"
>
<span
className={`w-1.5 h-1.5 rounded-full ${autoDetectOn ? 'motion-safe:animate-pulse' : ''}`}
style={{
backgroundColor: autoDetectOn
? 'var(--color-success)'
: 'var(--text-secondary)',
}}
aria-hidden="true"
/>
Auto-detect {autoDetectOn ? 'ON' : 'OFF'}
</GlassSurface>
</div>
)}
<ScannerToast
message={toast.message}
visible={toast.visible}
type={toast.type}
/>
<div className={isWorkstation ? 'md:hidden' : undefined}>
<ScannerScanPeek
key={latestPeekCard?.id ?? latestPeekCard?.name ?? 'peek'}
card={latestPeekCard}
onOpenCheckout={onOpenCheckout}
/>
</div> </div>
)}
<ScannerToast
message={toast.message}
visible={toast.visible}
type={toast.type}
/>
<div className={isWorkstation ? 'md:hidden' : undefined}>
<ScannerScanPeek
key={latestPeekCard?.id ?? latestPeekCard?.name ?? 'peek'}
card={latestPeekCard}
onOpenCheckout={onOpenCheckout}
/>
</div>
<div <div
className={`absolute top-0 left-0 right-0 z-30 px-3 pb-2 ${isWorkstation ? 'md:hidden' : ''}`} className={`absolute top-0 left-0 right-0 z-30 px-3 pb-2 ${isWorkstation ? 'md:hidden' : ''}`}

View file

@ -7,7 +7,8 @@ const LIMITER_CONFIG = {
upload: { limit: 10, durationSec: 60 * 60, prefix: 'deckhearth:upload' }, upload: { limit: 10, durationSec: 60 * 60, prefix: 'deckhearth:upload' },
generate: { limit: 5, durationSec: 60 * 60, prefix: 'deckhearth:generate' }, generate: { limit: 5, durationSec: 60 * 60, prefix: 'deckhearth:generate' },
import: { limit: 5, durationSec: 60 * 60, prefix: 'deckhearth:import' }, import: { limit: 5, durationSec: 60 * 60, prefix: 'deckhearth:import' },
scan: { limit: 5, durationSec: 60, prefix: 'deckhearth:scan' }, // One camera verify may escalate L0→L2; vision path is the expensive step.
scan: { limit: 15, durationSec: 60, prefix: 'deckhearth:scan' },
}; };
let cached = null; let cached = null;

View file

@ -24,7 +24,16 @@ export const MIN_STABLE_COUNT_FOR_VERIFY = 3;
export const MIN_FIRST_SEEN_MS_FOR_VERIFY = 800; export const MIN_FIRST_SEEN_MS_FOR_VERIFY = 800;
/** Delay after camera start before detection loops begin. */ /** Delay after camera start before detection loops begin. */
export const DETECTION_START_DELAY_MS = 1000; export const DETECTION_START_DELAY_MS = 300;
/** Max verification attempts per tracked card before requiring reset. */
export const MAX_SCAN_ATTEMPTS = 3;
/** Ms before a negative tracker can retry without large movement. */
export const NEGATIVE_RETRY_MS = 5000;
/** Terminal tracker statuses that should not absorb new shape detections. */
export const TERMINAL_TRACKER_STATUSES = new Set(['confirmed', 'scanned']);
/** Tracked cards eligible for one-shot server verification. */ /** Tracked cards eligible for one-shot server verification. */
export function selectCardsReadyForVerification( export function selectCardsReadyForVerification(
@ -32,7 +41,7 @@ export function selectCardsReadyForVerification(
now = Date.now(), now = Date.now(),
{ {
minStableCount = MIN_STABLE_COUNT_FOR_VERIFY, minStableCount = MIN_STABLE_COUNT_FOR_VERIFY,
maxScanAttempts = 1, maxScanAttempts = MAX_SCAN_ATTEMPTS,
minFirstSeenMs = MIN_FIRST_SEEN_MS_FOR_VERIFY, minFirstSeenMs = MIN_FIRST_SEEN_MS_FOR_VERIFY,
} = {} } = {}
) { ) {
@ -198,7 +207,7 @@ function scoreCardCandidate(edges, width, height, bbox) {
function findCardCandidates(edges, width, height) { function findCardCandidates(edges, width, height) {
const minCardWidth = Math.floor(width * 0.2); const minCardWidth = Math.floor(width * 0.2);
const maxCardWidth = Math.floor(width * 0.6); const maxCardWidth = Math.floor(width * 0.85);
const minCardHeight = Math.floor(height * 0.25); const minCardHeight = Math.floor(height * 0.25);
const maxCardHeight = Math.floor(height * 0.7); const maxCardHeight = Math.floor(height * 0.7);
const candidates = []; const candidates = [];
@ -303,6 +312,7 @@ export function mergeDetectedShapesIntoTrackedCards(
let matchedCard = null; let matchedCard = null;
for (const card of updatedCards) { for (const card of updatedCards) {
if (TERMINAL_TRACKER_STATUSES.has(card.status)) continue;
if (rectanglesOverlap(shape, card.bounds, 0.4)) { if (rectanglesOverlap(shape, card.bounds, 0.4)) {
matchedCard = card; matchedCard = card;
break; break;
@ -310,6 +320,7 @@ export function mergeDetectedShapesIntoTrackedCards(
} }
if (matchedCard) { if (matchedCard) {
const previousBounds = matchedCard.bounds;
matchedCard.bounds = { matchedCard.bounds = {
x: shape.x, x: shape.x,
y: shape.y, y: shape.y,
@ -322,13 +333,17 @@ export function mergeDetectedShapesIntoTrackedCards(
matchedCard.lastSeen = now; matchedCard.lastSeen = now;
matchedCard.stableCount = Math.min(matchedCard.stableCount + 1, 10); matchedCard.stableCount = Math.min(matchedCard.stableCount + 1, 10);
const positionChange = if (matchedCard.status === 'negative') {
Math.abs(matchedCard.bounds.x - shape.x) + Math.abs(matchedCard.bounds.y - shape.y); const negativeSince = matchedCard.negativeAt ?? matchedCard.lastSeen;
if (positionChange > 50 && matchedCard.status === 'negative') { const positionChange =
console.log(`🔄 Card ${matchedCard.id} moved significantly, allowing re-scan`); Math.abs(previousBounds.x - shape.x) + Math.abs(previousBounds.y - shape.y);
matchedCard.status = 'detecting'; if (positionChange > 25 || now - negativeSince > NEGATIVE_RETRY_MS) {
matchedCard.scanAttempts = 0; console.log(`🔄 Card ${matchedCard.id} ready for re-scan`);
matchedCard.stableCount = 1; matchedCard.status = 'detecting';
matchedCard.scanAttempts = 0;
matchedCard.stableCount = Math.max(matchedCard.stableCount, MIN_STABLE_COUNT_FOR_VERIFY);
matchedCard.negativeAt = undefined;
}
} }
} else { } else {
const newCard = { const newCard = {

View file

@ -0,0 +1,98 @@
/** Trading card aspect ratio (width / height). */
export const CARD_ASPECT_RATIO = 5 / 7;
/**
* Layout math for a video element using CSS object-cover inside a container.
*/
export function computeObjectCoverLayout(videoWidth, videoHeight, containerWidth, containerHeight) {
if (!videoWidth || !videoHeight || !containerWidth || !containerHeight) {
return null;
}
const videoAspect = videoWidth / videoHeight;
const containerAspect = containerWidth / containerHeight;
let renderedWidth;
let renderedHeight;
let offsetX;
let offsetY;
if (videoAspect > containerAspect) {
renderedHeight = containerHeight;
renderedWidth = containerHeight * videoAspect;
offsetX = (containerWidth - renderedWidth) / 2;
offsetY = 0;
} else {
renderedWidth = containerWidth;
renderedHeight = containerWidth / videoAspect;
offsetX = 0;
offsetY = (containerHeight - renderedHeight) / 2;
}
return {
videoWidth,
videoHeight,
containerWidth,
containerHeight,
renderedWidth,
renderedHeight,
offsetX,
offsetY,
};
}
/** Map video-pixel bounds to percentage styles within an object-cover container. */
export function videoBoundsToContainerStyle(bounds, layout) {
const {
videoWidth,
videoHeight,
containerWidth,
containerHeight,
renderedWidth,
renderedHeight,
offsetX,
offsetY,
} = layout;
const left = offsetX + (bounds.x / videoWidth) * renderedWidth;
const top = offsetY + (bounds.y / videoHeight) * renderedHeight;
const width = (bounds.width / videoWidth) * renderedWidth;
const height = (bounds.height / videoHeight) * renderedHeight;
return {
left: `${(left / containerWidth) * 100}%`,
top: `${(top / containerHeight) * 100}%`,
width: `${(width / containerWidth) * 100}%`,
height: `${(height / containerHeight) * 100}%`,
};
}
/** Centered card guide frame in container pixel coordinates. */
export function computeCardGuideBounds(containerWidth, containerHeight) {
let width = containerWidth * 0.78;
let height = width / CARD_ASPECT_RATIO;
const maxHeight = containerHeight * 0.72;
if (height > maxHeight) {
height = maxHeight;
width = height * CARD_ASPECT_RATIO;
}
return {
left: (containerWidth - width) / 2,
top: (containerHeight - height) / 2,
width,
height,
};
}
/** Card guide as percentage styles for an object-cover container. */
export function cardGuideToContainerStyle(layout) {
const guide = computeCardGuideBounds(layout.containerWidth, layout.containerHeight);
return {
left: `${(guide.left / layout.containerWidth) * 100}%`,
top: `${(guide.top / layout.containerHeight) * 100}%`,
width: `${(guide.width / layout.containerWidth) * 100}%`,
height: `${(guide.height / layout.containerHeight) * 100}%`,
};
}

View file

@ -168,6 +168,12 @@ export function useCameraScanner({
setTrackedCards([]); setTrackedCards([]);
}; };
const removeTrackedCard = useCallback((cardId) => {
const next = trackedCardsRef.current.filter((card) => card.id !== cardId);
trackedCardsRef.current = next;
setTrackedCards(next);
}, []);
const startDetection = () => { const startDetection = () => {
if (detectionIntervalRef.current || !isStreamingRef.current) return; if (detectionIntervalRef.current || !isStreamingRef.current) return;
@ -312,6 +318,12 @@ export function useCameraScanner({
// eslint-disable-next-line react-hooks/exhaustive-deps -- start detection once per stream session // eslint-disable-next-line react-hooks/exhaustive-deps -- start detection once per stream session
}, [isStreaming]); }, [isStreaming]);
useEffect(() => {
if (!isStreaming) return undefined;
import('./ocr-worker.js').catch(() => {});
return undefined;
}, [isStreaming]);
useEffect(() => { useEffect(() => {
return () => { return () => {
stopCamera(); stopCamera();
@ -359,6 +371,7 @@ export function useCameraScanner({
videoMetrics, videoMetrics,
startCamera, startCamera,
stopCamera, stopCamera,
removeTrackedCard,
streamRef, streamRef,
facingMode: activeFacingMode, facingMode: activeFacingMode,
switchFacingMode, switchFacingMode,

View file

@ -45,6 +45,7 @@ function readFileToImageData(file) {
export function useScannerIdentification({ export function useScannerIdentification({
onCardScanned, onCardScanned,
onError, onError,
onTrackerComplete,
videoRef, videoRef,
canvasRef, canvasRef,
onVerifyCardRef, onVerifyCardRef,
@ -60,6 +61,17 @@ export function useScannerIdentification({
const lastErrorAtRef = useRef(0); const lastErrorAtRef = useRef(0);
const disambiguationRefineRef = useRef(null); 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 = {}) => { const emitScannedCard = async (cardTracker, imageData, finalCard, ocrMeta = {}) => {
cardTracker.status = 'scanned'; cardTracker.status = 'scanned';
@ -79,6 +91,7 @@ export function useScannerIdentification({
} }
onCardScanned(buildScannedCardPayload(finalCard, { imageData, scanImageUrl, ocrMeta })); onCardScanned(buildScannedCardPayload(finalCard, { imageData, scanImageUrl, ocrMeta }));
finishTrackedCard(cardTracker);
}; };
const showScanNotice = (message) => { const showScanNotice = (message) => {
@ -87,7 +100,10 @@ export function useScannerIdentification({
}; };
const handleReviewSubmitted = (cardTracker, message) => { const handleReviewSubmitted = (cardTracker, message) => {
if (cardTracker) cardTracker.status = 'confirmed'; if (cardTracker) {
cardTracker.status = 'confirmed';
finishTrackedCard(cardTracker);
}
setDisambiguation(null); setDisambiguation(null);
disambiguationRefineRef.current = null; disambiguationRefineRef.current = null;
showScanNotice(message); showScanNotice(message);
@ -102,6 +118,11 @@ export function useScannerIdentification({
}; };
const cancelDisambiguation = () => { const cancelDisambiguation = () => {
if (disambiguation?.cardTracker) {
disambiguation.cardTracker.status = 'detecting';
disambiguation.cardTracker.scanAttempts = 0;
disambiguation.cardTracker.negativeAt = undefined;
}
setDisambiguation(null); setDisambiguation(null);
disambiguationRefineRef.current = null; disambiguationRefineRef.current = null;
}; };
@ -115,13 +136,13 @@ export function useScannerIdentification({
}; };
const applyIdentifyOutcome = async (cardTracker, imageData, outcome) => { const applyIdentifyOutcome = async (cardTracker, imageData, outcome) => {
cardTracker.status = outcome.cardStatus;
switch (outcome.type) { switch (outcome.type) {
case 'emit': case 'emit':
cardTracker.status = outcome.cardStatus;
await emitScannedCard(cardTracker, imageData, outcome.card, outcome.ocrMeta); await emitScannedCard(cardTracker, imageData, outcome.card, outcome.ocrMeta);
break; break;
case 'disambiguation': case 'disambiguation':
cardTracker.status = 'verifying';
setDisambiguation({ setDisambiguation({
cardTracker, cardTracker,
imageData, imageData,
@ -133,9 +154,13 @@ export function useScannerIdentification({
}); });
break; break;
case 'notice': case 'notice':
cardTracker.status = outcome.cardStatus;
showScanNotice(outcome.message); showScanNotice(outcome.message);
finishTrackedCard(cardTracker);
break; break;
case 'error': case 'error':
cardTracker.status = outcome.cardStatus;
cardTracker.negativeAt = Date.now();
reportScannerError(outcome.message); reportScannerError(outcome.message);
break; break;
default: default:
@ -297,16 +322,21 @@ export function useScannerIdentification({
visionCooldownUntilRef.current = Date.now() + VISION_RATE_LIMIT_MS; visionCooldownUntilRef.current = Date.now() + VISION_RATE_LIMIT_MS;
reportScannerError('Too many scan attempts. Please wait a moment and try again.'); reportScannerError('Too many scan attempts. Please wait a moment and try again.');
cardTracker.status = 'negative'; cardTracker.status = 'negative';
cardTracker.negativeAt = Date.now();
return; return;
} }
if (identification.handled && identification.outcome) { if (identification.handled && identification.outcome) {
cardTracker.status = 'confirmed'; cardTracker.status = 'confirmed';
await applyIdentifyOutcome(cardTracker, identification.imageData, identification.outcome); await applyIdentifyOutcome(cardTracker, identification.imageData, identification.outcome);
} else {
cardTracker.status = 'negative';
cardTracker.negativeAt = Date.now();
} }
} catch (error) { } catch (error) {
console.error(`Error verifying card ${cardTracker.id}:`, error); console.error(`Error verifying card ${cardTracker.id}:`, error);
cardTracker.status = 'negative'; cardTracker.status = 'negative';
cardTracker.negativeAt = Date.now();
reportScannerError(error.message || 'Scan failed'); reportScannerError(error.message || 'Scan failed');
} finally { } finally {
activeVerificationRef.current = Math.max(0, activeVerificationRef.current - 1); activeVerificationRef.current = Math.max(0, activeVerificationRef.current - 1);

View file

@ -1,5 +1,4 @@
import { getUserFromRequest } from '../../../lib/permission-middleware'; import { getUserFromRequest } from '../../../lib/permission-middleware';
import { checkScanRateLimit } from '../../../lib/rate-limit.js';
import { embedCardImage, EmbedApiError } from '../../../lib/card-embed.js'; import { embedCardImage, EmbedApiError } from '../../../lib/card-embed.js';
import { matchVisualInCatalog, catalogHasEmbeddings } from '../../../lib/card-visual-match.js'; import { matchVisualInCatalog, catalogHasEmbeddings } from '../../../lib/card-visual-match.js';
import { logScanAttempt } from '../../../lib/card-catalog-match.js'; import { logScanAttempt } from '../../../lib/card-catalog-match.js';
@ -63,12 +62,6 @@ export default async function handler(req, res) {
}); });
} }
const { allowed, reset } = await checkScanRateLimit(req, user.userId);
if (!allowed) {
res.setHeader('Retry-After', Math.ceil((reset - Date.now()) / 1000));
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
}
const embedding = await embedCardImage(imageData); const embedding = await embedCardImage(imageData);
const matchResult = await matchVisualInCatalog({ embedding, game: game || null }); const matchResult = await matchVisualInCatalog({ embedding, game: game || null });
const latencyMs = Date.now() - startedAt; const latencyMs = Date.now() - startedAt;

View file

@ -63,6 +63,7 @@ export default function Scanner() {
const identification = useScannerIdentification({ const identification = useScannerIdentification({
onCardScanned: queue.handleCardScanned, onCardScanned: queue.handleCardScanned,
onError: (msg) => console.error('Identification error:', msg), onError: (msg) => console.error('Identification error:', msg),
onTrackerComplete: camera.removeTrackedCard,
videoRef: camera.videoRef, videoRef: camera.videoRef,
canvasRef: camera.canvasRef, canvasRef: camera.canvasRef,
onVerifyCardRef, onVerifyCardRef,

View file

@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
convertToVideoCoordinates, convertToVideoCoordinates,
MAX_SCAN_ATTEMPTS,
mergeDetectedShapesIntoTrackedCards, mergeDetectedShapesIntoTrackedCards,
NEGATIVE_RETRY_MS,
rectanglesOverlap, rectanglesOverlap,
refineCardCornersFromEdges, refineCardCornersFromEdges,
selectCardsReadyForVerification, selectCardsReadyForVerification,
@ -126,9 +128,22 @@ describe('selectCardsReadyForVerification', () => {
expect(selectCardsReadyForVerification([ready, tooFresh], now)).toEqual([ready]); expect(selectCardsReadyForVerification([ready, tooFresh], now)).toEqual([ready]);
}); });
it('excludes cards that already attempted verification', () => { it('excludes cards that exhausted verification attempts', () => {
const now = 10_000; const now = 10_000;
const retried = { const retried = {
id: 1,
status: 'detecting',
stableCount: 8,
scanAttempts: MAX_SCAN_ATTEMPTS,
firstSeen: now - 5000,
};
expect(selectCardsReadyForVerification([retried], now)).toHaveLength(0);
});
it('allows retry after a failed attempt when under the attempt cap', () => {
const now = 10_000;
const retrying = {
id: 1, id: 1,
status: 'detecting', status: 'detecting',
stableCount: 8, stableCount: 8,
@ -136,7 +151,47 @@ describe('selectCardsReadyForVerification', () => {
firstSeen: now - 5000, firstSeen: now - 5000,
}; };
expect(selectCardsReadyForVerification([retried], now)).toHaveLength(0); expect(selectCardsReadyForVerification([retrying], now)).toHaveLength(1);
});
it('creates a new tracker when a finished card overlaps a fresh detection', () => {
const now = 1_000_000;
const finished = {
id: 1,
bounds: { x: 10, y: 20, width: 100, height: 140 },
status: 'scanned',
firstSeen: now - 2000,
lastSeen: now - 100,
stableCount: 5,
scanAttempts: 1,
};
const shape = { x: 12, y: 22, width: 100, height: 140, score: 80 };
const { cards, nextCardId } = mergeDetectedShapesIntoTrackedCards([finished], [shape], 2, now);
expect(cards).toHaveLength(2);
expect(cards.some((card) => card.id === 2 && card.status === 'detecting')).toBe(true);
expect(nextCardId).toBe(3);
});
it('resets negative trackers after the retry window', () => {
const now = 10_000;
const negative = {
id: 1,
bounds: { x: 10, y: 20, width: 100, height: 140 },
status: 'negative',
negativeAt: now - NEGATIVE_RETRY_MS - 1,
firstSeen: now - 8000,
lastSeen: now - 100,
stableCount: 2,
scanAttempts: 1,
};
const shape = { x: 10, y: 20, width: 100, height: 140, score: 80 };
const { cards } = mergeDetectedShapesIntoTrackedCards([negative], [shape], 2, now);
expect(cards[0].status).toBe('detecting');
expect(cards[0].scanAttempts).toBe(0);
}); });
}); });

View file

@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import {
cardGuideToContainerStyle,
computeCardGuideBounds,
computeObjectCoverLayout,
videoBoundsToContainerStyle,
} from '../../lib/scanner-video-layout.js';
describe('computeObjectCoverLayout', () => {
it('computes horizontal crop when video is wider than container', () => {
const layout = computeObjectCoverLayout(1280, 720, 390, 844);
expect(layout).not.toBeNull();
expect(layout.renderedHeight).toBe(844);
expect(layout.renderedWidth).toBeGreaterThan(390);
expect(layout.offsetX).toBeLessThan(0);
});
});
describe('videoBoundsToContainerStyle', () => {
it('maps full-frame video bounds to full container percentages', () => {
const layout = computeObjectCoverLayout(1000, 1000, 500, 500);
const style = videoBoundsToContainerStyle(
{ x: 0, y: 0, width: 1000, height: 1000 },
layout
);
expect(style).toEqual({
left: '0%',
top: '0%',
width: '100%',
height: '100%',
});
});
});
describe('card guide layout', () => {
it('centers a 5:7 guide within the container', () => {
const guide = computeCardGuideBounds(400, 800);
expect(guide.width / guide.height).toBeCloseTo(5 / 7, 5);
expect(guide.left).toBeGreaterThan(0);
expect(guide.top).toBeGreaterThan(0);
});
it('returns percentage styles for the guide overlay', () => {
const layout = computeObjectCoverLayout(1280, 720, 390, 844);
const style = cardGuideToContainerStyle(layout);
expect(style.left).toMatch(/%$/);
expect(style.width).toMatch(/%$/);
});
});