1/* eslint-env browser */ 2import invariant from 'invariant'; 3import { CameraType, ImageType, } from './Camera.types'; 4import * as CapabilityUtils from './WebCapabilityUtils'; 5import { CameraTypeToFacingMode, ImageTypeFormat, MinimumConstraints } from './WebConstants'; 6import { requestUserMediaAsync } from './WebUserMediaManager'; 7export function getImageSize(videoWidth, videoHeight, scale) { 8 const width = videoWidth * scale; 9 const ratio = videoWidth / width; 10 const height = videoHeight / ratio; 11 return { 12 width, 13 height, 14 }; 15} 16export function toDataURL(canvas, imageType, quality) { 17 invariant(Object.values(ImageType).includes(imageType), `expo-camera: ${imageType} is not a valid ImageType. Expected a string from: ${Object.values(ImageType).join(', ')}`); 18 const format = ImageTypeFormat[imageType]; 19 if (imageType === ImageType.jpg) { 20 invariant(quality <= 1 && quality >= 0, `expo-camera: ${quality} is not a valid image quality. Expected a number from 0...1`); 21 return canvas.toDataURL(format, quality); 22 } 23 else { 24 return canvas.toDataURL(format); 25 } 26} 27export function hasValidConstraints(preferredCameraType, width, height) { 28 return preferredCameraType !== undefined && width !== undefined && height !== undefined; 29} 30function ensureCameraPictureOptions(config) { 31 const captureOptions = { 32 scale: 1, 33 imageType: ImageType.png, 34 isImageMirror: false, 35 }; 36 for (const key in config) { 37 if (key in config && config[key] !== undefined && key in captureOptions) { 38 captureOptions[key] = config[key]; 39 } 40 } 41 return captureOptions; 42} 43const DEFAULT_QUALITY = 0.92; 44export function captureImageData(video, pictureOptions = {}) { 45 if (!video || video.readyState !== video.HAVE_ENOUGH_DATA) { 46 return null; 47 } 48 const canvas = captureImageContext(video, pictureOptions); 49 const context = canvas.getContext('2d', { alpha: false }); 50 if (!context || !canvas.width || !canvas.height) { 51 return null; 52 } 53 const imageData = context.getImageData(0, 0, canvas.width, canvas.height); 54 return imageData; 55} 56export function captureImageContext(video, { scale = 1, isImageMirror = false }) { 57 const { videoWidth, videoHeight } = video; 58 const { width, height } = getImageSize(videoWidth, videoHeight, scale); 59 // Build the canvas size and draw the camera image to the context from video 60 const canvas = document.createElement('canvas'); 61 canvas.width = width; 62 canvas.height = height; 63 const context = canvas.getContext('2d', { alpha: false }); 64 if (!context) { 65 // Should never be called 66 throw new Error('Context is not defined'); 67 } 68 // sharp image details 69 // context.imageSmoothingEnabled = false; 70 // Flip horizontally (as css transform: rotateY(180deg)) 71 if (isImageMirror) { 72 context.setTransform(-1, 0, 0, 1, canvas.width, 0); 73 } 74 context.drawImage(video, 0, 0, width, height); 75 return canvas; 76} 77export function captureImage(video, pictureOptions) { 78 const config = ensureCameraPictureOptions(pictureOptions); 79 const canvas = captureImageContext(video, config); 80 const { imageType, quality = DEFAULT_QUALITY } = config; 81 return toDataURL(canvas, imageType, quality); 82} 83function getSupportedConstraints() { 84 if (navigator.mediaDevices && navigator.mediaDevices.getSupportedConstraints) { 85 return navigator.mediaDevices.getSupportedConstraints(); 86 } 87 return null; 88} 89export function getIdealConstraints(preferredCameraType, width, height) { 90 const preferredConstraints = { 91 audio: false, 92 video: {}, 93 }; 94 if (hasValidConstraints(preferredCameraType, width, height)) { 95 return MinimumConstraints; 96 } 97 const supports = getSupportedConstraints(); 98 // TODO(Bacon): Test this 99 if (!supports || !supports.facingMode || !supports.width || !supports.height) { 100 return MinimumConstraints; 101 } 102 if (preferredCameraType && Object.values(CameraType).includes(preferredCameraType)) { 103 const facingMode = CameraTypeToFacingMode[preferredCameraType]; 104 if (isWebKit()) { 105 const key = facingMode === 'user' ? 'exact' : 'ideal'; 106 preferredConstraints.video.facingMode = { 107 [key]: facingMode, 108 }; 109 } 110 else { 111 preferredConstraints.video.facingMode = { 112 ideal: CameraTypeToFacingMode[preferredCameraType], 113 }; 114 } 115 } 116 if (isMediaTrackConstraints(preferredConstraints.video)) { 117 preferredConstraints.video.width = width; 118 preferredConstraints.video.height = height; 119 } 120 return preferredConstraints; 121} 122function isMediaTrackConstraints(input) { 123 return input && typeof input.video !== 'boolean'; 124} 125/** 126 * Invoke getStreamDevice a second time with the opposing camera type if the preferred type cannot be retrieved. 127 * 128 * @param preferredCameraType 129 * @param preferredWidth 130 * @param preferredHeight 131 */ 132export async function getPreferredStreamDevice(preferredCameraType, preferredWidth, preferredHeight) { 133 try { 134 return await getStreamDevice(preferredCameraType, preferredWidth, preferredHeight); 135 } 136 catch (error) { 137 // A hack on desktop browsers to ensure any camera is used. 138 // eslint-disable-next-line no-undef 139 if (error instanceof OverconstrainedError && error.constraint === 'facingMode') { 140 const nextCameraType = preferredCameraType === CameraType.back ? CameraType.front : CameraType.back; 141 return await getStreamDevice(nextCameraType, preferredWidth, preferredHeight); 142 } 143 throw error; 144 } 145} 146export async function getStreamDevice(preferredCameraType, preferredWidth, preferredHeight) { 147 const constraints = getIdealConstraints(preferredCameraType, preferredWidth, preferredHeight); 148 const stream = await requestUserMediaAsync(constraints); 149 return stream; 150} 151export function isWebKit() { 152 return /WebKit/.test(navigator.userAgent) && !/Edg/.test(navigator.userAgent); 153} 154export function compareStreams(a, b) { 155 if (!a || !b) { 156 return false; 157 } 158 const settingsA = a.getTracks()[0].getSettings(); 159 const settingsB = b.getTracks()[0].getSettings(); 160 return settingsA.deviceId === settingsB.deviceId; 161} 162export function capture(video, settings, config) { 163 const base64 = captureImage(video, config); 164 const capturedPicture = { 165 uri: base64, 166 base64, 167 width: 0, 168 height: 0, 169 }; 170 if (settings) { 171 const { width = 0, height = 0 } = settings; 172 capturedPicture.width = width; 173 capturedPicture.height = height; 174 capturedPicture.exif = settings; 175 } 176 if (config.onPictureSaved) { 177 config.onPictureSaved(capturedPicture); 178 } 179 return capturedPicture; 180} 181export async function syncTrackCapabilities(cameraType, stream, settings = {}) { 182 if (stream?.getVideoTracks) { 183 await Promise.all(stream.getVideoTracks().map((track) => onCapabilitiesReady(cameraType, track, settings))); 184 } 185} 186// https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints 187async function onCapabilitiesReady(cameraType, track, settings = {}) { 188 const capabilities = track.getCapabilities(); 189 // Create an empty object because if you set a constraint that isn't available an error will be thrown. 190 const constraints = {}; 191 // TODO(Bacon): Add `pointsOfInterest` support 192 const clampedValues = [ 193 'exposureCompensation', 194 'colorTemperature', 195 'iso', 196 'brightness', 197 'contrast', 198 'saturation', 199 'sharpness', 200 'focusDistance', 201 'zoom', 202 ]; 203 for (const property of clampedValues) { 204 if (capabilities[property]) { 205 constraints[property] = convertNormalizedSetting(capabilities[property], settings[property]); 206 } 207 } 208 function validatedInternalConstrainedValue(constraintKey, settingsKey, converter) { 209 const convertedSetting = converter(settings[settingsKey]); 210 return validatedConstrainedValue({ 211 constraintKey, 212 settingsKey, 213 convertedSetting, 214 capabilities, 215 settings, 216 cameraType, 217 }); 218 } 219 if (capabilities.focusMode && settings.autoFocus !== undefined) { 220 constraints.focusMode = validatedInternalConstrainedValue('focusMode', 'autoFocus', CapabilityUtils.convertAutoFocusJSONToNative); 221 } 222 if (capabilities.torch && settings.flashMode !== undefined) { 223 constraints.torch = validatedInternalConstrainedValue('torch', 'flashMode', CapabilityUtils.convertFlashModeJSONToNative); 224 } 225 if (capabilities.whiteBalanceMode && settings.whiteBalance !== undefined) { 226 constraints.whiteBalanceMode = validatedInternalConstrainedValue('whiteBalanceMode', 'whiteBalance', CapabilityUtils.convertWhiteBalanceJSONToNative); 227 } 228 try { 229 await track.applyConstraints({ advanced: [constraints] }); 230 } 231 catch (error) { 232 if (__DEV__) 233 console.warn('Failed to apply constraints', error); 234 } 235} 236export function stopMediaStream(stream) { 237 if (!stream) { 238 return; 239 } 240 if (stream.getAudioTracks) { 241 stream.getAudioTracks().forEach((track) => track.stop()); 242 } 243 if (stream.getVideoTracks) { 244 stream.getVideoTracks().forEach((track) => track.stop()); 245 } 246 if (isMediaStreamTrack(stream)) { 247 stream.stop(); 248 } 249} 250export function setVideoSource(video, stream) { 251 const createObjectURL = window.URL.createObjectURL ?? window.webkitURL.createObjectURL; 252 if (typeof video.srcObject !== 'undefined') { 253 video.srcObject = stream; 254 } 255 else if (typeof video.mozSrcObject !== 'undefined') { 256 video.mozSrcObject = stream; 257 } 258 else if (stream && createObjectURL) { 259 video.src = createObjectURL(stream); 260 } 261 if (!stream) { 262 const revokeObjectURL = window.URL.revokeObjectURL ?? window.webkitURL.revokeObjectURL; 263 const source = video.src ?? video.srcObject ?? video.mozSrcObject; 264 if (revokeObjectURL && typeof source === 'string') { 265 revokeObjectURL(source); 266 } 267 } 268} 269export function isCapabilityAvailable(video, keyName) { 270 const stream = video.srcObject; 271 if (stream instanceof MediaStream) { 272 const videoTrack = stream.getVideoTracks()[0]; 273 return videoTrack.getCapabilities?.()?.[keyName]; 274 } 275 return false; 276} 277function isMediaStreamTrack(input) { 278 return typeof input.stop === 'function'; 279} 280function convertNormalizedSetting(range, value) { 281 if (!value) { 282 return; 283 } 284 // convert the normalized incoming setting to the native camera zoom range 285 const converted = convertRange(value, [range.min, range.max]); 286 // clamp value so we don't get an error 287 return Math.min(range.max, Math.max(range.min, converted)); 288} 289function convertRange(value, r2, r1 = [0, 1]) { 290 return ((value - r1[0]) * (r2[1] - r2[0])) / (r1[1] - r1[0]) + r2[0]; 291} 292function validatedConstrainedValue(props) { 293 const { constraintKey, settingsKey, convertedSetting, capabilities, settings, cameraType } = props; 294 const setting = settings[settingsKey]; 295 if (Array.isArray(capabilities[constraintKey]) && 296 convertedSetting && 297 !capabilities[constraintKey].includes(convertedSetting)) { 298 if (__DEV__) { 299 // Only warn in dev mode. 300 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.`); 301 } 302 return undefined; 303 } 304 return convertedSetting; 305} 306//# sourceMappingURL=WebCameraUtils.js.map