diff --git a/components/CameraScanner.js b/components/CameraScanner.js
index 857b29f..f4f4e91 100644
--- a/components/CameraScanner.js
+++ b/components/CameraScanner.js
@@ -1,9 +1,5 @@
-import { useState, useEffect, useRef } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { rateLimitCooldownUntil, uploadScanCapture } from '../lib/scan-capture-upload.js';
-import {
- detectCardShapesFromFrame,
- mergeDetectedShapesIntoTrackedCards,
-} from '../lib/scanner-card-detection.js';
import {
buildScannedCardPayload,
getScanAuthHeaders,
@@ -13,59 +9,40 @@ import {
VISION_RATE_LIMIT_MS,
fetchVisionIdentify,
} from '../lib/scanner-card-identify.js';
+import { useCameraScanner } from '../lib/use-camera-scanner.js';
import ScanDisambiguationDialog from './ScanDisambiguationDialog.js';
export default function CameraScanner({ onCardScanned, onError }) {
- const [isStreaming, setIsStreaming] = useState(false);
- const [isDetecting, setIsDetecting] = useState(false);
const [disambiguation, setDisambiguation] = useState(null);
const [scanNotice, setScanNotice] = useState(null);
const [submittingReview, setSubmittingReview] = useState(false);
- const videoRef = useRef(null);
- const canvasRef = useRef(null);
- const detectionCanvasRef = useRef(null);
- const streamRef = useRef(null);
- const detectionIntervalRef = useRef(null);
- const trackingIntervalRef = useRef(null);
-
- // Card tracking state
- const [trackedCards, setTrackedCards] = useState([]); // Array of tracked card objects
- const trackedCardsRef = useRef([]);
- const nextCardIdRef = useRef(1);
const visionCooldownUntilRef = useRef(0);
const activeVerificationRef = useRef(0);
const lastErrorAtRef = useRef(0);
const disambiguationRefineRef = useRef(null);
+ const verificationPausedRef = useRef(false);
+ const onVerifyCardRef = useRef(() => {});
- const [videoMetrics, setVideoMetrics] = useState({ width: 0, height: 0 });
-
- // Configure canvas contexts for optimal performance
useEffect(() => {
- if (canvasRef.current) {
- const ctx = canvasRef.current.getContext('2d', { willReadFrequently: true });
- }
- if (detectionCanvasRef.current) {
- const ctx = detectionCanvasRef.current.getContext('2d', { willReadFrequently: true });
- }
- }, []);
+ verificationPausedRef.current = Boolean(disambiguation);
+ }, [disambiguation]);
- // Continuous shape detection for card-like rectangles
- const detectCardShapes = () => {
- if (!videoRef.current || !detectionCanvasRef.current || !isStreaming) return [];
- return detectCardShapesFromFrame(videoRef.current, detectionCanvasRef.current);
- };
-
- const updateTrackedCards = (detectedShapes) => {
- const { cards, nextCardId } = mergeDetectedShapesIntoTrackedCards(
- trackedCardsRef.current,
- detectedShapes,
- nextCardIdRef.current
- );
- nextCardIdRef.current = nextCardId;
- trackedCardsRef.current = cards;
- setTrackedCards(cards);
- };
+ const {
+ videoRef,
+ canvasRef,
+ detectionCanvasRef,
+ isStreaming,
+ isDetecting,
+ trackedCards,
+ videoMetrics,
+ startCamera,
+ stopCamera,
+ } = useCameraScanner({
+ onError,
+ verificationPausedRef,
+ onVerifyCard: (card) => onVerifyCardRef.current(card),
+ });
const emitScannedCard = async (cardTracker, imageData, finalCard, ocrMeta = {}) => {
cardTracker.status = 'scanned';
@@ -311,167 +288,13 @@ export default function CameraScanner({ onCardScanned, onError }) {
}
};
- // Start continuous detection
- const startDetection = () => {
- if (detectionIntervalRef.current || !isStreaming) return;
-
- console.log('🎯 Starting continuous card detection...');
- setIsDetecting(true);
-
- // Shape detection every 200ms
- detectionIntervalRef.current = setInterval(() => {
- const shapes = detectCardShapes();
- updateTrackedCards(shapes);
- }, 200);
-
- // Card verification every 1 second
- trackingIntervalRef.current = setInterval(() => {
- const cardsToVerify = trackedCardsRef.current.filter(card =>
- card.status === 'detecting' &&
- card.stableCount >= 6 &&
- card.scanAttempts < 1 &&
- Date.now() - card.firstSeen > 2500
- );
-
- const cardsToProcess = cardsToVerify.slice(0, 1);
- cardsToProcess.forEach(card => {
- verifyCardShape(card);
- });
- }, 1000); // Back to 1 second intervals
- };
-
- // Stop detection
- const stopDetection = () => {
- console.log('🛑 Stopping card detection...');
- setIsDetecting(false);
-
- if (detectionIntervalRef.current) {
- clearInterval(detectionIntervalRef.current);
- detectionIntervalRef.current = null;
- }
-
- if (trackingIntervalRef.current) {
- clearInterval(trackingIntervalRef.current);
- trackingIntervalRef.current = null;
- }
-
- // Clear tracked cards
- trackedCardsRef.current = [];
- setTrackedCards([]);
- };
-
- // Start camera stream
- const startCamera = async () => {
- try {
- console.log('🎥 Starting camera...');
- const stream = await navigator.mediaDevices.getUserMedia({
- video: {
- facingMode: 'environment',
- width: { ideal: 1280 },
- height: { ideal: 720 },
- aspectRatio: { ideal: 16/9 }
- }
- });
-
- console.log('📹 Camera stream obtained:', stream);
-
- if (videoRef.current) {
- videoRef.current.srcObject = stream;
- streamRef.current = stream;
-
- videoRef.current.onloadedmetadata = () => {
- console.log('📺 Video metadata loaded, attempting to play...');
- videoRef.current?.play().then(() => {
- console.log('▶️ Video playback started successfully');
- setIsStreaming(true);
- }).catch((err) => {
- console.error('❌ Video playback failed:', err);
- onError(`Video playback failed: ${err.message}`);
- });
- };
-
- videoRef.current.onerror = (err) => {
- console.error('❌ Video element error:', err);
- onError('Video element error occurred');
- };
-
- // Add a fallback timeout
- setTimeout(() => {
- if (!isStreaming && videoRef.current && videoRef.current.readyState >= 2) {
- console.log('🔄 Fallback: Attempting to play video directly...');
- videoRef.current.play().then(() => {
- console.log('▶️ Fallback video playback started');
- setIsStreaming(true);
- }).catch(console.error);
- }
- }, 2000);
-
- } else {
- console.error('❌ Video element not available');
- onError('Video element not available');
- }
- } catch (err) {
- console.error('❌ Camera access error:', err);
- onError(`Unable to access camera: ${err.message}`);
- }
- };
-
- // Stop camera stream
- const stopCamera = () => {
- setIsStreaming(false);
- stopDetection();
-
- if (streamRef.current) {
- streamRef.current.getTracks().forEach(track => track.stop());
- streamRef.current = null;
- }
-
- if (videoRef.current) {
- videoRef.current.srcObject = null;
- }
- };
-
useEffect(() => {
- const video = videoRef.current;
- if (!video || !isStreaming) return undefined;
+ onVerifyCardRef.current = verifyCardShape;
+ });
- const syncVideoMetrics = () => {
- setVideoMetrics({
- width: video.videoWidth || 0,
- height: video.videoHeight || 0,
- });
- };
-
- video.addEventListener('loadedmetadata', syncVideoMetrics);
- video.addEventListener('resize', syncVideoMetrics);
- syncVideoMetrics();
-
- return () => {
- video.removeEventListener('loadedmetadata', syncVideoMetrics);
- video.removeEventListener('resize', syncVideoMetrics);
- };
- }, [isStreaming]);
-
- // Auto-start detection when camera starts
- useEffect(() => {
- if (isStreaming && !isDetecting) {
- // Small delay to let camera stabilize
- const timerId = setTimeout(() => {
- startDetection();
- }, 1000);
- return () => clearTimeout(timerId);
- }
- return undefined;
- // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: start detection once per stream session
- }, [isStreaming]);
-
- // Cleanup on unmount
- useEffect(() => {
- return () => {
- stopCamera();
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps -- run stopCamera only on unmount
- }, []);
+ const foundCardCount = trackedCards.filter(
+ (card) => card.status === 'confirmed' || card.status === 'scanned'
+ ).length;
return (
@@ -555,8 +378,7 @@ export default function CameraScanner({ onCardScanned, onError }) {
- 🎯 Scanning {trackedCards.filter(card => card.status === 'confirmed' || card.status === 'scanned').length > 0 &&
- `(${trackedCards.filter(card => card.status === 'confirmed' || card.status === 'scanned').length} found)`}
+ 🎯 Scanning {foundCardCount > 0 && `(${foundCardCount} found)`}
)}
diff --git a/lib/scanner-card-detection.js b/lib/scanner-card-detection.js
index de954ec..bd5a1d7 100644
--- a/lib/scanner-card-detection.js
+++ b/lib/scanner-card-detection.js
@@ -5,6 +5,40 @@ export const DETECTION_CANVAS_HEIGHT = 240;
/** Milliseconds before a tracked card is dropped when not re-detected. */
export const TRACK_STALE_MS = 3000;
+/** Shape-detection polling interval in the camera scanner hook. */
+export const SHAPE_DETECTION_INTERVAL_MS = 200;
+
+/** Verification polling interval in the camera scanner hook. */
+export const VERIFICATION_INTERVAL_MS = 1000;
+
+/** Minimum stable frames before a tracked card is sent for identification. */
+export const MIN_STABLE_COUNT_FOR_VERIFY = 6;
+
+/** Minimum time (ms) a card must be tracked before verification. */
+export const MIN_FIRST_SEEN_MS_FOR_VERIFY = 2500;
+
+/** Delay after camera start before detection loops begin. */
+export const DETECTION_START_DELAY_MS = 1000;
+
+/** Tracked cards eligible for one-shot server verification. */
+export function selectCardsReadyForVerification(
+ trackedCards,
+ now = Date.now(),
+ {
+ minStableCount = MIN_STABLE_COUNT_FOR_VERIFY,
+ maxScanAttempts = 1,
+ minFirstSeenMs = MIN_FIRST_SEEN_MS_FOR_VERIFY,
+ } = {}
+) {
+ return trackedCards.filter(
+ (card) =>
+ card.status === 'detecting' &&
+ card.stableCount >= minStableCount &&
+ card.scanAttempts < maxScanAttempts &&
+ now - card.firstSeen > minFirstSeenMs
+ );
+}
+
/** Convert detection-canvas coordinates to video pixel coordinates. */
export function convertToVideoCoordinates(detection, canvas, video) {
const scaleX = video.videoWidth / canvas.width;
diff --git a/lib/use-camera-scanner.js b/lib/use-camera-scanner.js
new file mode 100644
index 0000000..eec15a7
--- /dev/null
+++ b/lib/use-camera-scanner.js
@@ -0,0 +1,220 @@
+import { useEffect, useRef, useState } from 'react';
+import {
+ detectCardShapesFromFrame,
+ DETECTION_START_DELAY_MS,
+ mergeDetectedShapesIntoTrackedCards,
+ selectCardsReadyForVerification,
+ SHAPE_DETECTION_INTERVAL_MS,
+ VERIFICATION_INTERVAL_MS,
+} from './scanner-card-detection.js';
+
+/**
+ * Camera stream + OpenCV shape detection loop for the card scanner.
+ * Identification callbacks stay in the parent component.
+ */
+export function useCameraScanner({ onError, onVerifyCard, verificationPausedRef }) {
+ const [isStreaming, setIsStreaming] = useState(false);
+ const [isDetecting, setIsDetecting] = useState(false);
+ const [trackedCards, setTrackedCards] = useState([]);
+ const [videoMetrics, setVideoMetrics] = useState({ width: 0, height: 0 });
+
+ const videoRef = useRef(null);
+ const canvasRef = useRef(null);
+ const detectionCanvasRef = useRef(null);
+ const streamRef = useRef(null);
+ const detectionIntervalRef = useRef(null);
+ const trackingIntervalRef = useRef(null);
+ const trackedCardsRef = useRef([]);
+ const nextCardIdRef = useRef(1);
+ const isStreamingRef = useRef(false);
+
+ useEffect(() => {
+ isStreamingRef.current = isStreaming;
+ }, [isStreaming]);
+
+ useEffect(() => {
+ if (canvasRef.current) {
+ canvasRef.current.getContext('2d', { willReadFrequently: true });
+ }
+ if (detectionCanvasRef.current) {
+ detectionCanvasRef.current.getContext('2d', { willReadFrequently: true });
+ }
+ }, []);
+
+ const detectCardShapes = () => {
+ if (!videoRef.current || !detectionCanvasRef.current || !isStreamingRef.current) return [];
+ return detectCardShapesFromFrame(videoRef.current, detectionCanvasRef.current);
+ };
+
+ const updateTrackedCards = (detectedShapes) => {
+ const { cards, nextCardId } = mergeDetectedShapesIntoTrackedCards(
+ trackedCardsRef.current,
+ detectedShapes,
+ nextCardIdRef.current
+ );
+ nextCardIdRef.current = nextCardId;
+ trackedCardsRef.current = cards;
+ setTrackedCards(cards);
+ };
+
+ const stopDetection = () => {
+ console.log('🛑 Stopping card detection...');
+ setIsDetecting(false);
+
+ if (detectionIntervalRef.current) {
+ clearInterval(detectionIntervalRef.current);
+ detectionIntervalRef.current = null;
+ }
+
+ if (trackingIntervalRef.current) {
+ clearInterval(trackingIntervalRef.current);
+ trackingIntervalRef.current = null;
+ }
+
+ trackedCardsRef.current = [];
+ setTrackedCards([]);
+ };
+
+ const startDetection = () => {
+ if (detectionIntervalRef.current || !isStreamingRef.current) return;
+
+ console.log('🎯 Starting continuous card detection...');
+ setIsDetecting(true);
+
+ detectionIntervalRef.current = setInterval(() => {
+ const shapes = detectCardShapes();
+ updateTrackedCards(shapes);
+ }, SHAPE_DETECTION_INTERVAL_MS);
+
+ trackingIntervalRef.current = setInterval(() => {
+ if (verificationPausedRef?.current) return;
+
+ const cardsToVerify = selectCardsReadyForVerification(trackedCardsRef.current);
+ cardsToVerify.slice(0, 1).forEach((card) => {
+ onVerifyCard?.(card);
+ });
+ }, VERIFICATION_INTERVAL_MS);
+ };
+
+ const stopCamera = () => {
+ setIsStreaming(false);
+ isStreamingRef.current = false;
+ stopDetection();
+
+ if (streamRef.current) {
+ streamRef.current.getTracks().forEach((track) => track.stop());
+ streamRef.current = null;
+ }
+
+ if (videoRef.current) {
+ videoRef.current.srcObject = null;
+ }
+ };
+
+ const startCamera = async () => {
+ try {
+ console.log('🎥 Starting camera...');
+ const stream = await navigator.mediaDevices.getUserMedia({
+ video: {
+ facingMode: 'environment',
+ width: { ideal: 1280 },
+ height: { ideal: 720 },
+ aspectRatio: { ideal: 16 / 9 },
+ },
+ });
+
+ console.log('📹 Camera stream obtained:', stream);
+
+ if (!videoRef.current) {
+ console.error('❌ Video element not available');
+ onError?.('Video element not available');
+ return;
+ }
+
+ videoRef.current.srcObject = stream;
+ streamRef.current = stream;
+
+ videoRef.current.onloadedmetadata = () => {
+ console.log('📺 Video metadata loaded, attempting to play...');
+ videoRef.current?.play().then(() => {
+ console.log('▶️ Video playback started successfully');
+ setIsStreaming(true);
+ isStreamingRef.current = true;
+ }).catch((err) => {
+ console.error('❌ Video playback failed:', err);
+ onError?.(`Video playback failed: ${err.message}`);
+ });
+ };
+
+ videoRef.current.onerror = (err) => {
+ console.error('❌ Video element error:', err);
+ onError?.('Video element error occurred');
+ };
+
+ setTimeout(() => {
+ if (!isStreamingRef.current && videoRef.current?.readyState >= 2) {
+ console.log('🔄 Fallback: Attempting to play video directly...');
+ videoRef.current.play().then(() => {
+ console.log('▶️ Fallback video playback started');
+ setIsStreaming(true);
+ isStreamingRef.current = true;
+ }).catch(console.error);
+ }
+ }, 2000);
+ } catch (err) {
+ console.error('❌ Camera access error:', err);
+ onError?.(`Unable to access camera: ${err.message}`);
+ }
+ };
+
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video || !isStreaming) return undefined;
+
+ const syncVideoMetrics = () => {
+ setVideoMetrics({
+ width: video.videoWidth || 0,
+ height: video.videoHeight || 0,
+ });
+ };
+
+ video.addEventListener('loadedmetadata', syncVideoMetrics);
+ video.addEventListener('resize', syncVideoMetrics);
+ syncVideoMetrics();
+
+ return () => {
+ video.removeEventListener('loadedmetadata', syncVideoMetrics);
+ video.removeEventListener('resize', syncVideoMetrics);
+ };
+ }, [isStreaming]);
+
+ useEffect(() => {
+ if (!isStreaming || isDetecting) return undefined;
+
+ const timerId = setTimeout(() => {
+ startDetection();
+ }, DETECTION_START_DELAY_MS);
+
+ return () => clearTimeout(timerId);
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- start detection once per stream session
+ }, [isStreaming]);
+
+ useEffect(() => {
+ return () => {
+ stopCamera();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- run stopCamera only on unmount
+ }, []);
+
+ return {
+ videoRef,
+ canvasRef,
+ detectionCanvasRef,
+ isStreaming,
+ isDetecting,
+ trackedCards,
+ videoMetrics,
+ startCamera,
+ stopCamera,
+ };
+}
diff --git a/test/lib/scanner-card-detection.test.js b/test/lib/scanner-card-detection.test.js
index 229e2b0..4e57c50 100644
--- a/test/lib/scanner-card-detection.test.js
+++ b/test/lib/scanner-card-detection.test.js
@@ -3,6 +3,7 @@ import {
convertToVideoCoordinates,
mergeDetectedShapesIntoTrackedCards,
rectanglesOverlap,
+ selectCardsReadyForVerification,
TRACK_STALE_MS,
} from '../../lib/scanner-card-detection.js';
@@ -89,3 +90,38 @@ describe('mergeDetectedShapesIntoTrackedCards', () => {
expect(cards).toHaveLength(0);
});
});
+
+describe('selectCardsReadyForVerification', () => {
+ it('returns detecting cards that are stable long enough', () => {
+ const now = 10_000;
+ const ready = {
+ id: 1,
+ status: 'detecting',
+ stableCount: 6,
+ scanAttempts: 0,
+ firstSeen: now - 3000,
+ };
+ const tooFresh = {
+ id: 2,
+ status: 'detecting',
+ stableCount: 6,
+ scanAttempts: 0,
+ firstSeen: now - 1000,
+ };
+
+ expect(selectCardsReadyForVerification([ready, tooFresh], now)).toEqual([ready]);
+ });
+
+ it('excludes cards that already attempted verification', () => {
+ const now = 10_000;
+ const retried = {
+ id: 1,
+ status: 'detecting',
+ stableCount: 8,
+ scanAttempts: 1,
+ firstSeen: now - 5000,
+ };
+
+ expect(selectCardsReadyForVerification([retried], now)).toHaveLength(0);
+ });
+});