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