There are three main APIs needed to build a camera component
To build a camera component let’s first understand the needed Browser APIs.
-
Gain camera access with the MediaDevices API.
-
Play a
MediaStream
with avideo
element.
Take photos as blobs or base64 with the canvas
element.
Let’s build a custom camera element so you don’t have to worry about hooking this code up ever again.
Custom Elements make components reusable across frameworks
This tutorial is not framework specifc. Leaf node components should be reusable. Custom Elements are a new(ish) browser standard that allows you to build reusable elements that are portable in most JavaScript frameworks. If you’re not familiar with Custom Elements, it’s okay. They’re not too hard to use up front. It can get complex in advanced situations, but we’ll steer clear of those paths. Here’s a simple example:
class HelloElement extends HTMLElement {
constructor() {
// calling the construtor is not required.
// but if you do, make sure to call super()
super();
}
// this is called when the element is connected to the DOM
connectedCallback() {
// attach a shadow root so nobody can mess with your styles
const shadow = this.attachShadow({mode: 'open' });
shadow.textContent = 'Hello world!';
}
}
// define the tag name, it must have a dash
customElements.define('hello-element', HelloElement);
<hello-element></hello-element>
That’s the general idea. Like I said, it gets more complicated, but in the case of the camera component we can keep things simple.
A camera needs video element and a hidden canvas
Let’s start with the simple caxmera component.
class SimpleCamera extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({mode: 'open' });
this.videoElement = document.createElement('video');
this.canvasElement = document.createElemnt('canvas');
this.videoElement.setAttribute('playsinline', true);
this.canvasElement.style.display = 'none';
shadow.appendChild(this.videoElement);
shadow.appendChild(this.canvasElement);
}
}
customElements.define('simple-camera', SimpleCamera);
This component simply adds two elements: a video
and a hidden canvas
element. The playsinline
attribute helps prevent janky video. These elements set the stage for streaming video and taking photos.
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">>
<title>Simple Camera Component</title>
<script src="camera.js"></script>
</head>
<body>
<simple-camera></simple-camera>
</body>
</html>
This HTML document imports the component from the camera.js
file and creates an element for the camera. Let’s start streaming some video.
Access a camera through the MediaDevices API (with permission)
Use the navigator.mediaDevices.getUserMedia()
method to permissibly gain access to a user’s camera.
navigator.mediaDevices.getUserMedia(constraints)
.then((mediaStream) => {
});
Notice that getUserMedia()
returns a Promise
. The Promise
resolves a MediaStream
if successful. This stream is used on a video
element. If the Promise
rejects, you know the user has not granted permission. However! The Promise
may never resolve or reject. The user can decide to never take action on the permission popup. Isn’t that fun?
Browser support for MediaDevices is strong, but strange
The MediaDevices
API is strongly supported. It’s available in all modern browsers. However, there’s no support in Internet Explorer, so you’ll need a feature check.
if (navigator.mediaDevices.getUserMedia === undefined) {
navigator.mediaDevices.getUserMedia(constraints)
.then((mediaStream) => {
});
}
However, some browser versions have partial support for MediaDevices
and some have vendor specific implementations. The MDN article has a great section on setting the polyfills. Fortunately these polyfills should be applied outside of our element, so we won’t need to account for this in our element.
Set audio and video constraints for the media stream
The getUserMedia()
method takes in a set of contraints
. These contraints help configure the stream after the user accepts permission. They have the type of MediaStreamConstraints
. You can specify two main properties: audio
and video
.
navigator.mediaDevices.getUserMedia({ audio: false, video: { facingMode: 'user' }})
.then((mediaStream) => {
});
The audio
property is a simple boolean. You request the user’s audio or you don’t. The video
property is much more complex. The video
constraints, or also known as the MediaTrackConstraints
, specify everything you could possibly need for a video stream: echoCancellation
, latency
, sampleRate
, sampleSize
, volume
, noiseSuppression
, frameRate
, aspectRatio
, facingMode
, and of course height
and width
.
These are a lot of contraints. However, unless you’re building one heck of a camera app you’ll only need a few. Namely, height
, width
, and facingMode
.
Assign the MediaStream to the Video element
Now that the MediaStream
is configured, you can assign it to a video
element.
open(constraints) {
return navigator.mediaDevices.getUserMedia(constraints)
.then((mediaStream) => {
// Assign the MediaStream!
this.videoElement.srcObject = mediaStream;
// Play the stream when loaded!
this.videoElement.onloadedmetadata = (e) => {
this.videoElement.play();
};
});
}
The video element has a srcObject
. It streams from the device’s camera when assigned a MediaStream
. This snippet above added a open
method on the element. Custom Elements have callable methods. If a user calls this open
method it will start the video stream.
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Simple Camera Component</title>
<script src="camera.js"></script>
</head>
<body>
<simple-camera></simple-camera>
<script>
(async function() {
const camera = document.querySelector('simple-camera');
await camera.open({ video: { facingMode: 'user' }})
}())
</script>
</body>
</html>
Now that we can stream video, let’s take photos.
Use the Canvas to take photos as Blobs
The canvas
element has the ability to draw a frame from a video element. Using this functionality you can draw on an invisible canvas and then export the image as a blob.
_drawImage() {
const imageWidth = this.videoElement.videoWidth;
const imageHeight = this.videoElement.videoHeight;
const context = this.canvasElement.getContext('2d');
this.canvasElement.width = imageWidth;
this.canvasElement.height = imageHeight;
context.drawImage(this.videoElement, 0, 0, imageWidth, imageHeight);
return { imageHeight, imageWidth };
}
This private _drawImage()
method sets the height and width of the invisible canvas to the video’s height. Then it uses the drawImage()
method on the context
. The video element, x position, y position, width, and height are supplied. This creates a drawing on the invisible canvas and sets us up to create a blob.
takeBlobPhoto() {
const { imageHeight, imageWidth } = this._drawImage();
return new Promise((resolve, reject) => {
this.canvasElement.toBlob((blob) => {
resolve({ blob, imageHeight, imageWidth });
});
});
}
The canvas
element has a toBlob()
method. Since it is async, you can turn it into a Promise
so it’s easier to consume.
Now you can start to control this camera:
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Simple Camera Component</title>
<script src="camera.js"></script>
</head>
<body>
<simple-camera></simple-camera>
<button id="btnPhoto"></button>
<script>
(async function() {
const camera = document.querySelector('simple-camera');
const btnPhoto = document.querySelector('#btnPhoto');
await camera.open({ video: { facingMode: 'user' }})
btnPhoto.addEventListener('click', async event => {
const photo = await camera.takeBlobPhoto();
});
}())
</script>
</body>
</html>
Blobs are great when you need to upload a file. But sometimes it’s nice to just stick a base64 encoded string into an image tag. The canvas
element has a solution just for this.
Use the Canvas to take photos as base64
The canvas
element has a toDataURL()
method. This method takes the current contents of the canvas and spits it out to a base64 encoded image.
takeBase64Photo({ type, quality } = { type: 'png', quality: 1 }) {
const { imageHeight, imageWidth } = this._drawImage();
const base64 = this.canvasElement.toDataURL('image/' + type, quality);
return { base64, imageHeight, imageWidth };
}
The takeBase64()
method calls the toDataUrl()
method and returns it’s base64 value. Notice that you can specify image type and the quality of the image.
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Simple Camera Component</title>
<script src="camera.js"></script>
</head>
<body>
<simple-camera></simple-camera>
<button id="btnBlobPhoto">Take Blob</button>
<button id="btnBase64Photo">Take Base64</button>
<script>
(async function() {
const camera = document.querySelector('simple-camera');
const btnBlobPhoto = document.querySelector('#btnBlobPhoto');
const btnBase64Photo = document.querySelector('#btnBase64Photo');
await camera.open({ video: { facingMode: 'user' }})
btnBlobPhoto.addEventListener('click', async event => {
const photo = await camera.takeBlobPhoto();
});
btnBase64Photo.addEventListener('click', async event => {
const photo = camera.takeBase64Photo({ type: 'jpeg', quality: 0.8 });
});
}())
</script>
</body>
</html>
Port to your favorite framework
Modern JavaScript frameworks have the ability to use custom elements. This makes custom elements an atrractive choice for building common components. You can easily port this component if your company manages multiple apps that use multiple frameworks. The Custom Elements Everywhere shows how compatible each framework is with custom elements.
Outline
- There are three main APIs needed to build a camera component
- Custom Elements make components reusable across frameworks
- A camera needs video element and a hidden canvas
- Access a camera through the MediaDevices API (with permission)
- Browser support for MediaDevices is strong, but strange
- Set audio and video constraints for the media stream
- Assign the MediaStream to the Video element
- Use the Canvas to take photos as Blobs
- Use the Canvas to take photos as base64
- Port to your favorite framework