+ )}
- {isStreaming &&
- hasMetrics &&
- foundCards.map((card) => (
-
-
+ {isWorkstation && isStreaming && (
+
+
+
+ Auto-detect {autoDetectOn ? 'ON' : 'OFF'}
+
+ )}
+
+
+
+
+
+
50 && matchedCard.status === 'negative') {
- console.log(`🔄 Card ${matchedCard.id} moved significantly, allowing re-scan`);
- matchedCard.status = 'detecting';
- matchedCard.scanAttempts = 0;
- matchedCard.stableCount = 1;
+ if (matchedCard.status === 'negative') {
+ const negativeSince = matchedCard.negativeAt ?? matchedCard.lastSeen;
+ const positionChange =
+ Math.abs(previousBounds.x - shape.x) + Math.abs(previousBounds.y - shape.y);
+ if (positionChange > 25 || now - negativeSince > NEGATIVE_RETRY_MS) {
+ console.log(`🔄 Card ${matchedCard.id} ready for re-scan`);
+ matchedCard.status = 'detecting';
+ matchedCard.scanAttempts = 0;
+ matchedCard.stableCount = Math.max(matchedCard.stableCount, MIN_STABLE_COUNT_FOR_VERIFY);
+ matchedCard.negativeAt = undefined;
+ }
}
} else {
const newCard = {
diff --git a/lib/scanner-video-layout.js b/lib/scanner-video-layout.js
new file mode 100644
index 0000000..308d3c9
--- /dev/null
+++ b/lib/scanner-video-layout.js
@@ -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}%`,
+ };
+}
diff --git a/lib/use-camera-scanner.js b/lib/use-camera-scanner.js
index 726cbbd..24ae572 100644
--- a/lib/use-camera-scanner.js
+++ b/lib/use-camera-scanner.js
@@ -168,6 +168,12 @@ export function useCameraScanner({
setTrackedCards([]);
};
+ const removeTrackedCard = useCallback((cardId) => {
+ const next = trackedCardsRef.current.filter((card) => card.id !== cardId);
+ trackedCardsRef.current = next;
+ setTrackedCards(next);
+ }, []);
+
const startDetection = () => {
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
}, [isStreaming]);
+ useEffect(() => {
+ if (!isStreaming) return undefined;
+ import('./ocr-worker.js').catch(() => {});
+ return undefined;
+ }, [isStreaming]);
+
useEffect(() => {
return () => {
stopCamera();
@@ -359,6 +371,7 @@ export function useCameraScanner({
videoMetrics,
startCamera,
stopCamera,
+ removeTrackedCard,
streamRef,
facingMode: activeFacingMode,
switchFacingMode,
diff --git a/lib/use-scanner-identification.js b/lib/use-scanner-identification.js
index f641c8f..828113d 100644
--- a/lib/use-scanner-identification.js
+++ b/lib/use-scanner-identification.js
@@ -45,6 +45,7 @@ function readFileToImageData(file) {
export function useScannerIdentification({
onCardScanned,
onError,
+ onTrackerComplete,
videoRef,
canvasRef,
onVerifyCardRef,
@@ -60,6 +61,17 @@ export function useScannerIdentification({
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';
@@ -79,6 +91,7 @@ export function useScannerIdentification({
}
onCardScanned(buildScannedCardPayload(finalCard, { imageData, scanImageUrl, ocrMeta }));
+ finishTrackedCard(cardTracker);
};
const showScanNotice = (message) => {
@@ -87,7 +100,10 @@ export function useScannerIdentification({
};
const handleReviewSubmitted = (cardTracker, message) => {
- if (cardTracker) cardTracker.status = 'confirmed';
+ if (cardTracker) {
+ cardTracker.status = 'confirmed';
+ finishTrackedCard(cardTracker);
+ }
setDisambiguation(null);
disambiguationRefineRef.current = null;
showScanNotice(message);
@@ -102,6 +118,11 @@ export function useScannerIdentification({
};
const cancelDisambiguation = () => {
+ if (disambiguation?.cardTracker) {
+ disambiguation.cardTracker.status = 'detecting';
+ disambiguation.cardTracker.scanAttempts = 0;
+ disambiguation.cardTracker.negativeAt = undefined;
+ }
setDisambiguation(null);
disambiguationRefineRef.current = null;
};
@@ -115,13 +136,13 @@ export function useScannerIdentification({
};
const applyIdentifyOutcome = async (cardTracker, imageData, outcome) => {
- cardTracker.status = outcome.cardStatus;
-
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,
@@ -133,9 +154,13 @@ export function useScannerIdentification({
});
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:
@@ -297,16 +322,21 @@ export function useScannerIdentification({
visionCooldownUntilRef.current = Date.now() + VISION_RATE_LIMIT_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';
await applyIdentifyOutcome(cardTracker, identification.imageData, identification.outcome);
+ } else {
+ cardTracker.status = 'negative';
+ cardTracker.negativeAt = Date.now();
}
} catch (error) {
console.error(`Error verifying card ${cardTracker.id}:`, error);
cardTracker.status = 'negative';
+ cardTracker.negativeAt = Date.now();
reportScannerError(error.message || 'Scan failed');
} finally {
activeVerificationRef.current = Math.max(0, activeVerificationRef.current - 1);
diff --git a/pages/api/scan/identify-by-image.js b/pages/api/scan/identify-by-image.js
index bbadcfa..0603b63 100644
--- a/pages/api/scan/identify-by-image.js
+++ b/pages/api/scan/identify-by-image.js
@@ -1,5 +1,4 @@
import { getUserFromRequest } from '../../../lib/permission-middleware';
-import { checkScanRateLimit } from '../../../lib/rate-limit.js';
import { embedCardImage, EmbedApiError } from '../../../lib/card-embed.js';
import { matchVisualInCatalog, catalogHasEmbeddings } from '../../../lib/card-visual-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 matchResult = await matchVisualInCatalog({ embedding, game: game || null });
const latencyMs = Date.now() - startedAt;
diff --git a/pages/scanner.js b/pages/scanner.js
index 455de1f..dda5490 100644
--- a/pages/scanner.js
+++ b/pages/scanner.js
@@ -63,6 +63,7 @@ export default function Scanner() {
const identification = useScannerIdentification({
onCardScanned: queue.handleCardScanned,
onError: (msg) => console.error('Identification error:', msg),
+ onTrackerComplete: camera.removeTrackedCard,
videoRef: camera.videoRef,
canvasRef: camera.canvasRef,
onVerifyCardRef,
diff --git a/test/lib/scanner-card-detection.test.js b/test/lib/scanner-card-detection.test.js
index 306dfd2..5ffa3aa 100644
--- a/test/lib/scanner-card-detection.test.js
+++ b/test/lib/scanner-card-detection.test.js
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
import {
convertToVideoCoordinates,
+ MAX_SCAN_ATTEMPTS,
mergeDetectedShapesIntoTrackedCards,
+ NEGATIVE_RETRY_MS,
rectanglesOverlap,
refineCardCornersFromEdges,
selectCardsReadyForVerification,
@@ -126,9 +128,22 @@ describe('selectCardsReadyForVerification', () => {
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 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,
status: 'detecting',
stableCount: 8,
@@ -136,7 +151,47 @@ describe('selectCardsReadyForVerification', () => {
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);
});
});
diff --git a/test/lib/scanner-video-layout.test.js b/test/lib/scanner-video-layout.test.js
new file mode 100644
index 0000000..8423d99
--- /dev/null
+++ b/test/lib/scanner-video-layout.test.js
@@ -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(/%$/);
+ });
+});