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); + }); +});