UNPKG

expo-camera

Version:

A React component that renders a preview for the device's either front or back camera. Camera's parameters like zoom, auto focus, white balance and flash mode are adjustable. With expo-camera, one can also take photos and record videos that are saved to t

407 lines (347 loc) 11.6 kB
/* eslint-env browser */ import invariant from 'invariant'; import { PictureOptions } from '../Camera.types'; import { CameraType, CapturedPicture, CaptureOptions, ImageType } from './CameraModule.types'; import * as Utils from './CameraUtils'; import * as CapabilityUtils from './CapabilityUtils'; import { FacingModeToCameraType, PictureSizes } from './constants'; import { isBackCameraAvailableAsync, isFrontCameraAvailableAsync } from './UserMediaManager'; export { ImageType, CameraType, CaptureOptions }; type OnCameraReadyListener = () => void; type OnMountErrorListener = (event: { nativeEvent: Error }) => void; export type WebCameraSettings = Partial<{ autoFocus: string; flashMode: string; whiteBalance: string; exposureCompensation: number; colorTemperature: number; iso: number; brightness: number; contrast: number; saturation: number; sharpness: number; focusDistance: number; zoom: number; }>; const VALID_SETTINGS_KEYS = [ 'autoFocus', 'flashMode', 'exposureCompensation', 'colorTemperature', 'iso', 'brightness', 'contrast', 'saturation', 'sharpness', 'focusDistance', 'whiteBalance', 'zoom', ]; class CameraModule { public onCameraReady: OnCameraReadyListener = () => {}; public onMountError: OnMountErrorListener = () => {}; private stream: MediaStream | null = null; private settings: MediaTrackSettings | null = null; private pictureSize?: string; private isStartingCamera = false; private cameraType: CameraType = CameraType.front; private webCameraSettings: WebCameraSettings = { autoFocus: 'continuous', flashMode: 'off', whiteBalance: 'continuous', zoom: 1, }; public get type(): CameraType { return this.cameraType; } constructor(private videoElement: HTMLVideoElement) { if (this.videoElement) { this.videoElement.addEventListener('loadedmetadata', () => { this.syncTrackCapabilities(); }); } } public async updateWebCameraSettingsAsync(nextSettings: { [key: string]: any; }): Promise<boolean> { const changes: WebCameraSettings = {}; for (const key of Object.keys(nextSettings)) { if (!VALID_SETTINGS_KEYS.includes(key)) continue; const nextValue = nextSettings[key]; if (nextValue !== this.webCameraSettings[key]) { changes[key] = nextValue; } } // Only update the native camera if changes were found const hasChanges = !!Object.keys(changes).length; this.webCameraSettings = { ...this.webCameraSettings, ...changes }; if (hasChanges) { await this.syncTrackCapabilities(changes); } return hasChanges; } public async setTypeAsync(value: CameraType) { if (value === this.cameraType) { return; } this.cameraType = value; await this.resumePreview(); } public setPictureSize(value: string) { if (value === this.pictureSize) { return; } invariant( PictureSizes.includes(value), `expo-camera: CameraModule.setPictureSize(): invalid size supplied ${value}, expected one of: ${PictureSizes.join( ', ' )}` ); // TODO: Bacon: IMP // const [width, height] = value.split('x'); // const aspectRatio = parseFloat(width) / parseFloat(height); this.pictureSize = value; } public isTorchAvailable(): boolean { return isCapabilityAvailable(this.videoElement, 'torch'); } public isZoomAvailable(): boolean { return isCapabilityAvailable(this.videoElement, 'zoom'); } // https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints private async onCapabilitiesReady( track: MediaStreamTrack, settings: WebCameraSettings = {} ): Promise<void> { 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', ]; for (const property of clampedValues) { if (capabilities[property]) { constraints[property] = convertNormalizedSetting( capabilities[property], settings[property] ); } } const _validatedConstrainedValue = (key, propName, converter) => validatedConstrainedValue( key, propName, converter(settings[propName]), capabilities, settings, this.cameraType ); if (capabilities.focusMode && settings.autoFocus !== undefined) { constraints.focusMode = _validatedConstrainedValue( 'focusMode', 'autoFocus', CapabilityUtils.convertAutoFocusJSONToNative ); } if (capabilities.torch && settings.flashMode !== undefined) { constraints.torch = _validatedConstrainedValue( 'torch', 'flashMode', CapabilityUtils.convertFlashModeJSONToNative ); } if (capabilities.whiteBalanceMode && settings.whiteBalance !== undefined) { constraints.whiteBalanceMode = _validatedConstrainedValue( 'whiteBalanceMode', 'whiteBalance', CapabilityUtils.convertWhiteBalanceJSONToNative ); } await track.applyConstraints({ advanced: [constraints] as any }); } private async applyVideoConstraints(constraints: { [key: string]: any }): Promise<boolean> { if (!this.stream || !this.stream.getVideoTracks) { return false; } return await applyConstraints(this.stream.getVideoTracks(), constraints); } private async applyAudioConstraints(constraints: { [key: string]: any }): Promise<boolean> { if (!this.stream || !this.stream.getAudioTracks) { return false; } return await applyConstraints(this.stream.getAudioTracks(), constraints); } private async syncTrackCapabilities(settings: WebCameraSettings = {}): Promise<void> { if (this.stream && this.stream.getVideoTracks) { await Promise.all( this.stream.getVideoTracks().map(track => this.onCapabilitiesReady(track, settings)) ); } } private setStream(stream: MediaStream | null): void { this.stream = stream; this.settings = stream ? stream.getTracks()[0].getSettings() : null; setVideoSource(this.videoElement, stream); } public getActualCameraType(): CameraType | null { if (this.settings) { // On desktop no value will be returned, in this case we should assume the cameraType is 'front' const { facingMode = 'user' } = this.settings; return FacingModeToCameraType[facingMode]; } return null; } public async ensureCameraIsRunningAsync(): Promise<void> { if (!this.stream) { await this.resumePreview(); } } public async resumePreview(): Promise<MediaStream | null> { if (this.isStartingCamera) { return null; } this.isStartingCamera = true; try { this.stopAsync(); const stream = await Utils.getStreamDevice(this.type); this.setStream(stream); this.isStartingCamera = false; this.onCameraReady(); return stream; } catch (error) { this.isStartingCamera = false; this.onMountError({ nativeEvent: error }); } return null; } public takePicture(config: PictureOptions): CapturedPicture { const base64 = Utils.captureImage(this.videoElement, config); const capturedPicture: CapturedPicture = { uri: base64, base64, width: 0, height: 0, }; if (this.settings) { const { width = 0, height = 0 } = this.settings; capturedPicture.width = width; capturedPicture.height = height; // TODO: Bacon: verify/enforce exif shape. capturedPicture.exif = this.settings; } if (config.onPictureSaved) { config.onPictureSaved({ nativeEvent: { data: capturedPicture, id: config.id } }); } return capturedPicture; } public stopAsync(): void { stopMediaStream(this.stream); this.setStream(null); } // TODO: Bacon: we don't even use ratio in native... public getAvailablePictureSizes = async (ratio: string): Promise<string[]> => { return PictureSizes; }; public getAvailableCameraTypesAsync = async (): Promise<string[]> => { if (!navigator.mediaDevices.enumerateDevices) { return []; } const devices = await navigator.mediaDevices.enumerateDevices(); const types: (string | null)[] = await Promise.all([ (await isFrontCameraAvailableAsync(devices)) && CameraType.front, (await isBackCameraAvailableAsync()) && CameraType.back, ]); return types.filter(Boolean) as string[]; }; } function stopMediaStream(stream: MediaStream | null) { if (!stream) return; if (stream.getAudioTracks) stream.getAudioTracks().forEach(track => track.stop()); if (stream.getVideoTracks) stream.getVideoTracks().forEach(track => track.stop()); if (isMediaStreamTrack(stream)) stream.stop(); } function setVideoSource( video: HTMLVideoElement, stream: MediaStream | MediaSource | Blob | null ): void { try { video.srcObject = stream; } catch (_) { if (stream) { video.src = window.URL.createObjectURL(stream); } else if (typeof video.src === 'string') { window.URL.revokeObjectURL(video.src); } } } async function applyConstraints( tracks: MediaStreamTrack[], constraints: { [key: string]: any } ): Promise<boolean> { try { await Promise.all( tracks.map(async track => { await track.applyConstraints({ advanced: [constraints] as any }); }) ); return true; } catch (_) { return false; } } function isCapabilityAvailable(video: HTMLVideoElement, keyName: string): boolean { const stream = video.srcObject; if (stream instanceof MediaStream) { const videoTrack = stream.getVideoTracks()[0]; if (typeof videoTrack.getCapabilities === 'undefined') { return false; } const capabilities: any = videoTrack.getCapabilities(); return !!capabilities[keyName]; } return false; } function isMediaStreamTrack(input: any): input is MediaStreamTrack { return typeof input.stop === 'function'; } function convertNormalizedSetting(range: MediaSettingsRange, value?: number): number | undefined { if (!value) return; // 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[], r1: number[] = [0, 1]): number { return ((value - r1[0]) * (r2[1] - r2[0])) / (r1[1] - r1[0]) + r2[0]; } function validatedConstrainedValue( constraintKey: string, settingsKey: string, convertedSetting: any, capabilities: MediaTrackCapabilities, settings: any, cameraType: string ): any { const setting = settings[settingsKey]; if ( Array.isArray(capabilities[constraintKey]) && convertedSetting && !capabilities[constraintKey].includes(convertedSetting) ) { 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; } export default CameraModule;