@uppy/webcam
Version:
Uppy plugin that takes photos or records videos using the device's camera.
606 lines (594 loc) • 20.7 kB
JavaScript
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
function _classPrivateFieldLooseBase(e, t) { if (!{}.hasOwnProperty.call(e, t)) throw new TypeError("attempted to use private field on non-instance"); return e; }
var id = 0;
function _classPrivateFieldLooseKey(e) { return "__private_" + id++ + "_" + e; }
import { h } from 'preact';
import { UIPlugin } from '@uppy/core';
import getFileTypeExtension from '@uppy/utils/lib/getFileTypeExtension';
import mimeTypes from '@uppy/utils/lib/mimeTypes';
import isMobile from 'is-mobile';
import canvasToBlob from '@uppy/utils/lib/canvasToBlob';
import supportsMediaRecorder from './supportsMediaRecorder.js';
import CameraIcon from "./CameraIcon.js";
import CameraScreen from "./CameraScreen.js";
import PermissionsScreen from "./PermissionsScreen.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore We don't want TS to generate types for the package.json
const packageJson = {
"version": "4.1.2"
};
import locale from './locale.js';
/**
* Normalize a MIME type or file extension into a MIME type.
*
* @param fileType - MIME type or a file extension prefixed with `.`.
* @returns The MIME type or `undefined` if the fileType is an extension and is not known.
*/
function toMimeType(fileType) {
if (fileType[0] === '.') {
return mimeTypes[fileType.slice(1)];
}
return fileType;
}
/**
* Is this MIME type a video?
*/
function isVideoMimeType(mimeType) {
return /^video\/[^*]+$/.test(mimeType);
}
/**
* Is this MIME type an image?
*/
function isImageMimeType(mimeType) {
return /^image\/[^*]+$/.test(mimeType);
}
function getMediaDevices() {
// bug in the compatibility data
// eslint-disable-next-line compat/compat
return navigator.mediaDevices;
}
function isModeAvailable(modes, mode) {
return modes.includes(mode);
}
// set default options
const defaultOptions = {
onBeforeSnapshot: () => Promise.resolve(),
countdown: false,
modes: ['video-audio', 'video-only', 'audio-only', 'picture'],
mirror: true,
showVideoSourceDropdown: false,
preferredImageMimeType: null,
preferredVideoMimeType: null,
showRecordingLength: false,
mobileNativeCamera: isMobile({
tablet: true
})
};
/**
* Webcam
*/
var _enableMirror = /*#__PURE__*/_classPrivateFieldLooseKey("enableMirror");
export default class Webcam extends UIPlugin {
constructor(uppy, opts) {
super(uppy, {
...defaultOptions,
...opts
});
// enableMirror is used to toggle mirroring, for instance when discarding the video,
// while `opts.mirror` is used to remember the initial user setting
Object.defineProperty(this, _enableMirror, {
writable: true,
value: void 0
});
this.stream = null;
this.recorder = null;
this.recordingChunks = null;
this.captureInProgress = false;
this.mediaDevices = getMediaDevices();
this.supportsUserMedia = !!this.mediaDevices;
// eslint-disable-next-line no-restricted-globals
this.protocol = location.protocol.match(/https/i) ? 'https' : 'http';
this.id = this.opts.id || 'Webcam';
this.type = 'acquirer';
this.capturedMediaFile = null;
this.icon = () => h("svg", {
"aria-hidden": "true",
focusable: "false",
width: "32",
height: "32",
viewBox: "0 0 32 32"
}, h("path", {
d: "M23.5 9.5c1.417 0 2.5 1.083 2.5 2.5v9.167c0 1.416-1.083 2.5-2.5 2.5h-15c-1.417 0-2.5-1.084-2.5-2.5V12c0-1.417 1.083-2.5 2.5-2.5h2.917l1.416-2.167C13 7.167 13.25 7 13.5 7h5c.25 0 .5.167.667.333L20.583 9.5H23.5zM16 11.417a4.706 4.706 0 00-4.75 4.75 4.704 4.704 0 004.75 4.75 4.703 4.703 0 004.75-4.75c0-2.663-2.09-4.75-4.75-4.75zm0 7.825c-1.744 0-3.076-1.332-3.076-3.074 0-1.745 1.333-3.077 3.076-3.077 1.744 0 3.074 1.333 3.074 3.076s-1.33 3.075-3.074 3.075z",
fill: "#02B383",
fillRule: "nonzero"
}));
this.defaultLocale = locale;
this.i18nInit();
this.title = this.i18n('pluginNameCamera');
_classPrivateFieldLooseBase(this, _enableMirror)[_enableMirror] = this.opts.mirror;
this.install = this.install.bind(this);
this.setPluginState = this.setPluginState.bind(this);
this.render = this.render.bind(this);
// Camera controls
this.start = this.start.bind(this);
this.stop = this.stop.bind(this);
this.takeSnapshot = this.takeSnapshot.bind(this);
this.startRecording = this.startRecording.bind(this);
this.stopRecording = this.stopRecording.bind(this);
this.discardRecordedVideo = this.discardRecordedVideo.bind(this);
this.submit = this.submit.bind(this);
this.oneTwoThreeSmile = this.oneTwoThreeSmile.bind(this);
this.focus = this.focus.bind(this);
this.changeVideoSource = this.changeVideoSource.bind(this);
this.webcamActive = false;
if (this.opts.countdown) {
this.opts.onBeforeSnapshot = this.oneTwoThreeSmile;
}
this.setPluginState({
hasCamera: false,
cameraReady: false,
cameraError: null,
recordingLengthSeconds: 0,
videoSources: [],
currentDeviceId: null
});
}
setOptions(newOpts) {
super.setOptions({
...newOpts,
videoConstraints: {
// May be undefined but ... handles that
...this.opts.videoConstraints,
...(newOpts == null ? void 0 : newOpts.videoConstraints)
}
});
}
hasCameraCheck() {
if (!this.mediaDevices) {
return Promise.resolve(false);
}
return this.mediaDevices.enumerateDevices().then(devices => {
return devices.some(device => device.kind === 'videoinput');
});
}
isAudioOnly() {
return this.opts.modes.length === 1 && this.opts.modes[0] === 'audio-only';
}
getConstraints(deviceId) {
if (deviceId === void 0) {
deviceId = null;
}
const acceptsAudio = this.opts.modes.indexOf('video-audio') !== -1 || this.opts.modes.indexOf('audio-only') !== -1;
const acceptsVideo = !this.isAudioOnly() && (this.opts.modes.indexOf('video-audio') !== -1 || this.opts.modes.indexOf('video-only') !== -1 || this.opts.modes.indexOf('picture') !== -1);
const videoConstraints = {
...(this.opts.videoConstraints || {}),
...(deviceId != null && {
deviceId
})
};
return {
audio: acceptsAudio,
video: acceptsVideo ? videoConstraints : false
};
}
// eslint-disable-next-line consistent-return
start(options) {
var _options;
if (options === void 0) {
options = null;
}
if (!this.supportsUserMedia) {
return Promise.reject(new Error('Webcam access not supported'));
}
this.webcamActive = true;
if (this.opts.mirror) {
_classPrivateFieldLooseBase(this, _enableMirror)[_enableMirror] = true;
}
const constraints = this.getConstraints((_options = options) == null ? void 0 : _options.deviceId);
// TODO: add a return and/or convert this to async/await
this.hasCameraCheck().then(hasCamera => {
this.setPluginState({
hasCamera
});
// ask user for access to their camera
return this.mediaDevices.getUserMedia(constraints).then(stream => {
this.stream = stream;
let currentDeviceId = null;
const tracks = this.isAudioOnly() ? stream.getAudioTracks() : stream.getVideoTracks();
if (!options || !options.deviceId) {
currentDeviceId = tracks[0].getSettings().deviceId;
} else {
tracks.forEach(track => {
if (track.getSettings().deviceId === options.deviceId) {
currentDeviceId = track.getSettings().deviceId;
}
});
}
// Update the sources now, so we can access the names.
this.updateVideoSources();
this.setPluginState({
currentDeviceId,
cameraReady: true
});
}).catch(err => {
this.setPluginState({
cameraReady: false,
cameraError: err
});
this.uppy.info(err.message, 'error');
});
});
}
getMediaRecorderOptions() {
const options = {};
// Try to use the `opts.preferredVideoMimeType` or one of the `allowedFileTypes` for the recording.
// If the browser doesn't support it, we'll fall back to the browser default instead.
// Safari doesn't have the `isTypeSupported` API.
if (MediaRecorder.isTypeSupported) {
const {
restrictions
} = this.uppy.opts;
let preferredVideoMimeTypes = [];
if (this.opts.preferredVideoMimeType) {
preferredVideoMimeTypes = [this.opts.preferredVideoMimeType];
} else if (restrictions.allowedFileTypes) {
preferredVideoMimeTypes = restrictions.allowedFileTypes.map(toMimeType).filter(isVideoMimeType);
}
const filterSupportedTypes = candidateType => MediaRecorder.isTypeSupported(candidateType) && getFileTypeExtension(candidateType);
const acceptableMimeTypes = preferredVideoMimeTypes.filter(filterSupportedTypes);
if (acceptableMimeTypes.length > 0) {
// eslint-disable-next-line prefer-destructuring
options.mimeType = acceptableMimeTypes[0];
}
}
return options;
}
startRecording() {
// only used if supportsMediaRecorder() returned true
// eslint-disable-next-line compat/compat
this.recorder = new MediaRecorder(this.stream, this.getMediaRecorderOptions());
this.recordingChunks = [];
let stoppingBecauseOfMaxSize = false;
this.recorder.addEventListener('dataavailable', event => {
this.recordingChunks.push(event.data);
const {
restrictions
} = this.uppy.opts;
if (this.recordingChunks.length > 1 && restrictions.maxFileSize != null && !stoppingBecauseOfMaxSize) {
const totalSize = this.recordingChunks.reduce((acc, chunk) => acc + chunk.size, 0);
// Exclude the initial chunk from the average size calculation because it is likely to be a very small outlier
const averageChunkSize = (totalSize - this.recordingChunks[0].size) / (this.recordingChunks.length - 1);
const expectedEndChunkSize = averageChunkSize * 3;
const maxSize = Math.max(0, restrictions.maxFileSize - expectedEndChunkSize);
if (totalSize > maxSize) {
stoppingBecauseOfMaxSize = true;
this.uppy.info(this.i18n('recordingStoppedMaxSize'), 'warning', 4000);
this.stopRecording();
}
}
});
// use a "time slice" of 500ms: ondataavailable will be called each 500ms
// smaller time slices mean we can more accurately check the max file size restriction
this.recorder.start(500);
if (this.opts.showRecordingLength) {
// Start the recordingLengthTimer if we are showing the recording length.
this.recordingLengthTimer = setInterval(() => {
const currentRecordingLength = this.getPluginState().recordingLengthSeconds;
this.setPluginState({
recordingLengthSeconds: currentRecordingLength + 1
});
}, 1000);
}
this.setPluginState({
isRecording: true
});
}
stopRecording() {
const stopped = new Promise(resolve => {
this.recorder.addEventListener('stop', () => {
resolve();
});
this.recorder.stop();
if (this.opts.showRecordingLength) {
// Stop the recordingLengthTimer if we are showing the recording length.
clearInterval(this.recordingLengthTimer);
this.setPluginState({
recordingLengthSeconds: 0
});
}
});
return stopped.then(() => {
this.setPluginState({
isRecording: false
});
return this.getVideo();
}).then(file => {
try {
this.capturedMediaFile = file;
// create object url for capture result preview
this.setPluginState({
// eslint-disable-next-line compat/compat
recordedVideo: URL.createObjectURL(file.data)
});
_classPrivateFieldLooseBase(this, _enableMirror)[_enableMirror] = false;
} catch (err) {
// Logging the error, exept restrictions, which is handled in Core
if (!err.isRestriction) {
this.uppy.log(err);
}
}
}).then(() => {
this.recordingChunks = null;
this.recorder = null;
}, error => {
this.recordingChunks = null;
this.recorder = null;
throw error;
});
}
discardRecordedVideo() {
this.setPluginState({
recordedVideo: null
});
if (this.opts.mirror) {
_classPrivateFieldLooseBase(this, _enableMirror)[_enableMirror] = true;
}
this.capturedMediaFile = null;
}
submit() {
try {
if (this.capturedMediaFile) {
this.uppy.addFile(this.capturedMediaFile);
}
} catch (err) {
// Logging the error, exept restrictions, which is handled in Core
if (!err.isRestriction) {
this.uppy.log(err, 'error');
}
}
}
async stop() {
if (this.stream) {
const audioTracks = this.stream.getAudioTracks();
const videoTracks = this.stream.getVideoTracks();
audioTracks.concat(videoTracks).forEach(track => track.stop());
}
if (this.recorder) {
await new Promise(resolve => {
this.recorder.addEventListener('stop', resolve, {
once: true
});
this.recorder.stop();
if (this.opts.showRecordingLength) {
clearInterval(this.recordingLengthTimer);
}
});
}
this.recordingChunks = null;
this.recorder = null;
this.webcamActive = false;
this.stream = null;
this.setPluginState({
recordedVideo: null,
isRecording: false,
recordingLengthSeconds: 0
});
}
getVideoElement() {
return this.el.querySelector('.uppy-Webcam-video');
}
oneTwoThreeSmile() {
return new Promise((resolve, reject) => {
let count = this.opts.countdown;
// eslint-disable-next-line consistent-return
const countDown = setInterval(() => {
if (!this.webcamActive) {
clearInterval(countDown);
this.captureInProgress = false;
return reject(new Error('Webcam is not active'));
}
if (count) {
this.uppy.info(`${count}...`, 'warning', 800);
count--;
} else {
clearInterval(countDown);
this.uppy.info(this.i18n('smile'), 'success', 1500);
setTimeout(() => resolve(), 1500);
}
}, 1000);
});
}
takeSnapshot() {
if (this.captureInProgress) return;
this.captureInProgress = true;
this.opts.onBeforeSnapshot().catch(err => {
const message = typeof err === 'object' ? err.message : err;
this.uppy.info(message, 'error', 5000);
return Promise.reject(new Error(`onBeforeSnapshot: ${message}`));
}).then(() => {
return this.getImage();
}).then(tagFile => {
this.captureInProgress = false;
try {
this.uppy.addFile(tagFile);
} catch (err) {
// Logging the error, except restrictions, which is handled in Core
if (!err.isRestriction) {
this.uppy.log(err);
}
}
}, error => {
this.captureInProgress = false;
throw error;
});
}
getImage() {
const video = this.getVideoElement();
if (!video) {
return Promise.reject(new Error('No video element found, likely due to the Webcam tab being closed.'));
}
const width = video.videoWidth;
const height = video.videoHeight;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0);
const {
restrictions
} = this.uppy.opts;
let preferredImageMimeTypes = [];
if (this.opts.preferredImageMimeType) {
preferredImageMimeTypes = [this.opts.preferredImageMimeType];
} else if (restrictions.allowedFileTypes) {
preferredImageMimeTypes = restrictions.allowedFileTypes.map(toMimeType).filter(isImageMimeType);
}
const mimeType = preferredImageMimeTypes[0] || 'image/jpeg';
const ext = getFileTypeExtension(mimeType) || 'jpg';
const name = `cam-${Date.now()}.${ext}`;
return canvasToBlob(canvas, mimeType).then(blob => {
return {
source: this.id,
name,
data: new Blob([blob], {
type: mimeType
}),
type: mimeType
};
});
}
getVideo() {
// Sometimes in iOS Safari, Blobs (especially the first Blob in the recordingChunks Array)
// have empty 'type' attributes (e.g. '') so we need to find a Blob that has a defined 'type'
// attribute in order to determine the correct MIME type.
const mimeType = this.recordingChunks.find(blob => {
var _blob$type;
return ((_blob$type = blob.type) == null ? void 0 : _blob$type.length) > 0;
}).type;
const fileExtension = getFileTypeExtension(mimeType);
if (!fileExtension) {
return Promise.reject(new Error(`Could not retrieve recording: Unsupported media type "${mimeType}"`));
}
const name = `webcam-${Date.now()}.${fileExtension}`;
const blob = new Blob(this.recordingChunks, {
type: mimeType
});
const file = {
source: this.id,
name,
data: new Blob([blob], {
type: mimeType
}),
type: mimeType
};
return Promise.resolve(file);
}
focus() {
if (!this.opts.countdown) return;
setTimeout(() => {
this.uppy.info(this.i18n('smile'), 'success', 1500);
}, 1000);
}
changeVideoSource(deviceId) {
this.stop();
this.start({
deviceId
});
}
updateVideoSources() {
this.mediaDevices.enumerateDevices().then(devices => {
this.setPluginState({
videoSources: devices.filter(device => device.kind === 'videoinput')
});
});
}
render() {
if (!this.webcamActive) {
this.start();
}
const webcamState = this.getPluginState();
if (!webcamState.cameraReady || !webcamState.hasCamera) {
return h(PermissionsScreen, {
icon: CameraIcon,
i18n: this.i18n,
hasCamera: webcamState.hasCamera
});
}
return h(CameraScreen
// eslint-disable-next-line react/jsx-props-no-spreading
, _extends({}, webcamState, {
onChangeVideoSource: this.changeVideoSource,
onSnapshot: this.takeSnapshot,
onStartRecording: this.startRecording,
onStopRecording: this.stopRecording,
onDiscardRecordedVideo: this.discardRecordedVideo,
onSubmit: this.submit,
onFocus: this.focus,
onStop: this.stop,
i18n: this.i18n,
modes: this.opts.modes,
showRecordingLength: this.opts.showRecordingLength,
showVideoSourceDropdown: this.opts.showVideoSourceDropdown,
supportsRecording: supportsMediaRecorder(),
recording: webcamState.isRecording,
mirror: _classPrivateFieldLooseBase(this, _enableMirror)[_enableMirror],
src: this.stream
}));
}
install() {
const {
mobileNativeCamera,
modes,
videoConstraints
} = this.opts;
const {
target
} = this.opts;
if (mobileNativeCamera && target) {
var _this$getTargetPlugin;
(_this$getTargetPlugin = this.getTargetPlugin(target)) == null || _this$getTargetPlugin.setOptions({
showNativeVideoCameraButton: isModeAvailable(modes, 'video-only') || isModeAvailable(modes, 'video-audio'),
showNativePhotoCameraButton: isModeAvailable(modes, 'picture'),
nativeCameraFacingMode: videoConstraints == null ? void 0 : videoConstraints.facingMode
});
return;
}
this.setPluginState({
cameraReady: false,
recordingLengthSeconds: 0
});
if (target) {
this.mount(target, this);
}
if (this.mediaDevices) {
this.updateVideoSources();
this.mediaDevices.ondevicechange = () => {
this.updateVideoSources();
if (this.stream) {
let restartStream = true;
const {
videoSources,
currentDeviceId
} = this.getPluginState();
videoSources.forEach(videoSource => {
if (currentDeviceId === videoSource.deviceId) {
restartStream = false;
}
});
if (restartStream) {
this.stop();
this.start();
}
}
};
}
}
uninstall() {
this.stop();
this.unmount();
}
onUnmount() {
this.stop();
}
}
Webcam.VERSION = packageJson.version;