deckhearth/lib/use-camera-scanner.js
Randall Stillwell ca3b8a78c2 fix(scanner): satisfy react-hooks/immutability and jsdom ResizeObserver in tests
- Move disambiguation-cancel tracker reset into useCameraScanner's new
  resetTrackedCard(cardId) (immutable map + setTrackedCards), wired via
  onTrackerReset — the identification hook no longer mutates state-derived
  objects, clearing the blocking react-hooks/immutability lint error.
- Stub ResizeObserver in test/setup.js so ScannerCamera's workstation
  tests render under jsdom.
- Drop unused eslint-disable directive in CollectionsPageView.

npm run lint: 0 problems; vitest 231/231.
2026-08-23 22:27:49 -05:00

454 lines
13 KiB
JavaScript

import { useCallback, useEffect, useRef, useState } from 'react';
import {
detectCardShapesFromFrame,
DETECTION_START_DELAY_MS,
mergeDetectedShapesIntoTrackedCards,
MIN_STABLE_COUNT_FOR_VERIFY,
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,
autoDetectPausedRef,
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 resetTrackedCard = useCallback((cardId) => {
const next = trackedCardsRef.current.map((card) => (
card.id === cardId
? { ...card, status: 'detecting', scanAttempts: 0, negativeAt: undefined }
: card
));
trackedCardsRef.current = next;
setTrackedCards(next);
}, []);
const resetScanTrackers = useCallback(() => {
trackedCardsRef.current = [];
setTrackedCards([]);
nextCardIdRef.current = 1;
}, []);
/**
* Manual scan: fresh detection pass, then verify the best candidate (or guide bounds).
* @returns {{ ok: boolean, reason?: string }}
*/
const triggerManualScan = useCallback(
(guideVideoBounds = null) => {
if (!isStreamingRef.current) {
return { ok: false, reason: 'not_streaming' };
}
if (verificationPausedRef?.current) {
return { ok: false, reason: 'paused' };
}
resetScanTrackers();
const shapes = detectCardShapes();
updateTrackedCards(shapes);
let target = trackedCardsRef.current
.filter((card) => card.status === 'detecting')
.sort((a, b) => b.stableCount - a.stableCount)[0];
if (!target && guideVideoBounds?.width > 0 && guideVideoBounds?.height > 0) {
target = {
id: nextCardIdRef.current++,
bounds: guideVideoBounds,
corners: null,
status: 'detecting',
firstSeen: Date.now(),
lastSeen: Date.now(),
stableCount: MIN_STABLE_COUNT_FOR_VERIFY,
scanAttempts: 0,
};
trackedCardsRef.current = [target];
setTrackedCards([target]);
}
if (!target) {
return { ok: false, reason: 'no_card' };
}
onVerifyCard?.(target);
return { ok: true };
},
[onVerifyCard, resetScanTrackers, verificationPausedRef]
);
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;
if (autoDetectPausedRef?.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,
resetTrackedCard,
resetScanTrackers,
triggerManualScan,
streamRef,
facingMode: activeFacingMode,
switchFacingMode,
videoDevices,
selectedDeviceId,
setSelectedDeviceId,
devicePickerStatus,
devicePickerMessage,
};
}