refactor(scanner): extract camera lifecycle hook (Brief 4) #71
4 changed files with 317 additions and 205 deletions
|
|
@ -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 { rateLimitCooldownUntil, uploadScanCapture } from '../lib/scan-capture-upload.js';
|
||||||
import {
|
|
||||||
detectCardShapesFromFrame,
|
|
||||||
mergeDetectedShapesIntoTrackedCards,
|
|
||||||
} from '../lib/scanner-card-detection.js';
|
|
||||||
import {
|
import {
|
||||||
buildScannedCardPayload,
|
buildScannedCardPayload,
|
||||||
getScanAuthHeaders,
|
getScanAuthHeaders,
|
||||||
|
|
@ -13,59 +9,40 @@ import {
|
||||||
VISION_RATE_LIMIT_MS,
|
VISION_RATE_LIMIT_MS,
|
||||||
fetchVisionIdentify,
|
fetchVisionIdentify,
|
||||||
} from '../lib/scanner-card-identify.js';
|
} from '../lib/scanner-card-identify.js';
|
||||||
|
import { useCameraScanner } from '../lib/use-camera-scanner.js';
|
||||||
import ScanDisambiguationDialog from './ScanDisambiguationDialog.js';
|
import ScanDisambiguationDialog from './ScanDisambiguationDialog.js';
|
||||||
|
|
||||||
export default function CameraScanner({ onCardScanned, onError }) {
|
export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
|
||||||
const [isDetecting, setIsDetecting] = useState(false);
|
|
||||||
const [disambiguation, setDisambiguation] = useState(null);
|
const [disambiguation, setDisambiguation] = useState(null);
|
||||||
const [scanNotice, setScanNotice] = useState(null);
|
const [scanNotice, setScanNotice] = useState(null);
|
||||||
const [submittingReview, setSubmittingReview] = useState(false);
|
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 visionCooldownUntilRef = useRef(0);
|
||||||
const activeVerificationRef = useRef(0);
|
const activeVerificationRef = useRef(0);
|
||||||
const lastErrorAtRef = useRef(0);
|
const lastErrorAtRef = useRef(0);
|
||||||
const disambiguationRefineRef = useRef(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (canvasRef.current) {
|
verificationPausedRef.current = Boolean(disambiguation);
|
||||||
const ctx = canvasRef.current.getContext('2d', { willReadFrequently: true });
|
}, [disambiguation]);
|
||||||
}
|
|
||||||
if (detectionCanvasRef.current) {
|
|
||||||
const ctx = detectionCanvasRef.current.getContext('2d', { willReadFrequently: true });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Continuous shape detection for card-like rectangles
|
const {
|
||||||
const detectCardShapes = () => {
|
videoRef,
|
||||||
if (!videoRef.current || !detectionCanvasRef.current || !isStreaming) return [];
|
canvasRef,
|
||||||
return detectCardShapesFromFrame(videoRef.current, detectionCanvasRef.current);
|
detectionCanvasRef,
|
||||||
};
|
isStreaming,
|
||||||
|
isDetecting,
|
||||||
const updateTrackedCards = (detectedShapes) => {
|
trackedCards,
|
||||||
const { cards, nextCardId } = mergeDetectedShapesIntoTrackedCards(
|
videoMetrics,
|
||||||
trackedCardsRef.current,
|
startCamera,
|
||||||
detectedShapes,
|
stopCamera,
|
||||||
nextCardIdRef.current
|
} = useCameraScanner({
|
||||||
);
|
onError,
|
||||||
nextCardIdRef.current = nextCardId;
|
verificationPausedRef,
|
||||||
trackedCardsRef.current = cards;
|
onVerifyCard: (card) => onVerifyCardRef.current(card),
|
||||||
setTrackedCards(cards);
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const emitScannedCard = async (cardTracker, imageData, finalCard, ocrMeta = {}) => {
|
const emitScannedCard = async (cardTracker, imageData, finalCard, ocrMeta = {}) => {
|
||||||
cardTracker.status = 'scanned';
|
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(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
onVerifyCardRef.current = verifyCardShape;
|
||||||
if (!video || !isStreaming) return undefined;
|
|
||||||
|
|
||||||
const syncVideoMetrics = () => {
|
|
||||||
setVideoMetrics({
|
|
||||||
width: video.videoWidth || 0,
|
|
||||||
height: video.videoHeight || 0,
|
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
video.addEventListener('loadedmetadata', syncVideoMetrics);
|
const foundCardCount = trackedCards.filter(
|
||||||
video.addEventListener('resize', syncVideoMetrics);
|
(card) => card.status === 'confirmed' || card.status === 'scanned'
|
||||||
syncVideoMetrics();
|
).length;
|
||||||
|
|
||||||
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
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex flex-col">
|
<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="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="flex items-center gap-2">
|
||||||
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
<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 &&
|
🎯 Scanning {foundCardCount > 0 && `(${foundCardCount} found)`}
|
||||||
`(${trackedCards.filter(card => card.status === 'confirmed' || card.status === 'scanned').length} found)`}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,40 @@ export const DETECTION_CANVAS_HEIGHT = 240;
|
||||||
/** Milliseconds before a tracked card is dropped when not re-detected. */
|
/** Milliseconds before a tracked card is dropped when not re-detected. */
|
||||||
export const TRACK_STALE_MS = 3000;
|
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. */
|
/** Convert detection-canvas coordinates to video pixel coordinates. */
|
||||||
export function convertToVideoCoordinates(detection, canvas, video) {
|
export function convertToVideoCoordinates(detection, canvas, video) {
|
||||||
const scaleX = video.videoWidth / canvas.width;
|
const scaleX = video.videoWidth / canvas.width;
|
||||||
|
|
|
||||||
220
lib/use-camera-scanner.js
Normal file
220
lib/use-camera-scanner.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
convertToVideoCoordinates,
|
convertToVideoCoordinates,
|
||||||
mergeDetectedShapesIntoTrackedCards,
|
mergeDetectedShapesIntoTrackedCards,
|
||||||
rectanglesOverlap,
|
rectanglesOverlap,
|
||||||
|
selectCardsReadyForVerification,
|
||||||
TRACK_STALE_MS,
|
TRACK_STALE_MS,
|
||||||
} from '../../lib/scanner-card-detection.js';
|
} from '../../lib/scanner-card-detection.js';
|
||||||
|
|
||||||
|
|
@ -89,3 +90,38 @@ describe('mergeDetectedShapesIntoTrackedCards', () => {
|
||||||
expect(cards).toHaveLength(0);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue