@uppy/webcam
Version:
Uppy plugin that takes photos or records videos using the device's camera.
561 lines (560 loc) • 22.4 kB
JavaScript
import { jsx as _jsx } from "preact/jsx-runtime";
import { UIPlugin } from '@uppy/core';
import { canvasToBlob, getFileTypeExtension, mimeTypes } from '@uppy/utils';
import { isMobile } from 'is-mobile';
// biome-ignore lint/style/useImportType: h is not a type
import { h } from 'preact';
import packageJson from '../package.json' with { type: 'json' };
import CameraIcon from './CameraIcon.js';
import CameraScreen from './CameraScreen.js';
import locale from './locale.js';
import PermissionsScreen from './PermissionsScreen.js';
import supportsMediaRecorder from './supportsMediaRecorder.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
return navigator.mediaDevices;
}
function isModeAvailable(modes, mode) {
return modes.includes(mode);
}
// set default options
export 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
*/
export default class Webcam extends UIPlugin {
static VERSION = packageJson.version;
// enableMirror is used to toggle mirroring, for instance when discarding the video,
// while `opts.mirror` is used to remember the initial user setting
#enableMirror;
mediaDevices;
supportsUserMedia;
protocol;
capturedMediaFile;
icon;
webcamActive;
stream = null;
recorder = null;
recordingChunks = null;
recordingLengthTimer;
captureInProgress = false;
constructor(uppy, opts) {
super(uppy, { ...defaultOptions, ...opts });
this.mediaDevices = getMediaDevices();
this.supportsUserMedia = !!this.mediaDevices;
this.protocol = location.protocol.match(/https/i) ? 'https' : 'http';
this.id = this.opts.id || 'Webcam';
this.type = 'acquirer';
this.capturedMediaFile = null;
this.icon = () => (_jsx("svg", { "aria-hidden": "true", focusable: "false", width: "32", height: "32", viewBox: "0 0 32 32", children: _jsx("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');
this.#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.discardRecordedMedia = this.discardRecordedMedia.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,
capturedSnapshot: null,
});
}
getStatus() {
const { recordedVideo, capturedSnapshot, isRecording, cameraReady, cameraError, } = this.getPluginState();
if (isRecording)
return 'recording';
if (recordedVideo != null || capturedSnapshot != null)
return 'captured';
if (cameraReady)
return 'ready';
if (cameraError)
return 'error';
return 'init';
}
setOptions(newOpts) {
super.setOptions({
...newOpts,
videoConstraints: {
// May be undefined but ... handles that
...this.opts.videoConstraints,
...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 = 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,
};
}
start(options = null) {
if (!this.supportsUserMedia) {
return Promise.reject(new Error('Webcam access not supported'));
}
this.webcamActive = true;
if (this.opts.mirror) {
this.#enableMirror = true;
}
const constraints = this.getConstraints(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) {
options.mimeType = acceptableMimeTypes[0];
}
}
return options;
}
startRecording() {
// only used if supportsMediaRecorder() returned true
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({
recordedVideo: URL.createObjectURL(file.data),
});
this.#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;
});
}
discardRecordedMedia() {
const { recordedVideo, capturedSnapshot } = this.getPluginState();
if (recordedVideo) {
URL.revokeObjectURL(recordedVideo);
}
if (capturedSnapshot) {
URL.revokeObjectURL(capturedSnapshot);
}
this.setPluginState({
recordedVideo: null,
capturedSnapshot: null,
});
if (this.opts.mirror) {
this.#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,
capturedSnapshot: null,
isRecording: false,
recordingLengthSeconds: 0,
});
}
getVideoElement() {
return this.el.querySelector('.uppy-Webcam-video');
}
oneTwoThreeSmile() {
return new Promise((resolve, reject) => {
let count = this.opts.countdown;
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);
});
}
async takeSnapshot() {
if (this.captureInProgress)
return;
this.captureInProgress = true;
try {
await this.opts.onBeforeSnapshot();
}
catch (err) {
const message = typeof err === 'object' ? err.message : err;
this.uppy.info(message, 'error', 5000);
throw new Error(`onBeforeSnapshot: ${message}`);
}
try {
const file = await this.getImage();
this.capturedMediaFile = file;
if (file.data == null)
throw new Error('File data is empty');
// Create object URL for preview
const capturedSnapshotUrl = URL.createObjectURL(file.data);
this.setPluginState({ capturedSnapshot: capturedSnapshotUrl });
this.captureInProgress = false;
}
catch (error) {
// Logging the error, except restrictions, which is handled in Core
this.captureInProgress = false;
if (!error.isRestriction) {
this.uppy.log(error);
}
}
}
async 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) => 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 (_jsx(PermissionsScreen, { icon: CameraIcon, i18n: this.i18n, hasCamera: webcamState.hasCamera }));
}
return (_jsx(CameraScreen, { ...webcamState, onChangeVideoSource: this.changeVideoSource, onSnapshot: this.takeSnapshot, onStartRecording: this.startRecording, onStopRecording: this.stopRecording, onDiscardRecordedMedia: this.discardRecordedMedia, 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: this.#enableMirror, src: this.stream }));
}
install() {
const { mobileNativeCamera, modes, videoConstraints } = this.opts;
const { target } = this.opts;
if (mobileNativeCamera && target) {
this.getTargetPlugin(target)?.setOptions({
showNativeVideoCameraButton: isModeAvailable(modes, 'video-only') ||
isModeAvailable(modes, 'video-audio'),
showNativePhotoCameraButton: isModeAvailable(modes, 'picture'),
nativeCameraFacingMode: 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();
}
}