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>
384 lines
11 KiB
JavaScript
384 lines
11 KiB
JavaScript
import { useCallback, 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';
|
|
|
|
const SESSION_DEVICE_KEY = 'scanner:last-camera-device-id';
|
|
|
|
const DEVICE_PICKER_MESSAGES = {
|
|
loading: 'Detecting cameras…',
|
|
ready: '',
|
|
empty: 'No camera found. Connect a webcam or use Upload Image.',
|
|
denied: 'Camera access blocked. Allow camera permission or use Upload Image.',
|
|
error: "Couldn't list cameras. Try again or use Upload Image.",
|
|
};
|
|
|
|
/**
|
|
* Camera stream + card shape detection loop for the card scanner.
|
|
* Identification callbacks stay in the parent component.
|
|
*/
|
|
export function useCameraScanner({
|
|
onError,
|
|
onVerifyCard,
|
|
verificationPausedRef,
|
|
facingMode = 'environment',
|
|
}) {
|
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
const [isDetecting, setIsDetecting] = useState(false);
|
|
const [trackedCards, setTrackedCards] = useState([]);
|
|
const [videoMetrics, setVideoMetrics] = useState({ width: 0, height: 0 });
|
|
const [activeFacingMode, setActiveFacingMode] = useState(facingMode);
|
|
const [videoDevices, setVideoDevices] = useState([]);
|
|
const [selectedDeviceId, setSelectedDeviceIdState] = useState(() => {
|
|
try {
|
|
return sessionStorage.getItem(SESSION_DEVICE_KEY) || '';
|
|
} catch {
|
|
return '';
|
|
}
|
|
});
|
|
const [devicePickerStatus, setDevicePickerStatus] = useState('loading');
|
|
const [devicePickerMessage, setDevicePickerMessage] = useState(
|
|
DEVICE_PICKER_MESSAGES.loading
|
|
);
|
|
|
|
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);
|
|
const activeFacingModeRef = useRef(activeFacingMode);
|
|
const facingModeInitializedRef = useRef(false);
|
|
const selectedDeviceIdRef = useRef(selectedDeviceId);
|
|
const selectedDeviceIdInitializedRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
isStreamingRef.current = isStreaming;
|
|
}, [isStreaming]);
|
|
|
|
useEffect(() => {
|
|
activeFacingModeRef.current = activeFacingMode;
|
|
}, [activeFacingMode]);
|
|
|
|
useEffect(() => {
|
|
selectedDeviceIdRef.current = selectedDeviceId;
|
|
}, [selectedDeviceId]);
|
|
|
|
const persistSelectedDeviceId = useCallback((deviceId) => {
|
|
try {
|
|
if (deviceId) {
|
|
sessionStorage.setItem(SESSION_DEVICE_KEY, deviceId);
|
|
} else {
|
|
sessionStorage.removeItem(SESSION_DEVICE_KEY);
|
|
}
|
|
} catch {
|
|
// sessionStorage may be unavailable in private mode or SSR
|
|
}
|
|
}, []);
|
|
|
|
const setSelectedDeviceId = useCallback((deviceId) => {
|
|
setSelectedDeviceIdState(deviceId);
|
|
persistSelectedDeviceId(deviceId);
|
|
}, [persistSelectedDeviceId]);
|
|
|
|
const refreshVideoDevices = useCallback(async () => {
|
|
if (!navigator.mediaDevices?.enumerateDevices) {
|
|
setDevicePickerStatus('error');
|
|
setDevicePickerMessage(DEVICE_PICKER_MESSAGES.error);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
const videoInputs = devices.filter((device) => device.kind === 'videoinput');
|
|
setVideoDevices(videoInputs);
|
|
|
|
if (videoInputs.length === 0) {
|
|
setDevicePickerStatus('empty');
|
|
setDevicePickerMessage(DEVICE_PICKER_MESSAGES.empty);
|
|
return;
|
|
}
|
|
|
|
const availableIds = videoInputs.map((device) => device.deviceId);
|
|
const currentId = selectedDeviceIdRef.current;
|
|
if (currentId && !availableIds.includes(currentId)) {
|
|
const fallbackId = videoInputs[0]?.deviceId || '';
|
|
selectedDeviceIdRef.current = fallbackId;
|
|
setSelectedDeviceIdState(fallbackId);
|
|
persistSelectedDeviceId(fallbackId);
|
|
}
|
|
|
|
setDevicePickerStatus('ready');
|
|
setDevicePickerMessage(DEVICE_PICKER_MESSAGES.ready);
|
|
} catch (err) {
|
|
console.error('enumerateDevices failed:', err);
|
|
setDevicePickerStatus('error');
|
|
setDevicePickerMessage(DEVICE_PICKER_MESSAGES.error);
|
|
}
|
|
}, [persistSelectedDeviceId]);
|
|
|
|
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 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;
|
|
|
|
setIsDetecting(true);
|
|
|
|
detectionIntervalRef.current = setInterval(() => {
|
|
if (document.hidden) return;
|
|
const shapes = detectCardShapes();
|
|
updateTrackedCards(shapes);
|
|
}, SHAPE_DETECTION_INTERVAL_MS);
|
|
|
|
trackingIntervalRef.current = setInterval(() => {
|
|
if (document.hidden) return;
|
|
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 buildVideoConstraints = () => {
|
|
const deviceId = selectedDeviceIdRef.current;
|
|
if (deviceId) {
|
|
return {
|
|
deviceId: { exact: deviceId },
|
|
width: { ideal: 1280 },
|
|
height: { ideal: 720 },
|
|
};
|
|
}
|
|
|
|
return {
|
|
facingMode: activeFacingModeRef.current,
|
|
width: { ideal: 1280 },
|
|
height: { ideal: 720 },
|
|
aspectRatio: { ideal: 16 / 9 },
|
|
};
|
|
};
|
|
|
|
const startCamera = async () => {
|
|
try {
|
|
console.log('🎥 Starting camera...');
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
video: buildVideoConstraints(),
|
|
});
|
|
|
|
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;
|
|
refreshVideoDevices();
|
|
}).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;
|
|
refreshVideoDevices();
|
|
}).catch(console.error);
|
|
}
|
|
}, 2000);
|
|
} catch (err) {
|
|
console.error('❌ Camera access error:', err);
|
|
if (err?.name === 'NotAllowedError') {
|
|
setDevicePickerStatus('denied');
|
|
setDevicePickerMessage(DEVICE_PICKER_MESSAGES.denied);
|
|
}
|
|
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(() => {
|
|
if (!isStreaming) return undefined;
|
|
import('./ocr-worker.js').catch(() => {});
|
|
return undefined;
|
|
}, [isStreaming]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
stopCamera();
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- run stopCamera only on unmount
|
|
}, []);
|
|
|
|
const switchFacingMode = useCallback(() => {
|
|
setActiveFacingMode((prev) => (prev === 'environment' ? 'user' : 'environment'));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!facingModeInitializedRef.current) {
|
|
facingModeInitializedRef.current = true;
|
|
return;
|
|
}
|
|
|
|
if (!isStreamingRef.current) return;
|
|
|
|
stopCamera();
|
|
startCamera();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- restart stream when facing mode toggles
|
|
}, [activeFacingMode]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedDeviceIdInitializedRef.current) {
|
|
selectedDeviceIdInitializedRef.current = true;
|
|
return;
|
|
}
|
|
|
|
if (!isStreamingRef.current) return;
|
|
|
|
stopCamera();
|
|
startCamera();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- restart stream when selected device changes
|
|
}, [selectedDeviceId]);
|
|
|
|
return {
|
|
videoRef,
|
|
canvasRef,
|
|
detectionCanvasRef,
|
|
isStreaming,
|
|
isDetecting,
|
|
trackedCards,
|
|
videoMetrics,
|
|
startCamera,
|
|
stopCamera,
|
|
removeTrackedCard,
|
|
streamRef,
|
|
facingMode: activeFacingMode,
|
|
switchFacingMode,
|
|
videoDevices,
|
|
selectedDeviceId,
|
|
setSelectedDeviceId,
|
|
devicePickerStatus,
|
|
devicePickerMessage,
|
|
};
|
|
}
|