UNPKG

scandit-sdk

Version:

Scandit Barcode Scanner SDK for the Web

674 lines 29 kB
import { CameraResolutionConstraint } from "./barcodePicker/cameraManager"; import { BrowserCompatibility } from "./browserCompatibility"; import { BrowserHelper } from "./browserHelper"; import { Camera } from "./camera"; import { CustomError } from "./customError"; import { Logger } from "./logger"; import { UnsupportedBrowserError } from "./unsupportedBrowserError"; /** * A helper object to interact with cameras. */ export var CameraAccess; (function (CameraAccess) { /** * @hidden * * Standard error names mapping. */ const standardErrorNamesMapping = new Map([ ["DeviceCaptureError", "AbortError"], ["NotSupportedError", "AbortError"], ["ScreenCaptureError", "AbortError"], ["TabCaptureError", "AbortError"], ["TypeError", "AbortError"], ["InvalidStateError", "NotAllowedError"], ["MediaDeviceFailedDueToShutdown", "NotAllowedError"], ["MediaDeviceKillSwitchOn", "NotAllowedError"], ["PermissionDeniedError", "NotAllowedError"], ["PermissionDismissedError", "NotAllowedError"], ["DevicesNotFoundError", "NotFoundError"], ["SourceUnavailableError", "NotReadableError"], ["TrackStartError", "NotReadableError"], ["ConstraintNotSatisfiedError", "OverconstrainedError"], ]); /** * @hidden * * Handle localized camera labels. Supported languages: * English, German, French, Spanish (spain), Portuguese (brasil), Portuguese (portugal), Italian, * Chinese (simplified), Chinese (traditional), Japanese, Russian, Turkish, Dutch, Arabic, Thai, Swedish, * Danish, Vietnamese, Norwegian, Polish, Finnish, Indonesian, Hebrew, Greek, Romanian, Hungarian, Czech, * Catalan, Slovak, Ukraininan, Croatian, Malay, Hindi. */ const backCameraKeywords = [ "rear", "back", "rück", "arrière", "trasera", "trás", "traseira", "posteriore", "后面", "後面", "背面", "后置", "後置", "背置", "задней", "الخلفية", "후", "arka", "achterzijde", "หลัง", "baksidan", "bagside", "sau", "bak", "tylny", "takakamera", "belakang", "אחורית", "πίσω", "spate", "hátsó", "zadní", "darrere", "zadná", "задня", "stražnja", "belakang", "बैक", ]; /** * @hidden * * The (cached) list of available video devices, updated when [[getCameras]] is called for the first time and after * subsequent calls with the *refreshDevices* parameter enabled. The contained devices' order never changes, howver * their deviceIds could change when they are retrieved again after a camera access and stop in some situations. */ let availableVideoDevices; /** * @hidden * * Whether the currently cached available devices are out of date because of a `devicechange` event. */ let outdatedDevices = false; /** * @hidden * * Overrides for main camera for a given camera type on a desktop/laptop device, set when accessing an initial camera. */ CameraAccess.mainCameraForTypeOverridesOnDesktop = new Map(); /** * @hidden * * To be accessed directly only for tests. * * The mapping from deviceIds to camera objects. */ CameraAccess.deviceIdToCameraObjects = new Map(); /** * @hidden * * To be accessed directly only for tests. * * The list of inaccessible deviceIds. */ CameraAccess.inaccessibleDeviceIds = new Set(); /** * @hidden * * Listen to `devicechange` events. */ function deviceChangeListener() { outdatedDevices = true; } /** * @hidden * * @param label The camera label. * @returns Whether the label mentions the camera being a back-facing one. */ function isBackCameraLabel(label) { const lowercaseLabel = label.toLowerCase(); return backCameraKeywords.some((keyword) => { return lowercaseLabel.includes(keyword); }); } /** * @hidden * * Map non-standard error names to standard ones. * * @param error The error object. */ function mapNonStandardErrorName(error) { let name; if (error.message === "Invalid constraint") { name = "OverconstrainedError"; } else { name = standardErrorNamesMapping.get(error.name) ?? error.name; } Object.defineProperty(error, "name", { value: name, }); } /** * @hidden * * Get the main camera for the given camera type. * * @param cameras The array of available [[Camera]] objects. * @param cameraType The wanted camera type. * @returns The main camera matching the wanted camera type. */ function getMainCameraForType(cameras, cameraType) { let mainCameraForType; if (BrowserHelper.isDesktopDevice()) { // When the device is a desktop/laptop, the overridden camera for the given type or, if not present, the first // camera of the given type is the main one. if (CameraAccess.mainCameraForTypeOverridesOnDesktop.has(cameraType)) { mainCameraForType = CameraAccess.mainCameraForTypeOverridesOnDesktop.get(cameraType); } else { // Note that if the device is a desktop/laptop, with no labels all cameras are assumed to be front ones, // so this will return the first camera as the main front one and none for the back one. mainCameraForType = cameras.filter((camera) => { return camera.cameraType === cameraType; })[0]; } } else { const allHaveBlankLabel = cameras.every((camera) => { return camera.label === ""; }); const allHaveNonEmptyLabel = cameras.every((camera) => { return camera.label !== ""; }); const someHaveLabel = cameras.length > 1 && !allHaveBlankLabel && !allHaveNonEmptyLabel; if (allHaveBlankLabel) { // When no camera label is available cameras are already in front to back order, assume main front camera is the // first one and main back camera is the last one. mainCameraForType = cameras[cameraType === Camera.Type.FRONT ? 0 : cameras.length - 1]; } else if (someHaveLabel) { // When only a few cameras have labels, we may be in a webview where only labels from accessed stream are // available. const cameraOfType = cameras.filter((camera) => { return camera.cameraType === cameraType; }); if (cameraOfType.length === 1) { mainCameraForType = cameraOfType[0]; } else if (cameraOfType.length > 1) { // Assume main front camera is the first one and main back camera is the last one. mainCameraForType = cameraOfType[cameraType === Camera.Type.FRONT ? 0 : cameraOfType.length - 1]; } } else { mainCameraForType = cameras .filter((camera) => { return camera.cameraType === cameraType; }) // sort so that camera list looks like ['camera1 0', 'camera1 1', 'camera2 0', 'camera2 1'] .sort((camera1, camera2) => { return camera1.label.localeCompare(camera2.label); })[0]; } } return mainCameraForType; } CameraAccess.getMainCameraForType = getMainCameraForType; /** * @hidden * * Sort the given cameras in order of priority of access based on the given camera type. * * @param cameras The array of available [[Camera]] objects. * @param cameraType The preferred camera type. * @returns The sorted cameras. */ function sortCamerasForCameraType(cameras, cameraType) { function prioritizeMainCameraOverride(prioritizedCameras, currentCameraType) { const mainCameraOverride = CameraAccess.mainCameraForTypeOverridesOnDesktop.get(currentCameraType); if (mainCameraOverride != null && prioritizedCameras.includes(mainCameraOverride)) { prioritizedCameras = prioritizedCameras.filter((camera) => { return camera !== mainCameraOverride; }); prioritizedCameras.unshift(mainCameraOverride); } return prioritizedCameras; } let frontCameras = cameras.filter((camera) => { return camera.cameraType === Camera.Type.FRONT; }); let backCameras = cameras.filter((camera) => { return camera.cameraType === Camera.Type.BACK; }); if (BrowserHelper.isDesktopDevice()) { // When the device is a desktop/laptop, the cameras for each type are already ordered, we move the overrides // first if present and change front / back group order if needed. frontCameras = prioritizeMainCameraOverride(frontCameras, Camera.Type.FRONT); backCameras = prioritizeMainCameraOverride(backCameras, Camera.Type.BACK); } else if (cameras.every((camera) => { return camera.label === ""; })) { // When no camera label is available cameras are already in front to back order, we assume front cameras are // ordered and back cameras are in reversed order (try to access last first), and we change front / back group // order if needed. backCameras.reverse(); } else { frontCameras.sort((camera1, camera2) => { return camera1.label.localeCompare(camera2.label); }); backCameras.sort((camera1, camera2) => { return camera1.label.localeCompare(camera2.label); }); } return cameraType === Camera.Type.FRONT ? [...frontCameras, ...backCameras] : [...backCameras, ...frontCameras]; } CameraAccess.sortCamerasForCameraType = sortCamerasForCameraType; /** * @hidden * * Adjusts the camera's information based on the given currently active video stream. * * @param mediaStream The currently active `MediaStream` object. * @param camera The currently active [[Camera]] object associated with the video stream. */ function adjustCameraFromMediaStream(mediaStream, camera) { const videoTracks = mediaStream.getVideoTracks(); if (videoTracks.length !== 0) { const mediaStreamTrack = videoTracks[0]; let mediaTrackSettings; if (typeof mediaStreamTrack.getSettings === "function") { mediaTrackSettings = mediaStreamTrack.getSettings(); if (mediaTrackSettings?.facingMode != null && mediaTrackSettings.facingMode.length > 0) { camera.cameraType = mediaTrackSettings.facingMode === "environment" ? Camera.Type.BACK : Camera.Type.FRONT; } } if (mediaStreamTrack.label != null && mediaStreamTrack.label.length > 0) { camera.label = mediaStreamTrack.label; } } } CameraAccess.adjustCameraFromMediaStream = adjustCameraFromMediaStream; /** * @hidden * * @param devices The list of available devices. * @returns The extracted list of accessible camera objects initialized from the given devices. */ function extractAccessibleCamerasFromDevices(devices) { function createCamera(videoDevice, index, videoDevices) { if (CameraAccess.deviceIdToCameraObjects.has(videoDevice.deviceId)) { return CameraAccess.deviceIdToCameraObjects.get(videoDevice.deviceId); } const label = videoDevice.label ?? ""; let cameraType; if (!BrowserHelper.isDesktopDevice() && videoDevices.every((device) => { return device.label === "" && !CameraAccess.deviceIdToCameraObjects.has(device.deviceId); })) { // When the device is not a desktop/laptop and no camera label is available, assume the camera is a front one // if it's the only one or comes in the first half of the list of cameras (if an odd number of cameras is // available, it's more likely to have more back than front ones). cameraType = videoDevices.length === 1 || index + 1 <= Math.floor(videoDevices.length / 2) ? Camera.Type.FRONT : Camera.Type.BACK; } else { // Note that if the device is a desktop/laptop, unless the label specifies a back camera, a front one is assumed cameraType = isBackCameraLabel(label) ? Camera.Type.BACK : Camera.Type.FRONT; } return { deviceId: videoDevice.deviceId, label, cameraType, }; } const cameras = devices .map(createCamera) .map((camera) => { // If it's the initial camera, do nothing if (camera.deviceId !== "") { CameraAccess.deviceIdToCameraObjects.set(camera.deviceId, camera); } return camera; }) .filter((camera) => { // Ignore infrared cameras as they often fail to be accessed and are not useful in any case return !/\b(?:ir|infrared)\b/i.test(camera.label); }) .filter((camera) => { return !CameraAccess.inaccessibleDeviceIds.has(camera.deviceId); }); if (!BrowserHelper.isDesktopDevice() && cameras.length > 1 && !cameras.some((camera) => { return camera.cameraType === Camera.Type.BACK; })) { // When the device is not a desktop/laptop check if cameras are labeled with resolution information, if that's the // case, take the higher - resolution one, otherwise pick the last camera (it's not true that the primary camera // is first in most scenarios) and mark it as the back one. let backCameraIndex = cameras.length - 1; const cameraResolutions = cameras.map((camera) => { const match = camera.label.match(/\b([0-9]+)MP?\b/i); if (match != null) { return parseInt(match[1], 10); } return NaN; }); if (!cameraResolutions.some((cameraResolution) => { return isNaN(cameraResolution); })) { backCameraIndex = cameraResolutions.lastIndexOf(Math.max(...cameraResolutions)); } cameras[backCameraIndex].cameraType = Camera.Type.BACK; } return cameras; } /** * @hidden * * @returns The stream, if necessary, accessed to provide access to complete device information */ async function getStreamForDeviceAccessPermission() { // If there are video devices and all of them have no label it means we need to access a camera before we can get // the needed information if (availableVideoDevices != null && availableVideoDevices.length > 0 && availableVideoDevices.every((device) => { return device.label === "" && !CameraAccess.deviceIdToCameraObjects.has(device.deviceId); })) { try { return await navigator.mediaDevices.getUserMedia({ video: true, audio: false, }); } catch { // Ignored } } } /** * @hidden * * Checks and adjust cameras' deviceId information and related information if a change is detected. We can rely on the * fact that devices are returned in the same order even when deviceId information changes. * * @param oldAvailableDevices The old list of available devices before deviceId information was refreshed. * @param newAvailableDevices The new list of available devices after deviceId information was refreshed. */ function checkAndUpdateCameraDeviceIdInformation(oldAvailableDevices, newAvailableDevices) { if (newAvailableDevices.length > 0 && oldAvailableDevices.length === newAvailableDevices.length && !newAvailableDevices.every((device, index) => { return oldAvailableDevices[index].deviceId === device.deviceId; })) { const deviceIdChanges = {}; oldAvailableDevices.forEach((device, index) => { const camera = CameraAccess.deviceIdToCameraObjects.get(device.deviceId); if (camera == null || camera.label !== (newAvailableDevices[index].label ?? "")) { return; } const newDeviceId = newAvailableDevices[index].deviceId; deviceIdChanges[camera.deviceId] = newDeviceId; if (CameraAccess.inaccessibleDeviceIds.has(camera.deviceId)) { CameraAccess.inaccessibleDeviceIds.add(newDeviceId); } camera.deviceId = newDeviceId; CameraAccess.deviceIdToCameraObjects.set(newDeviceId, camera); }); Logger.log(Logger.Level.DEBUG, "Detected updated camera deviceId information and updated it accordingly", deviceIdChanges); } } /** * Get a list of cameras (if any) available on the device, a camera access permission is requested to the user * the first time this method is called if needed. * * If the browser is incompatible the returned promise is rejected with a `UnsupportedBrowserError` error. * * When refreshing available devices, if updated deviceId information is detected, cameras' deviceId are updated * accordingly. This could happen after a camera access and stop in some situations. * * @param refreshDevices Force a call to refresh available video devices even when information is already available. * @param cameraAlreadyAccessed Hint that a camera has already been accessed before, avoiding a possible initial * camera access permission request on the first call, in cases this cannot be already reliably detected. * @returns A promise resolving to the array of available [[Camera]] objects (could be empty). */ async function getCameras(refreshDevices = false, cameraAlreadyAccessed = false) { const browserCompatibility = BrowserHelper.checkBrowserCompatibility(); if (!browserCompatibility.fullSupport) { throw new UnsupportedBrowserError(browserCompatibility); } // This will add the listeners only once in case of multiple calls: identical listeners are ignored navigator.mediaDevices.addEventListener("devicechange", deviceChangeListener); if (availableVideoDevices == null || refreshDevices || outdatedDevices) { outdatedDevices = false; let stream; const oldAvailableDevices = availableVideoDevices ?? []; availableVideoDevices = []; try { availableVideoDevices = await enumerateVideoDevices(); if (!cameraAlreadyAccessed) { stream = await getStreamForDeviceAccessPermission(); if (stream != null) { availableVideoDevices = await enumerateVideoDevices(); } } Logger.log(Logger.Level.DEBUG, "Camera list (devices):", ...availableVideoDevices); checkAndUpdateCameraDeviceIdInformation(oldAvailableDevices, availableVideoDevices); } catch (error) { mapNonStandardErrorName(error); throw error; } finally { if (stream != null) { stream.getVideoTracks().forEach((track) => { track.stop(); }); } } } const cameras = extractAccessibleCamerasFromDevices(availableVideoDevices); Logger.log(Logger.Level.DEBUG, "Camera list (cameras): ", ...cameras); // Return a copy of the array to allow for array mutations in other functions return [...cameras]; } CameraAccess.getCameras = getCameras; /** * @hidden * * Call `navigator.mediaDevices.getUserMedia` asynchronously in a `setTimeout` call. * * @param getUserMediaParams The parameters for the `navigator.mediaDevices.getUserMedia` call. * @returns A promise resolving when the camera is accessed. */ function getUserMediaDelayed(getUserMediaParams) { Logger.log(Logger.Level.DEBUG, "Attempt to access camera (parameters):", getUserMediaParams.video); return new Promise((resolve, reject) => { window.setTimeout(() => { (navigator.mediaDevices.getUserMedia(getUserMediaParams) ?? Promise.reject(new CustomError({ name: "AbortError" }))) .then(resolve) .catch(reject); }, 0); }); } /** * @hidden * * Get the *getUserMedia* *video* parameters to be used given a resolution fallback level and the browser used. * * @param cameraResolutionConstraint The resolution constraint. * @returns The resulting *getUserMedia* *video* parameters. */ function getUserMediaVideoParams(cameraResolutionConstraint) { const userMediaVideoParams = { // @ts-ignore resizeMode: "none", }; switch (cameraResolutionConstraint) { case CameraResolutionConstraint.ULTRA_HD: return { ...userMediaVideoParams, width: { min: 3200, ideal: 3840, max: 4096 }, height: { min: 1800, ideal: 2160, max: 2400 }, }; case CameraResolutionConstraint.FULL_HD: return { ...userMediaVideoParams, width: { min: 1400, ideal: 1920, max: 2160 }, height: { min: 900, ideal: 1080, max: 1440 }, }; case CameraResolutionConstraint.HD: return { ...userMediaVideoParams, width: { min: 960, ideal: 1280, max: 1440 }, height: { min: 480, ideal: 720, max: 960 }, }; case CameraResolutionConstraint.SD: return { ...userMediaVideoParams, width: { min: 640, ideal: 640, max: 800 }, height: { min: 480, ideal: 480, max: 600 }, }; case CameraResolutionConstraint.NONE: default: return {}; } } /** * @hidden * * Try to access a given camera for video input at the given resolution level. * * If a camera is inaccessible because of errors, then it's added to the inaccessible device list. If the specific * error is of type `OverconstrainedError` or `NotReadableError` however, this procedure is done later on via a * separate external logic; also, in case of an error of type `NotAllowedError` (permission denied) this procedure is * not executed, in order to possibly recover if and when the user allows the camera to be accessed again. * This is done to allow checking if the camera can still be accessed via an updated deviceId when deviceId * information changes, or if it should then be confirmed to be considered inaccessible. * * Depending on parameters, device features and user permissions for camera access, any of the following errors * could be the rejected result of the returned promise: * - `AbortError` * - `NotAllowedError` * - `NotFoundError` * - `NotReadableError` * - `SecurityError` * - `OverconstrainedError` * * @param cameraResolutionConstraint The resolution constraint. * @param camera The camera to try to access for video input. * @returns A promise resolving to the `MediaStream` object coming from the accessed camera. */ async function accessCameraStream(cameraResolutionConstraint, camera) { Logger.log(Logger.Level.DEBUG, "Attempt to access camera (camera):", camera); const getUserMediaParams = { audio: false, video: getUserMediaVideoParams(cameraResolutionConstraint), }; // If it's the initial camera, use the given cameraType, otherwise use the given deviceId if (camera.deviceId === "") { getUserMediaParams.video.facingMode = { ideal: camera.cameraType === Camera.Type.BACK ? "environment" : "user", }; } else { getUserMediaParams.video.deviceId = { exact: camera.deviceId, }; } try { const mediaStream = await getUserMediaDelayed(getUserMediaParams); adjustCameraFromMediaStream(mediaStream, camera); return mediaStream; } catch (error) { mapNonStandardErrorName(error); if (!["OverconstrainedError", "NotReadableError", "NotAllowedError"].includes(error.name)) { markCameraAsInaccessible(camera); } throw error; } } CameraAccess.accessCameraStream = accessCameraStream; /** * @hidden * * Mark a camera to be inaccessible and thus excluded from the camera list returned by [[getCameras]]. * * @param camera The camera to mark to be inaccessible. */ function markCameraAsInaccessible(camera) { // If it's the initial camera, do nothing if (camera.deviceId !== "") { Logger.log(Logger.Level.DEBUG, "Camera marked to be inaccessible:", camera); CameraAccess.inaccessibleDeviceIds.add(camera.deviceId); } } CameraAccess.markCameraAsInaccessible = markCameraAsInaccessible; /** * @hidden * * Get a list of available video devices in a cross-browser compatible way. * * @returns A promise resolving to the `MediaDeviceInfo` array of all available video devices. */ async function enumerateVideoDevices() { let devices; if (typeof navigator.enumerateDevices === "function") { devices = await navigator.enumerateDevices(); } else if (typeof navigator.mediaDevices === "object" && typeof navigator.mediaDevices.enumerateDevices === "function") { devices = await navigator.mediaDevices.enumerateDevices(); } else { try { if (window.MediaStreamTrack?.getSources == null) { throw new Error(); } devices = await new Promise((resolve) => { window.MediaStreamTrack?.getSources?.(resolve); }); devices = devices .filter((device) => { return device.kind.toLowerCase() === "video" || device.kind.toLowerCase() === "videoinput"; }) .map((device) => { return { deviceId: device.deviceId ?? "", groupId: device.groupId, kind: "videoinput", label: device.label, toJSON: /* istanbul ignore next */ function () { return this; }, }; }); } catch { throw new UnsupportedBrowserError({ fullSupport: false, scannerSupport: true, missingFeatures: [BrowserCompatibility.Feature.MEDIA_DEVICES], }); } } return devices.filter((device) => { return device.kind === "videoinput"; }); } })(CameraAccess || (CameraAccess = {})); //# sourceMappingURL=cameraAccess.js.map