refactor(scanner): extract camera lifecycle hook (Brief 4) #71

Merged
varutasu merged 1 commit from refactor/camera-scanner-split-brief-4 into main 2026-06-02 16:44:56 -04:00
4 changed files with 317 additions and 205 deletions
Showing only changes of commit 9439b12f01 - Show all commits

View file

@ -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;
const syncVideoMetrics = () => {
setVideoMetrics({
width: video.videoWidth || 0,
height: video.videoHeight || 0,
onVerifyCardRef.current = verifyCardShape;
});
};
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 (
<div className="w-full h-full flex flex-col">
@ -555,8 +378,7 @@ export default function CameraScanner({ onCardScanned, onError }) {
<div className="bg-black bg-opacity-80 text-white px-4 py-2 rounded-full text-sm font-medium shadow-lg backdrop-blur-sm">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
🎯 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)`}
</div>
</div>
)}

View file

@ -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;

220
lib/use-camera-scanner.js Normal file
View file

@ -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,
};
}

View file

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