import {detectCameraFacingMode, scoreDevice, isVirtualCamera} from "../../core/helpers/camera-helpers.js"; import {zenidLog} from "../../core/zenid-log.js"; import {CameraFacingMode} from "../../interfaces/verification-system-interfaces.js"; import {CameraDeviceInfo} from "./web-camera.js"; export class CameraExplorer { private static userFacingCameras: CameraDeviceInfo[] = []; private static environmentCameras: CameraDeviceInfo[] = []; private static isInitialized = false; private static initPromise: Promise | null = null; /** * Reset camera explorer to force re-scanning of cameras. * Useful when camera settings change (e.g., allowing virtual cameras). */ static reset(): void { CameraExplorer.userFacingCameras = []; CameraExplorer.environmentCameras = []; CameraExplorer.isInitialized = false; CameraExplorer.initPromise = null; zenidLog.info('CameraExplorer reset - cameras will be re-scanned on next init'); } static async init(): Promise { // If already initialized, return immediately if (CameraExplorer.isInitialized) { return; } // If already initializing, return the existing promise if (CameraExplorer.initPromise) { return CameraExplorer.initPromise; } CameraExplorer.initPromise = CameraExplorer.setupCameras(); try { await CameraExplorer.initPromise; if (CameraExplorer.userFacingCameras.length > 0 || CameraExplorer.environmentCameras.length > 0) CameraExplorer.isInitialized = true; else CameraExplorer.initPromise = null; } finally { CameraExplorer.initPromise = null; } } /** * Check if camera is available and permission is granted */ static async isAnyCameraAvailable(): Promise { let anyCameraAvailable = (): boolean => { return CameraExplorer.userFacingCameras.length > 0 || CameraExplorer.environmentCameras.length > 0; } // If we already know cameras exist, return immediately if (anyCameraAvailable()) { return true; } // Otherwise wait for init to complete and check await CameraExplorer.init(); return anyCameraAvailable(); } static async getCamerasByFacingMode(facingMode: CameraFacingMode): Promise { await CameraExplorer.init(); return facingMode === CameraFacingMode.user ? CameraExplorer.userFacingCameras : CameraExplorer.environmentCameras; } /** * Scan all available cameras and detect their facing modes */ private static async scanCameras(): Promise { try { // Prompt for generic camera access to get permissions const permissionStream = await navigator.mediaDevices.getUserMedia({video: true, audio: false}); // Stop the permission stream immediately to free the camera permissionStream.getTracks().forEach(track => track.stop()); const rawDevices = await navigator.mediaDevices.enumerateDevices(); const videoDevices = rawDevices.filter(device => device.kind === 'videoinput'); const enhancedCameras: CameraDeviceInfo[] = []; for (const device of videoDevices) { // Skip virtual cameras (unless explicitly allowed for debugging) const allowVirtualCameras = (window as any).__zenid_allow_virtual_cameras || false; const isVirtual = isVirtualCamera(device.label); zenidLog.debug(`Camera scan: "${device.label}" - isVirtual: ${isVirtual}, allowVirtualCameras: ${allowVirtualCameras}`); if (!allowVirtualCameras && isVirtual) { zenidLog.info(`Skipping virtual camera: ${device.label}`); continue; } try { // Try to access the camera to determine its facing mode and maximum resolution // Request high resolution to get the actual maximum capabilities const stream = await navigator.mediaDevices.getUserMedia({ video: { deviceId: {exact: device.deviceId}, width: {ideal: 4096}, // Request very high resolution height: {ideal: 4096}, // to get the camera's maximum // @ts-ignore: focusDistance is not standard focusDistance: {ideal: 0.3}, // @ts-ignore: focusMode is not standard focusMode: {ideal: 'continuous'}, // Use continuous focus if available zoom: {ideal: 1.0}, // Request default zoom } }); let width: number | undefined; let height: number | undefined; let score = 0; const track = stream?.getVideoTracks()[0]; if (track) { const settings = track.getSettings(); const capabilities = track.getCapabilities(); // Get maximum resolution info if (capabilities?.width?.max && capabilities?.height?.max) { width = capabilities.width.max; height = capabilities.height.max; } else if (settings) { width = settings.width; height = settings.height; } // Get photo capabilities (if available) and calculate score let photoCapabilities = {}; try { // @ts-ignore: ImageCapture might not be available in all environments const imageCapture = typeof ImageCapture !== 'undefined' ? new ImageCapture(track) : null; photoCapabilities = imageCapture ? await imageCapture.getPhotoCapabilities() : {}; } catch (error) { // ImageCapture not supported or failed - no problem, we'll use track capabilities } score = scoreDevice(capabilities, photoCapabilities, device.label); } // Detect facing mode const detectedFacingMode = detectCameraFacingMode( track?.getSettings(), track?.getCapabilities() || {}, device.label ); // Stop the test stream stream?.getTracks().forEach(track => track.stop()); enhancedCameras.push({ deviceId: device.deviceId, label: device.label || `Camera ${enhancedCameras.length + 1}`, facingMode: detectedFacingMode, width, height, score }); } catch (error) { zenidLog.warn(`Failed to test camera ${device.deviceId}:`, error); // Add camera with unknown facing mode as fallback enhancedCameras.push({ deviceId: device.deviceId, label: device.label || `Camera ${enhancedCameras.length + 1}`, facingMode: CameraFacingMode.unknown, score: 0 }); } } return enhancedCameras; } catch (error) { zenidLog.error('Error scanning cameras:', error); return []; } } private static async setupCameras(): Promise { const allCameras = await CameraExplorer.scanCameras(); let unknownCameras = allCameras.filter(cam => cam.facingMode === CameraFacingMode.unknown); let userCameras = allCameras.filter(cam => cam.facingMode === CameraFacingMode.user); let envCameras = allCameras.filter(cam => cam.facingMode === CameraFacingMode.environment); // If no cameras of the requested type, use all available cameras if (userCameras.length === 0) { // No user cameras - accept all cameras for user mode CameraExplorer.userFacingCameras = CameraExplorer.sortByScore(allCameras); } else { CameraExplorer.userFacingCameras = [...CameraExplorer.sortByScore(userCameras), ...CameraExplorer.sortByScore(unknownCameras)]; } if (envCameras.length === 0) { // No environment cameras - accept all cameras for environment mode CameraExplorer.environmentCameras = CameraExplorer.sortByScore(allCameras); } else { CameraExplorer.environmentCameras = [...CameraExplorer.sortByScore(envCameras), ...CameraExplorer.sortByScore(unknownCameras)]; } } private static sortByScore(cameras: CameraDeviceInfo[]): CameraDeviceInfo[] { return cameras.slice().sort((a, b) => { // Sort by score (higher score = better camera) const scoreA = a.score || 0; const scoreB = b.score || 0; if (scoreA !== scoreB) { return scoreB - scoreA; } // Fall back to resolution if scores are equal const resA = Math.min(a.width || 0, a.height || 0); const resB = Math.min(b.width || 0, b.height || 0); return resB - resA; }); } static async canSwitchCamera(facingMode: CameraFacingMode): Promise { await CameraExplorer.init(); switch (facingMode) { case CameraFacingMode.user: return CameraExplorer.userFacingCameras.length > 1; case CameraFacingMode.environment: return CameraExplorer.environmentCameras.length > 1; default: // For unknown facing mode, allow switching if there are multiple cameras of any type return CameraExplorer.userFacingCameras.length > 1 || CameraExplorer.environmentCameras.length > 1; } } }