UNPKG

@microblink/blinkinput-in-browser-sdk

Version:

A simple barcode scanning library for WebAssembly-enabled browsers.

789 lines (725 loc) 30.1 kB
/** * Copyright (c) Microblink Ltd. All rights reserved. */ import { bindCameraToVideoFeed, PreferredCameraType, clearVideoFeed, selectCamera, SelectedCamera } from "./CameraUtils"; import { RecognizerRunner, RecognizerResultState } from "./DataStructures"; import { SDKError } from "./SDKError"; import { captureFrame } from "./FrameCapture"; import * as ErrorTypes from "./ErrorTypes"; /** * Explanation why VideoRecognizer has failed to open the camera feed. */ export enum NotSupportedReason { /** navigator.mediaDevices.getUserMedia is not supported by current browser for current context. */ MediaDevicesNotSupported = "MediaDevicesNotSupported", /** Camera with requested features is not available on current device. */ CameraNotFound = "CameraNotFound", /** Camera access was not granted by the user. */ CameraNotAllowed = "CameraNotAllowed", /** Unable to start playing because camera is already in use. */ CameraInUse = "CameraInUse", /** Camera is currently not available due to a OS or hardware error. */ CameraNotAvailable = "CameraNotAvailable", /** There is no provided video element to which the camera feed should be redirected. */ VideoElementNotProvided = "VideoElementNotProvided" } /** * Indicates mode of recognition in VideoRecognizer. */ export enum VideoRecognitionMode { /** Normal recognition */ Recognition, /** Indefinite scan. Useful for profiling the performance of scan (using onDebugText metadata callback) */ RecognitionTest, /** Only detection. Useful for profiling the performance of detection (using onDebugText metadata callback) */ DetectionTest } /** * Invoked when VideoRecognizer finishes the recognition of the video stream. * @param recognitionState The state of recognition after finishing. If RecognizerResultState.Empty or * RecognizerResultState.Empty are returned, this indicates that the scanning * was cancelled or timeout has been reached. */ export type OnScanningDone = ( recognitionState: RecognizerResultState ) => Promise< void > | void; /** * A wrapper around RecognizerRunner that can use it to perform recognition of video feeds - either from live camera or * from predefined video file. */ export class VideoRecognizer { /** * Creates a new VideoRecognizer by opening a camera stream and attaching it to given HTMLVideoElement. If camera * cannot be accessed, the returned promise will be rejected. * * @param cameraFeed HTMLVideoELement to which camera stream should be attached * @param recognizerRunner RecognizerRunner that should be used for video stream recognition * @param cameraId User can provide specific camera ID to be selected and used * @param preferredCameraType Whether back facing or front facing camera is preferred. Obeyed only if there is * a choice (i.e. if device has only front-facing camera, the opened camera will be a front-facing camera, * regardless of preference) */ static async createVideoRecognizerFromCameraStream ( cameraFeed: HTMLVideoElement, recognizerRunner: RecognizerRunner, cameraId: string | null = null, preferredCameraType: PreferredCameraType = PreferredCameraType.BackFacingCamera ): Promise< VideoRecognizer > { // TODO: refactor this function into async/await syntax, instead of reject use throw /* eslint-disable */ return new Promise< VideoRecognizer > ( async ( resolve, reject ) => { // Check for tag name intentionally left out, so it's possible to use VideoRecognizer with custom elements. if ( !cameraFeed || !( cameraFeed instanceof Element ) ) { reject( new SDKError( ErrorTypes.videoRecognizerErrors.elementMissing, { reason: NotSupportedReason.VideoElementNotProvided, } ) ); return; } if ( navigator.mediaDevices && navigator.mediaDevices.getUserMedia !== undefined ) { try { const selectedCamera = await selectCamera( cameraId, preferredCameraType ); if ( selectedCamera === null ) { reject( new SDKError( ErrorTypes.videoRecognizerErrors.cameraMissing, { reason: NotSupportedReason.CameraNotFound, } ) ); return; } const cameraFlipped = await bindCameraToVideoFeed( selectedCamera, cameraFeed, preferredCameraType ); // TODO: await maybe not needed here await recognizerRunner.setCameraPreviewMirrored( cameraFlipped ); resolve( new VideoRecognizer( cameraFeed, recognizerRunner, cameraFlipped, false, selectedCamera.deviceId ) ); } catch( error ) { let errorReason = NotSupportedReason.CameraInUse; let errorCode = ErrorTypes.ErrorCodes.VIDEO_RECOGNIZER_CAMERA_IN_USE; switch( error.name ) { case "NotFoundError": case "OverconstrainedError": errorReason = NotSupportedReason.CameraNotFound; errorCode = ErrorTypes.ErrorCodes.VIDEO_RECOGNIZER_CAMERA_MISSING; break; case "NotAllowedError": case "SecurityError": errorReason = NotSupportedReason.CameraNotAllowed; errorCode = ErrorTypes.ErrorCodes.VIDEO_RECOGNIZER_CAMERA_NOT_ALLOWED; break; case "AbortError": case "NotReadableError": errorReason = NotSupportedReason.CameraNotAvailable; errorCode = ErrorTypes.ErrorCodes.VIDEO_RECOGNIZER_CAMERA_UNAVAILABLE; break; case "TypeError": // this should never happen. If it does, rethrow it throw error; } reject( new SDKError( { message: error.message, code: errorCode, }, { reason: errorReason, } ) ); } } else { reject( new SDKError( ErrorTypes.videoRecognizerErrors.mediaDevicesUnsupported, { reason: NotSupportedReason.MediaDevicesNotSupported } ) ); } } ); /* eslint-enable */ } /** * Creates a new VideoRecognizer by attaching the given URL to video to given HTMLVideoElement and using it to * display video frames while processing them. * * @param videoPath URL of the video file that should be recognized. * @param videoFeed HTMLVideoElement to which video file will be attached * @param recognizerRunner RecognizerRunner that should be used for video stream recognition. */ static async createVideoRecognizerFromVideoPath ( videoPath : string, videoFeed : HTMLVideoElement, recognizerRunner : RecognizerRunner ): Promise< VideoRecognizer > { return new Promise ( ( resolve: ( videoRecognizer: VideoRecognizer ) => void ) => { videoFeed.src = videoPath; videoFeed.currentTime = 0; videoFeed.onended = () => { videoRecognizer.cancelRecognition(); }; const videoRecognizer = new VideoRecognizer( videoFeed, recognizerRunner ); resolve( videoRecognizer ); } ); } /** * **Use only if provided factory functions are not well-suited for your use case.** * * Creates a new VideoRecognizer with provided HTMLVideoElement. * * Keep in mind that HTMLVideoElement **must have** a video feed which is ready to use. * * - If you want to take advantage of provided camera management, use `createVideoRecognizerFromCameraStream` * - In case that static video file should be processed, use `createVideoRecognizerFromVideoPath` * * @param videoFeed HTMLVideoElement with video feed which is going to be processed * @param recognizerRunner RecognizerRunner that should be used for video stream recognition * @param cameraFlipped Whether the camera is flipped, e.g. if front-facing camera is used * @param allowManualVideoPlayout Whether to allow manual video playout. Default value is `false` */ constructor ( videoFeed: HTMLVideoElement, recognizerRunner: RecognizerRunner, cameraFlipped = false, allowManualVideoPlayout = false, deviceId: string | null = null ) { this.videoFeed = videoFeed; this.recognizerRunner = recognizerRunner; this.cameraFlipped = cameraFlipped; this.allowManualVideoPlayout = allowManualVideoPlayout; this.deviceId = deviceId; } deviceId: string | null = null; async flipCamera(): Promise< void > { if ( this.videoFeed ) { if ( !this.cameraFlipped ) { this.videoFeed.style.transform = "scaleX(-1)"; this.cameraFlipped = true; } else { this.videoFeed.style.transform = "scaleX(1)"; this.cameraFlipped = false; } await this.recognizerRunner.setCameraPreviewMirrored( this.cameraFlipped ); } } isCameraFlipped(): boolean { return this.cameraFlipped; } /** * Sets the video recognition mode to be used. * * @param videoRecognitionMode the video recognition mode to be used. */ async setVideoRecognitionMode( videoRecognitionMode: VideoRecognitionMode ): Promise< void > { this.videoRecognitionMode = videoRecognitionMode; const isDetectionMode = this.videoRecognitionMode === VideoRecognitionMode.DetectionTest; await this.recognizerRunner.setDetectionOnlyMode( isDetectionMode ); } /** * Starts the recognition of the video stream associated with this VideoRecognizer. The stream will be unpaused and * recognition loop will start. After recognition completes, a onScanningDone callback will be invoked with state of * the recognition. * * NOTE: As soon as the execution of the callback completes, the recognition loop will continue and recognition * state will be retained. To clear the recognition state, use resetRecognizers (within your callback). To * pause the recognition loop, use pauseRecognition (within your callback) - to resume it later use * resumeRecognition. To completely stop the recognition and video feed, while keeping the ability to use this * VideoRecognizer later, use pauseVideoFeed. To completely stop the recognition and video feed and release * all the resources involved with video stream, use releaseVideoFeed. * * @param onScanningDone Callback that will be invoked when recognition completes. * @param recognitionTimeoutMs Amount of time before returned promise will be resolved regardless of whether * recognition was successful or not. */ startRecognition( onScanningDone: OnScanningDone, recognitionTimeoutMs = 20000 ): Promise< void > { return new Promise( ( resolve, reject ) => { if ( this.videoFeed === null ) { reject( new SDKError( ErrorTypes.videoRecognizerErrors.videoFeedReleased ) ); return; } if ( !this.videoFeed.paused ) { reject( new SDKError( ErrorTypes.videoRecognizerErrors.videoFeedNotPaused ) ); return; } this.cancelled = false; this.recognitionPaused = false; this.clearTimeout(); this.recognitionTimeoutMs = recognitionTimeoutMs; this.onScanningDone = onScanningDone; void this.recognizerRunner.setClearTimeoutCallback( { onClearTimeout: () => this.clearTimeout() } ); this.videoFeed.play().then ( () => this.playPauseEvent().then ( () => resolve() ).catch ( ( error ) => reject( error ) ), /* eslint-disable @typescript-eslint/no-explicit-any */ ( nativeError: any ) => { if ( !this.allowManualVideoPlayout ) { reject ( new SDKError( ErrorTypes.videoRecognizerErrors.playRequestInterrupted, nativeError ) ); return; } if ( !this.videoFeed ) { return; } this.videoFeed.controls = true; this.videoFeed.addEventListener ( "play" , () => void this.playPauseEvent().then().catch( ( error ) => reject( error ) ) ); this.videoFeed.addEventListener ( "pause", () => void this.playPauseEvent().then().catch( ( error ) => reject( error ) ) ); } /* eslint-enable @typescript-eslint/no-explicit-any */ ); } ); } /** * Performs the recognition of the video stream associated with this VideoRecognizer. The stream will be * unpaused, recognition will be performed and promise will be resolved with recognition status. After * the resolution of returned promise, the video stream will be paused, but not released. To release the * stream, use function releaseVideoFeed. * * This is a simple version of startRecognition that should be used for most cases, like when you only need * to perform one scan per video session. * * @param recognitionTimeoutMs Amount of time before returned promise will be resolved regardless of whether * recognition was successful or not. */ recognize( recognitionTimeoutMs = 20000 ): Promise< RecognizerResultState > { return new Promise ( ( resolve: ( recognitionStatus: RecognizerResultState ) => void, reject ) => { try { void this.startRecognition ( ( recognitionState: RecognizerResultState ) => { this.pauseVideoFeed(); resolve( recognitionState ); }, recognitionTimeoutMs ).then ( // Do nothing, callback is used for resolving ).catch ( ( error ) => reject( error ) ); } catch ( error ) { reject( error ); } } ); } /** * Cancels current ongoing recognition. Note that after cancelling the recognition, the callback given to * startRecognition will be immediately called. This also means that the promise returned from method * recognize will be resolved immediately. */ cancelRecognition(): void { this.cancelled = true; } /** * Pauses the video feed. You can resume the feed by calling recognize or startRecognition. * Note that this pauses both the camera feed and recognition. If you just want to pause * recognition, while keeping the camera feed active, call method pauseRecognition. */ pauseVideoFeed(): void { this.pauseRecognition(); if ( this.videoFeed ) { this.videoFeed.pause(); } } /** * Pauses the recognition. This means that video frames that arrive from given video source * will not be recognized. To resume recognition, call resumeRecognition(boolean). * Unlike cancelRecognition, the callback given to startRecognition will not be invoked after pausing * the recognition (unless there is already processing in-flight that may call the callback just before * pausing the actual recognition loop). */ pauseRecognition(): void { this.recognitionPaused = true; } /** * Convenience method for invoking resetRecognizers on associated RecognizerRunner. * @param hardReset Same as in RecognizerRunner.resetRecognizers. */ async resetRecognizers( hardReset: boolean ): Promise< void > { await this.recognizerRunner.resetRecognizers( hardReset ); } /** * Convenience method for accessing RecognizerRunner associated with this VideoRecognizer. * Sometimes it's useful to reconfigure RecognizerRunner while handling onScanningDone callback * and this method makes that much more convenient. */ getRecognizerRunner(): RecognizerRunner { return this.recognizerRunner; } /** * Resumes the recognition. The video feed must not be paused. If it is, an error will be thrown. * If video feed is paused, you should use recognize or startRecognition methods. * @param resetRecognizers Indicates whether resetRecognizers should be invoked while resuming the recognition */ resumeRecognition( resetRecognizers: boolean ): Promise< void > { return new Promise( ( resolve, reject ) => { this.cancelled = false; this.timedOut = false; this.recognitionPaused = false; if ( this.videoFeed && this.videoFeed.paused ) { reject( new SDKError( ErrorTypes.videoRecognizerErrors.feedPaused ) ); return; } setTimeout ( () => { if ( resetRecognizers ) { this.resetRecognizers( true ).then ( () => { this.recognitionLoop().then ( () => resolve() ).catch ( ( error ) => reject( error ) ); } ).catch ( () => { reject( new SDKError( ErrorTypes.videoRecognizerErrors.recognizersResetFailure ) ); } ); } else { void this.recognitionLoop().then ( () => resolve() ).catch ( ( error ) => reject( error ) ); } }, 1 ); } ); } /** * Stops all media stream tracks associated with current HTMLVideoElement and removes any references to it. * Note that after calling this method you can no longer use this VideoRecognizer for recognition. * This method should be called after you no longer plan on performing video recognition to let browser know * that it can release resources related to any media streams used. */ releaseVideoFeed(): void { if ( !this.videoFeed || this.videoFeed?.readyState < this.videoFeed?.HAVE_CURRENT_DATA ) { this.shouldReleaseVideoFeed = true; return; } if ( !this.videoFeed.paused ) { this.cancelRecognition(); } clearVideoFeed( this.videoFeed ); this.videoFeed = null; this.shouldReleaseVideoFeed = false; } /** * Change currently used camera device for recognition. To get list of available camera devices * use "getCameraDevices" method. * * Keep in mind that this method will reset recognizers. * * @param camera Desired camera device which should be used for recognition. */ changeCameraDevice( camera: SelectedCamera ): Promise< void > { return new Promise( ( resolve, reject ) => { if ( this.videoFeed === null ) { reject( new SDKError( ErrorTypes.videoRecognizerErrors.feedMissing ) ); return; } this.pauseRecognition(); clearVideoFeed( this.videoFeed ); bindCameraToVideoFeed( camera, this.videoFeed ).then ( () => { if ( this.videoFeed === null ) { reject( new SDKError( ErrorTypes.videoRecognizerErrors.feedMissing ) ); return; } this.videoFeed.play().then ( () => { // Recognition errors should be handled by `startRecognition` or `recognize` method void this.resumeRecognition( true ); resolve(); }, /* eslint-disable @typescript-eslint/no-explicit-any */ ( nativeError: any ) => { if ( !this.allowManualVideoPlayout ) { reject( new SDKError( ErrorTypes.videoRecognizerErrors.playRequestInterrupted, nativeError ) ); return; } if ( !this.videoFeed ) { reject( new SDKError( ErrorTypes.videoRecognizerErrors.feedMissing ) ); return; } this.videoFeed.controls = true; } /* eslint-enable @typescript-eslint/no-explicit-any */ ); } ).catch ( ( error ) => reject( error ) ); } ); } /** ********************************************************************************************* * PRIVATE AREA */ private videoFeed: HTMLVideoElement | null = null; private recognizerRunner: RecognizerRunner; private cancelled = false; private timedOut = false; private recognitionPaused = false; private recognitionTimeoutMs = 20000; private timeoutID = 0; private videoRecognitionMode: VideoRecognitionMode = VideoRecognitionMode.Recognition; private onScanningDone: OnScanningDone | null = null; private allowManualVideoPlayout = false; private cameraFlipped = false; private shouldReleaseVideoFeed = false; private playPauseEvent(): Promise< void > { return new Promise( ( resolve, reject ) => { if ( this.videoFeed && this.videoFeed.paused ) { this.cancelRecognition(); resolve(); return; } else { this.resumeRecognition( true ).then ( () => resolve() ).catch ( ( error ) => reject( error ) ); } } ); } private recognitionLoop(): Promise< void > { return new Promise( ( resolve, reject ) => { if ( !this.videoFeed ) { reject( new SDKError( ErrorTypes.videoRecognizerErrors.feedMissing ) ); return; } if ( this.shouldReleaseVideoFeed && this.videoFeed.readyState > this.videoFeed.HAVE_CURRENT_DATA ) { this.releaseVideoFeed(); resolve(); return; } const cameraFrame = captureFrame( this.videoFeed ); this.recognizerRunner.processImage( cameraFrame ).then ( ( processResult: RecognizerResultState ) => { const completeFn = () => { if ( !this.recognitionPaused ) { // ensure browser events are processed and then recognize another frame setTimeout( () => { this.recognitionLoop().then ( () => resolve() ).catch ( ( error ) => reject( error ) ); }, 1 ); } else { resolve(); } }; if ( processResult === RecognizerResultState.Valid || this.cancelled || this.timedOut ) { if ( this.videoRecognitionMode === VideoRecognitionMode.Recognition || this.cancelled ) { // valid results, clear the timeout and invoke the callback this.clearTimeout(); if ( this.onScanningDone ) { void this.onScanningDone( processResult ); } // after returning from callback, resume scanning if not paused } else { // in test mode - reset the recognizers and continue the loop indefinitely this.recognizerRunner.resetRecognizers( true ).then ( () => { // clear any time outs this.clearTimeout(); completeFn(); } ).catch ( ( error ) => reject( error ) ); return; } } else if ( processResult === RecognizerResultState.Uncertain ) { if ( this.timeoutID === 0 ) { // first non-empty result - start timeout this.timeoutID = window.setTimeout( () => { this.timedOut = true; }, this.recognitionTimeoutMs ); } completeFn(); return; } else if ( processResult === RecognizerResultState.StageValid ) { // stage recognition is finished, clear timeout and resume recognition this.clearTimeout(); completeFn(); return; } completeFn(); } ).catch ( ( error ) => reject( error ) ); } ); } private clearTimeout() { if ( this.timeoutID > 0 ) { window.clearTimeout( this.timeoutID ); this.timeoutID = 0; } } }