/* eslint-env browser */ import * as CapabilityUtils from './WebCapabilityUtils'; import { CameraTypeToFacingMode, ImageTypeFormat, MinimumConstraints } from './WebConstants'; import { requestUserMediaAsync } from './WebUserMediaManager'; import { CameraType, CameraCapturedPicture, ImageSize, ImageType, WebCameraSettings, CameraPictureOptions, } from '../Camera.types'; interface ConstrainLongRange { max?: number; min?: number; exact?: number; ideal?: number; } export function getImageSize(videoWidth: number, videoHeight: number, scale: number): ImageSize { const width = videoWidth * scale; const ratio = videoWidth / width; const height = videoHeight / ratio; return { width, height, }; } export function toDataURL( canvas: HTMLCanvasElement, imageType: ImageType, quality: number ): string { const format = ImageTypeFormat[imageType]; if (imageType === 'jpg') { return canvas.toDataURL(format, quality); } else { return canvas.toDataURL(format); } } export function hasValidConstraints( preferredCameraType?: CameraType, width?: number | ConstrainLongRange, height?: number | ConstrainLongRange ): boolean { return preferredCameraType !== undefined && width !== undefined && height !== undefined; } function ensureCameraPictureOptions(config: CameraPictureOptions): CameraPictureOptions { const captureOptions: CameraPictureOptions = { scale: 1, imageType: 'png' as ImageType, isImageMirror: false, }; for (const key in config) { const prop = key as keyof CameraPictureOptions; if (prop in config && config[prop] !== undefined && prop in captureOptions) { captureOptions[prop] = config[prop] as any; } } return captureOptions; } const DEFAULT_QUALITY = 0.92; export function captureImageContext( video: HTMLVideoElement, { scale = 1, isImageMirror = false }: Pick ): HTMLCanvasElement { const { videoWidth, videoHeight } = video; const { width, height } = getImageSize(videoWidth, videoHeight, scale!); // Build the canvas size and draw the camera image to the context from video const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const context = canvas.getContext('2d', { alpha: false }); if (!context) { // Should never be called throw new Error('Context is not defined'); } // sharp image details // context.imageSmoothingEnabled = false; // Flip horizontally (as css transform: rotateY(180deg)) if (isImageMirror) { context.setTransform(-1, 0, 0, 1, canvas.width, 0); } context.drawImage(video, 0, 0, width, height); return canvas; } export function captureImage( video: HTMLVideoElement, pictureOptions: CameraPictureOptions ): string { const config = ensureCameraPictureOptions(pictureOptions); const canvas = captureImageContext(video, config); const { imageType, quality = DEFAULT_QUALITY } = config; return toDataURL(canvas, imageType!, quality); } function getSupportedConstraints(): MediaTrackSupportedConstraints | null { if (navigator.mediaDevices && navigator.mediaDevices.getSupportedConstraints) { return navigator.mediaDevices.getSupportedConstraints(); } return null; } export function getIdealConstraints( preferredCameraType: CameraType, width?: number | ConstrainLongRange, height?: number | ConstrainLongRange ): MediaStreamConstraints { const preferredConstraints: MediaStreamConstraints = { audio: false, video: {}, }; if (hasValidConstraints(preferredCameraType, width, height)) { return MinimumConstraints; } const supports = getSupportedConstraints(); // TODO(Bacon): Test this if (!supports || !supports.facingMode || !supports.width || !supports.height) { return MinimumConstraints; } const types = ['front', 'back']; if (preferredCameraType && types.includes(preferredCameraType)) { const facingMode = CameraTypeToFacingMode[preferredCameraType]; if (isWebKit()) { const key = facingMode === 'user' ? 'exact' : 'ideal'; (preferredConstraints.video as MediaTrackConstraints).facingMode = { [key]: facingMode, }; } else { (preferredConstraints.video as MediaTrackConstraints).facingMode = { ideal: CameraTypeToFacingMode[preferredCameraType], }; } } if (isMediaTrackConstraints(preferredConstraints.video)) { preferredConstraints.video.width = width; preferredConstraints.video.height = height; } return preferredConstraints; } function isMediaTrackConstraints(input: any): input is MediaTrackConstraints { return input && typeof input.video !== 'boolean'; } /** * Invoke getStreamDevice a second time with the opposing camera type if the preferred type cannot be retrieved. * * @param preferredCameraType * @param preferredWidth * @param preferredHeight */ export async function getPreferredStreamDevice( preferredCameraType: CameraType, preferredWidth?: number | ConstrainLongRange, preferredHeight?: number | ConstrainLongRange ): Promise { try { return await getStreamDevice(preferredCameraType, preferredWidth, preferredHeight); } catch (error) { // A hack on desktop browsers to ensure any camera is used. // eslint-disable-next-line no-undef if (error instanceof OverconstrainedError && error.constraint === 'facingMode') { const nextCameraType = preferredCameraType === 'back' ? 'front' : 'back'; return await getStreamDevice(nextCameraType, preferredWidth, preferredHeight); } throw error; } } export async function getStreamDevice( preferredCameraType: CameraType, preferredWidth?: number | ConstrainLongRange, preferredHeight?: number | ConstrainLongRange ): Promise { const constraints: MediaStreamConstraints = getIdealConstraints( preferredCameraType, preferredWidth, preferredHeight ); const stream: MediaStream = await requestUserMediaAsync(constraints); return stream; } export function isWebKit(): boolean { return /WebKit/.test(navigator.userAgent) && !/Edg/.test(navigator.userAgent); } export function compareStreams(a: MediaStream | null, b: MediaStream | null): boolean { if (!a || !b) { return false; } const settingsA = a.getTracks()[0].getSettings(); const settingsB = b.getTracks()[0].getSettings(); return settingsA.deviceId === settingsB.deviceId; } export function capture( video: HTMLVideoElement, settings: MediaTrackSettings, config: CameraPictureOptions ): CameraCapturedPicture { const base64 = captureImage(video, config); const capturedPicture: CameraCapturedPicture = { uri: base64, base64, width: 0, height: 0, format: config.imageType ?? 'jpg', }; if (settings) { const { width = 0, height = 0 } = settings; capturedPicture.width = width; capturedPicture.height = height; capturedPicture.exif = settings; } if (config.onPictureSaved) { config.onPictureSaved(capturedPicture); } return capturedPicture; } export async function syncTrackCapabilities( cameraType: CameraType, stream: MediaStream | null, settings: WebCameraSettings = {} ): Promise { if (stream?.getVideoTracks) { await Promise.all( stream.getVideoTracks().map((track) => onCapabilitiesReady(cameraType, track, settings)) ); } } // https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints async function onCapabilitiesReady( cameraType: CameraType, track: MediaStreamTrack, settings: WebCameraSettings = {} ): Promise { if (typeof track.getCapabilities !== 'function') { return; } const capabilities = track.getCapabilities(); // Create an empty object because if you set a constraint that isn't available an error will be thrown. const constraints: MediaTrackConstraintSet = {}; // TODO(Bacon): Add `pointsOfInterest` support const clampedValues = [ 'exposureCompensation', 'colorTemperature', 'iso', 'brightness', 'contrast', 'saturation', 'sharpness', 'focusDistance', 'zoom', ] as const; for (const property of clampedValues) { if (capabilities[property]) { constraints[property] = convertNormalizedSetting(capabilities[property], settings[property]); } } function validatedInternalConstrainedValue( constraintKey: keyof MediaTrackCapabilities, settingsKey: keyof WebCameraSettings, converter: (settingValue: any) => IConvertedType ) { const convertedSetting = converter(settings[settingsKey]); return validatedConstrainedValue({ constraintKey, settingsKey, convertedSetting, capabilities, settings, cameraType, }); } if (capabilities.focusMode && settings.autoFocus !== undefined) { constraints.focusMode = validatedInternalConstrainedValue( 'focusMode', 'autoFocus', CapabilityUtils.convertAutoFocusJSONToNative ); } if (capabilities.torch && settings.flashMode !== undefined) { constraints.torch = validatedInternalConstrainedValue( 'torch', 'flashMode', CapabilityUtils.convertFlashModeJSONToNative ); } if (capabilities.whiteBalanceMode && settings.whiteBalance !== undefined) { constraints.whiteBalanceMode = validatedInternalConstrainedValue< MediaTrackConstraintSet['whiteBalanceMode'] >('whiteBalanceMode', 'whiteBalance', CapabilityUtils.convertWhiteBalanceJSONToNative); } try { await track.applyConstraints({ advanced: [constraints] }); } catch (error) { if (__DEV__) console.warn('Failed to apply constraints', error); } } export function stopMediaStream(stream: MediaStream | null) { if (!stream) { return; } stream.getAudioTracks().forEach((track) => track.stop()); stream.getVideoTracks().forEach((track) => track.stop()); } export function setVideoSource(video: HTMLVideoElement, stream: MediaStream | null): void { video.srcObject = stream; } export function isCapabilityAvailable( video: HTMLVideoElement, keyName: keyof MediaTrackCapabilities ): boolean { const stream = video.srcObject; if (stream instanceof MediaStream) { const videoTrack = stream.getVideoTracks()[0]; return !!videoTrack.getCapabilities?.()?.[keyName]; } return false; } function convertNormalizedSetting(range: MediaSettingsRange, value?: number): number | undefined { if (!value) { return; } // TODO(@kitten): Handle undefined values / normalize explicitly // convert the normalized incoming setting to the native camera zoom range const converted = convertRange(value, [range.min!, range.max!]); // clamp value so we don't get an error return Math.min(range.max!, Math.max(range.min!, converted)); } function convertRange(value: number, r2: [number, number], r1: [number, number] = [0, 1]): number { return ((value - r1[0]) * (r2[1] - r2[0])) / (r1[1] - r1[0]) + r2[0]; } function validatedConstrainedValue(props: { constraintKey: keyof MediaTrackCapabilities; settingsKey: keyof WebCameraSettings; convertedSetting: T; capabilities: MediaTrackCapabilities; settings: WebCameraSettings; cameraType: string; }): T | undefined { const { constraintKey, settingsKey, convertedSetting, capabilities, settings, cameraType } = props; const setting = settings[settingsKey]; if ( Array.isArray(capabilities[constraintKey]) && convertedSetting && !capabilities[constraintKey].includes(convertedSetting) ) { if (__DEV__) { // Only warn in dev mode. console.warn( ` { ${settingsKey}: "${setting}" } (converted to "${convertedSetting}" in the browser) is not supported for camera type "${cameraType}" in your browser. Using the default value instead.` ); } return undefined; } return convertedSetting; }