1/* eslint-env browser */ 2import invariant from 'invariant'; 3 4import { 5 CameraType, 6 CameraCapturedPicture, 7 ImageSize, 8 ImageType, 9 WebCameraSettings, 10 CameraPictureOptions, 11} from './Camera.types'; 12import * as CapabilityUtils from './WebCapabilityUtils'; 13import { CameraTypeToFacingMode, ImageTypeFormat, MinimumConstraints } from './WebConstants'; 14import { requestUserMediaAsync } from './WebUserMediaManager'; 15 16interface ConstrainLongRange { 17 max?: number; 18 min?: number; 19 exact?: number; 20 ideal?: number; 21} 22 23export function getImageSize(videoWidth: number, videoHeight: number, scale: number): ImageSize { 24 const width = videoWidth * scale; 25 const ratio = videoWidth / width; 26 const height = videoHeight / ratio; 27 28 return { 29 width, 30 height, 31 }; 32} 33 34export function toDataURL( 35 canvas: HTMLCanvasElement, 36 imageType: ImageType, 37 quality: number 38): string { 39 invariant( 40 Object.values(ImageType).includes(imageType), 41 `expo-camera: ${imageType} is not a valid ImageType. Expected a string from: ${Object.values( 42 ImageType 43 ).join(', ')}` 44 ); 45 46 const format = ImageTypeFormat[imageType]; 47 if (imageType === ImageType.jpg) { 48 invariant( 49 quality <= 1 && quality >= 0, 50 `expo-camera: ${quality} is not a valid image quality. Expected a number from 0...1` 51 ); 52 return canvas.toDataURL(format, quality); 53 } else { 54 return canvas.toDataURL(format); 55 } 56} 57 58export function hasValidConstraints( 59 preferredCameraType?: CameraType, 60 width?: number | ConstrainLongRange, 61 height?: number | ConstrainLongRange 62): boolean { 63 return preferredCameraType !== undefined && width !== undefined && height !== undefined; 64} 65 66function ensureCameraPictureOptions(config: CameraPictureOptions): CameraPictureOptions { 67 const captureOptions = { 68 scale: 1, 69 imageType: ImageType.png, 70 isImageMirror: false, 71 }; 72 73 for (const key in config) { 74 if (key in config && config[key] !== undefined && key in captureOptions) { 75 captureOptions[key] = config[key]; 76 } 77 } 78 return captureOptions; 79} 80 81const DEFAULT_QUALITY = 0.92; 82 83export function captureImageData( 84 video: HTMLVideoElement | null, 85 pictureOptions: Pick<CameraPictureOptions, 'scale' | 'isImageMirror'> = {} 86): ImageData | null { 87 if (!video || video.readyState !== video.HAVE_ENOUGH_DATA) { 88 return null; 89 } 90 const canvas = captureImageContext(video, pictureOptions); 91 92 const context = canvas.getContext('2d', { alpha: false }); 93 if (!context || !canvas.width || !canvas.height) { 94 return null; 95 } 96 97 const imageData = context.getImageData(0, 0, canvas.width, canvas.height); 98 return imageData; 99} 100 101export function captureImageContext( 102 video: HTMLVideoElement, 103 { scale = 1, isImageMirror = false }: Pick<CameraPictureOptions, 'scale' | 'isImageMirror'> 104): HTMLCanvasElement { 105 const { videoWidth, videoHeight } = video; 106 const { width, height } = getImageSize(videoWidth, videoHeight, scale!); 107 108 // Build the canvas size and draw the camera image to the context from video 109 const canvas = document.createElement('canvas'); 110 canvas.width = width; 111 canvas.height = height; 112 const context = canvas.getContext('2d', { alpha: false }); 113 114 if (!context) { 115 // Should never be called 116 throw new Error('Context is not defined'); 117 } 118 // sharp image details 119 // context.imageSmoothingEnabled = false; 120 121 // Flip horizontally (as css transform: rotateY(180deg)) 122 if (isImageMirror) { 123 context.setTransform(-1, 0, 0, 1, canvas.width, 0); 124 } 125 126 context.drawImage(video, 0, 0, width, height); 127 128 return canvas; 129} 130 131export function captureImage( 132 video: HTMLVideoElement, 133 pictureOptions: CameraPictureOptions 134): string { 135 const config = ensureCameraPictureOptions(pictureOptions); 136 const canvas = captureImageContext(video, config); 137 const { imageType, quality = DEFAULT_QUALITY } = config; 138 return toDataURL(canvas, imageType!, quality); 139} 140 141function getSupportedConstraints(): MediaTrackSupportedConstraints | null { 142 if (navigator.mediaDevices && navigator.mediaDevices.getSupportedConstraints) { 143 return navigator.mediaDevices.getSupportedConstraints(); 144 } 145 return null; 146} 147 148export function getIdealConstraints( 149 preferredCameraType: CameraType, 150 width?: number | ConstrainLongRange, 151 height?: number | ConstrainLongRange 152): MediaStreamConstraints { 153 const preferredConstraints: MediaStreamConstraints = { 154 audio: false, 155 video: {}, 156 }; 157 158 if (hasValidConstraints(preferredCameraType, width, height)) { 159 return MinimumConstraints; 160 } 161 162 const supports = getSupportedConstraints(); 163 // TODO(Bacon): Test this 164 if (!supports || !supports.facingMode || !supports.width || !supports.height) { 165 return MinimumConstraints; 166 } 167 168 if (preferredCameraType && Object.values(CameraType).includes(preferredCameraType)) { 169 const facingMode = CameraTypeToFacingMode[preferredCameraType]; 170 if (isWebKit()) { 171 const key = facingMode === 'user' ? 'exact' : 'ideal'; 172 (preferredConstraints.video as MediaTrackConstraints).facingMode = { 173 [key]: facingMode, 174 }; 175 } else { 176 (preferredConstraints.video as MediaTrackConstraints).facingMode = { 177 ideal: CameraTypeToFacingMode[preferredCameraType], 178 }; 179 } 180 } 181 182 if (isMediaTrackConstraints(preferredConstraints.video)) { 183 preferredConstraints.video.width = width; 184 preferredConstraints.video.height = height; 185 } 186 187 return preferredConstraints; 188} 189 190function isMediaTrackConstraints(input: any): input is MediaTrackConstraints { 191 return input && typeof input.video !== 'boolean'; 192} 193 194/** 195 * Invoke getStreamDevice a second time with the opposing camera type if the preferred type cannot be retrieved. 196 * 197 * @param preferredCameraType 198 * @param preferredWidth 199 * @param preferredHeight 200 */ 201export async function getPreferredStreamDevice( 202 preferredCameraType: CameraType, 203 preferredWidth?: number | ConstrainLongRange, 204 preferredHeight?: number | ConstrainLongRange 205): Promise<MediaStream> { 206 try { 207 return await getStreamDevice(preferredCameraType, preferredWidth, preferredHeight); 208 } catch (error) { 209 // A hack on desktop browsers to ensure any camera is used. 210 // eslint-disable-next-line no-undef 211 if (error instanceof OverconstrainedError && error.constraint === 'facingMode') { 212 const nextCameraType = 213 preferredCameraType === CameraType.back ? CameraType.front : CameraType.back; 214 return await getStreamDevice(nextCameraType, preferredWidth, preferredHeight); 215 } 216 throw error; 217 } 218} 219 220export async function getStreamDevice( 221 preferredCameraType: CameraType, 222 preferredWidth?: number | ConstrainLongRange, 223 preferredHeight?: number | ConstrainLongRange 224): Promise<MediaStream> { 225 const constraints: MediaStreamConstraints = getIdealConstraints( 226 preferredCameraType, 227 preferredWidth, 228 preferredHeight 229 ); 230 const stream: MediaStream = await requestUserMediaAsync(constraints); 231 return stream; 232} 233 234export function isWebKit(): boolean { 235 return /WebKit/.test(navigator.userAgent) && !/Edg/.test(navigator.userAgent); 236} 237 238export function compareStreams(a: MediaStream | null, b: MediaStream | null): boolean { 239 if (!a || !b) { 240 return false; 241 } 242 const settingsA = a.getTracks()[0].getSettings(); 243 const settingsB = b.getTracks()[0].getSettings(); 244 return settingsA.deviceId === settingsB.deviceId; 245} 246 247export function capture( 248 video: HTMLVideoElement, 249 settings: MediaTrackSettings, 250 config: CameraPictureOptions 251): CameraCapturedPicture { 252 const base64 = captureImage(video, config); 253 254 const capturedPicture: CameraCapturedPicture = { 255 uri: base64, 256 base64, 257 width: 0, 258 height: 0, 259 }; 260 261 if (settings) { 262 const { width = 0, height = 0 } = settings; 263 capturedPicture.width = width; 264 capturedPicture.height = height; 265 capturedPicture.exif = settings; 266 } 267 268 if (config.onPictureSaved) { 269 config.onPictureSaved(capturedPicture); 270 } 271 return capturedPicture; 272} 273 274export async function syncTrackCapabilities( 275 cameraType: CameraType, 276 stream: MediaStream | null, 277 settings: WebCameraSettings = {} 278): Promise<void> { 279 if (stream?.getVideoTracks) { 280 await Promise.all( 281 stream.getVideoTracks().map((track) => onCapabilitiesReady(cameraType, track, settings)) 282 ); 283 } 284} 285 286// https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints 287async function onCapabilitiesReady( 288 cameraType: CameraType, 289 track: MediaStreamTrack, 290 settings: WebCameraSettings = {} 291): Promise<void> { 292 const capabilities = track.getCapabilities(); 293 294 // Create an empty object because if you set a constraint that isn't available an error will be thrown. 295 const constraints: MediaTrackConstraintSet = {}; 296 297 // TODO(Bacon): Add `pointsOfInterest` support 298 const clampedValues = [ 299 'exposureCompensation', 300 'colorTemperature', 301 'iso', 302 'brightness', 303 'contrast', 304 'saturation', 305 'sharpness', 306 'focusDistance', 307 'zoom', 308 ]; 309 310 for (const property of clampedValues) { 311 if (capabilities[property]) { 312 constraints[property] = convertNormalizedSetting(capabilities[property], settings[property]); 313 } 314 } 315 316 function validatedInternalConstrainedValue<IConvertedType>( 317 constraintKey: string, 318 settingsKey: string, 319 converter: (settingValue: any) => IConvertedType 320 ) { 321 const convertedSetting = converter(settings[settingsKey]); 322 return validatedConstrainedValue({ 323 constraintKey, 324 settingsKey, 325 convertedSetting, 326 capabilities, 327 settings, 328 cameraType, 329 }); 330 } 331 332 if (capabilities.focusMode && settings.autoFocus !== undefined) { 333 constraints.focusMode = validatedInternalConstrainedValue<MediaTrackConstraintSet['focusMode']>( 334 'focusMode', 335 'autoFocus', 336 CapabilityUtils.convertAutoFocusJSONToNative 337 ); 338 } 339 340 if (capabilities.torch && settings.flashMode !== undefined) { 341 constraints.torch = validatedInternalConstrainedValue<MediaTrackConstraintSet['torch']>( 342 'torch', 343 'flashMode', 344 CapabilityUtils.convertFlashModeJSONToNative 345 ); 346 } 347 348 if (capabilities.whiteBalanceMode && settings.whiteBalance !== undefined) { 349 constraints.whiteBalanceMode = validatedInternalConstrainedValue< 350 MediaTrackConstraintSet['whiteBalanceMode'] 351 >('whiteBalanceMode', 'whiteBalance', CapabilityUtils.convertWhiteBalanceJSONToNative); 352 } 353 354 try { 355 await track.applyConstraints({ advanced: [constraints] }); 356 } catch (error) { 357 if (__DEV__) console.warn('Failed to apply constraints', error); 358 } 359} 360 361export function stopMediaStream(stream: MediaStream | null) { 362 if (!stream) { 363 return; 364 } 365 if (stream.getAudioTracks) { 366 stream.getAudioTracks().forEach((track) => track.stop()); 367 } 368 if (stream.getVideoTracks) { 369 stream.getVideoTracks().forEach((track) => track.stop()); 370 } 371 if (isMediaStreamTrack(stream)) { 372 stream.stop(); 373 } 374} 375 376export function setVideoSource( 377 video: HTMLVideoElement, 378 stream: MediaStream | MediaSource | Blob | null 379): void { 380 const createObjectURL = window.URL.createObjectURL ?? window.webkitURL.createObjectURL; 381 382 if (typeof video.srcObject !== 'undefined') { 383 video.srcObject = stream; 384 } else if (typeof (video as any).mozSrcObject !== 'undefined') { 385 (video as any).mozSrcObject = stream; 386 } else if (stream && createObjectURL) { 387 video.src = createObjectURL(stream as MediaSource | Blob); 388 } 389 390 if (!stream) { 391 const revokeObjectURL = window.URL.revokeObjectURL ?? window.webkitURL.revokeObjectURL; 392 const source = video.src ?? video.srcObject ?? (video as any).mozSrcObject; 393 if (revokeObjectURL && typeof source === 'string') { 394 revokeObjectURL(source); 395 } 396 } 397} 398 399export function isCapabilityAvailable(video: HTMLVideoElement, keyName: string): boolean { 400 const stream = video.srcObject; 401 402 if (stream instanceof MediaStream) { 403 const videoTrack = stream.getVideoTracks()[0]; 404 return videoTrack.getCapabilities?.()?.[keyName]; 405 } 406 407 return false; 408} 409 410function isMediaStreamTrack(input: any): input is MediaStreamTrack { 411 return typeof input.stop === 'function'; 412} 413 414function convertNormalizedSetting(range: MediaSettingsRange, value?: number): number | undefined { 415 if (!value) { 416 return; 417 } 418 // convert the normalized incoming setting to the native camera zoom range 419 const converted = convertRange(value, [range.min, range.max]); 420 // clamp value so we don't get an error 421 return Math.min(range.max, Math.max(range.min, converted)); 422} 423 424function convertRange(value: number, r2: number[], r1: number[] = [0, 1]): number { 425 return ((value - r1[0]) * (r2[1] - r2[0])) / (r1[1] - r1[0]) + r2[0]; 426} 427 428function validatedConstrainedValue<T>(props: { 429 constraintKey: string; 430 settingsKey: string; 431 convertedSetting: T; 432 capabilities: MediaTrackCapabilities; 433 settings: WebCameraSettings; 434 cameraType: string; 435}): T | undefined { 436 const { constraintKey, settingsKey, convertedSetting, capabilities, settings, cameraType } = 437 props; 438 const setting = settings[settingsKey]; 439 if ( 440 Array.isArray(capabilities[constraintKey]) && 441 convertedSetting && 442 !capabilities[constraintKey].includes(convertedSetting) 443 ) { 444 if (__DEV__) { 445 // Only warn in dev mode. 446 console.warn( 447 ` { ${settingsKey}: "${setting}" } (converted to "${convertedSetting}" in the browser) is not supported for camera type "${cameraType}" in your browser. Using the default value instead.` 448 ); 449 } 450 return undefined; 451 } 452 return convertedSetting; 453} 454