
- Subscribe to RSS Feed
- Mark as New
- Mark as Read
- Bookmark
- Subscribe
- Printer Friendly Page
- Report Inappropriate Content
Check out other articles in the C3 Series
Solution Overview
How data flows through the Camera parent widget and angular providers
- tcgCameraPicker: Angular Directive that enumerates available cameras and provides selection interface
- tcgCamera: Angular Directive that manages camera stream, video display, and photo capture
- Parent Widget: Coordinates the data and events between the two Angular Providers, the intake UI, and the server upload
<tcg-camera-picker></tcg-camera-picker>
in my widget HTML.Camera Directive Implementation
Camera Directive HTML and visual structure
/**
* Element directive that supports showing a selected Camera
* Usage <tcg-camera id="c.cameraId" photo-requested="c.photoRequested" photo="c.photo"></tcg-camera>
* {String} id - The Device ID of the camera retrieved using navigator.mediaDevices.enumerateDevices()
* {Any} photo-requested - A unique value that when changed will trigger taking a photo
* {String} photo - A Base 64 Data URL representing the photo taken
*/
function() {
// Note the video feed can be updated using constraints such as: video.srcObject.getVideoTracks()[0].applyConstraints({ aspectRatio: 16/9 })
let videoEl;
let canvasEl;
function selectCamera(cameraDeviceId, $scope) {
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
return navigator.mediaDevices.getUserMedia({
video: {
deviceId: cameraDeviceId,
width: 4096,
height: 2160,
}
})
.then(function (stream) {
if (videoEl.srcObject) {
videoEl.srcObject.getTracks().forEach((track) => {
track.stop();
});
}
videoEl.srcObject = stream;
videoEl.play();
})
.catch(function (err) {
$scope.$emit('CAMERA_ERROR', err);
});
}
}
function getPhoto() {
// Get the intrinsic dimensions of the video stream
const vidWidth = videoEl.videoWidth;
const vidHeight = videoEl.videoHeight;
// We will resize the image to a cropped square
// using the shorter dimension of the video stream
const size = Math.min(vidWidth, vidHeight);
// Set canvas to the desired square size
canvasEl.width = size;
canvasEl.height = size;
// Find the x and y coordinates
// that will allow centering
// the cropped region
const sx = (vidWidth - size) / 2;
const sy = (vidHeight - size) / 2;
let ctx = canvasEl.getContext('2d');
ctx.scale(-1,1); // Mirror image to reflect mirrored video
// Select the region of the video stream
// from the top left coordinate (sx, sy)
// extending to the legnth and width = size
// and draw that region to the canvas
// starting at the top left coordinate (0, 0)
// and drawing to the legnth and width = size
ctx.drawImage(videoEl, sx, sy, size, size, 0, 0, size * -1, size);
// Return a data url of the cropped image
return canvasEl.toDataURL('image/jpeg');
}
function controller($scope, $element, $attrs) {
videoEl = $element.find('video')[0];
canvasEl = document.createElement('canvas');
$scope.$watch('id', function(newValue, oldValue) {
selectCamera(newValue, $scope);
});
$scope.$watch('photoRequested', function(newValue, oldValue) {
if (newValue != oldValue) {
$scope.photo = getPhoto();
}
});
}
return {
restrict: 'E',
scope: {
id: '=',
photoRequested: '=',
photo: '='
},
controller: controller,
template: `
<div class="cameraOverlay"></div>
<video playsinline></video>
`
};
}
- restrict: The value 'E' indicates that this directive will create a custom HTML Element that can be used in AngularJS templates like Widget HTML. The name of the directive tcgCamera dictates that the element will be
<tcg-camera>
. - scope: This is the isolated scope of the directive which contains the properties that are passed to the controller. In short, this object defines the HTML attributes on the element and how data is bound from the attribute to the controller.
- controller: This defines the controller function which is very similar to the Client controller script of Service Portal Widgets. This function is executed when the custom element is added to the page, so all the core logic is here.
- Template: This is the HTML template that replaces the custom element tag when it is rendered out to the web page. Think of it as similar to using ng-include.
Scope
id: "="
) to communicate a few different attributes:- id: The selected camera id to display in the video. The parent widget communicates the selected camera to this directive via this attribute.
- photoRequested: The parent widget communicates to the directive that it should take a picture by changing this attribute. Totally stole this from the folks that implemented our Photobooth Next Experience component.
- photo: This attribute contains the Base64 encoding of the most recent image taken. The directive writes this value to communicate the image to the parent widget.
HTML Template
- Video: The video tag, seen on line 96, is a native HTML element that in this component allows us to show the live camera feed for the user to see themselves as they line up the picture. Webcams provide a continuous video stream from which we can capture still frames / images.
- Camera Overlay: Seen on line 95, the camera overlay is a CSS controlled semi-transparent oval overlay that guides the user in lining up their face
Controller Function
<tcg-camera>
element is rendered in a widget. This function sets up the core behavior:- Gives access to the video element so we can manipulate it (line72)
- Creates a hidden canvas element (never added to the DOM) so we can capture and manipulate still images from the camera's video stream
- Set up a watcher to update the video when the
<tcg-camera>
tag's id attribute changes (lines 75-77) - Set up a watcher to capture a still image and update the photo attribute to the Base64 encoded image when the
<tcg-camera>
tag's photoRequested attribute changes (line 79-83)
selectCamera
Behavior flow on initialization or when a user selects a new camera
The selectCamera
function uses navigator.mediaDevices.getUserMedia
to capture a camera device by id and play it in the video tag. It properly handles stream cleanup by stopping all existing tracks before creating a new stream. This prevents resource leaks and ensures only one camera stream is active at a time. Additionally, it emits an error event if it encounters any issues obtaining a camera device.
getPhoto
Behavior flow when a user clicks Take Photo
getPhoto
function uses the hidden canvas element to write a still image from the video tag. It also does a bit of image manipulation including cropping and mirroring which we will discuss separately. Lastly, it returns the Base64 encoding of the image in the canvas.Camera Picker Directive Implementation
Camera Picker directive's HTML and visual structure
function() {
function enumerateCameras() {
if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) {
return navigator.mediaDevices.enumerateDevices()
.then((devices) => {
return devices
.filter((device) => device.kind == 'videoinput')
.map((device) => {
return {id: device.deviceId, text: device.label};
});
})
.catch((err) => {
console.error('Enumerate cameras error', err);
});
}
else {
return Promise.reject('Required mediaDevices API not supported');
}
}
function controller($scope, $element, $attrs) {
navigator.mediaDevices.getUserMedia({
video: {
width: 4096,
height: 2160,
},
}).then(() => {
enumerateCameras().then(function(cameras) {
$scope.cameras = cameras;
$scope.selectedCamera = cameras[0].id;
$scope.$apply();
});
});
}
return {
restrict: 'E',
scope: {
selectedCamera: "="
},
controller: controller,
template: `
<div>
<label for="cameraPickerSelect">Which camera would you like to use:</label>
</div>
<div>
<select name="cameraPickerSelect" id="cameraPickerSelect" ng-model="selectedCamera">
<option ng-repeat="camera in cameras" value="{{camera.id}}">{{camera.text}}</option>
</select>
</div>
`
};
}
Scope
selectedCamera: "="
) to communicate camera selection back to the parent widget, enabling real-time camera switching.HTML Template
$scope.cameras
property and the ng-model
is bound to the selectedCamera
attribute.Controller
navigator.mediaDevices.getUserMedia
call to request access to camera devices. The object passed to the getUserMedia
defines the constraints for the desired device, in this case a high resolution video device (4096x2160). It's important to note that a camera does not have to have that resolution to be included in the results, this request simply ensures that we get the highest resolution the attached cameras can support. A lower value in this constraint object can result in lower resolution streams from the same cameras.enumerateCameras
function is called and the resulting array of camera objects is used to update the select box options via the cameras
property. Additionally, the selectedCamera
attribute is set to the first camera in the list as a default.enumerateCameras
enumerateCameras
function uses the navigator.mediaDevices.enumerateDevices
to populate an array of the available cameras. The enumeration filters specifically for videoinput
devices, excluding audio inputs and other media devices. Each camera is mapped to a simple object with id
and text
properties for clean data binding.Parent Widget Implementation
HTML Template
<div class="c3CameraWrapper">
<p class="notifText">
Please fill the entire oval with your face!
</p>
<div class="c3Camera">
<tcg-camera id="c.selectedCamera" photo-requested="c.photoRequested" photo="c.photo">
</tcg-camera>
<tcg-camera-picker selected-camera="c.selectedCamera">
</tcg-camera-picker>
</div>
</div>
<div class="cctcgActionContainer">
<button class="cctcgButton" ng-click="c.currentStage.previous()">
Previous
</button>
<button class="cctcgButtonPrimary" ng-click="c.currentStage.next()">
Take Photo
</button>
</div>
tcg-camera
element, the tcg-camera-picker
element, and a button that handles the Take Photo logic via c.currentStage.next()
.Client Controller
function takePhoto() {
c.photoRequested = Date.now();
goToStage(c.Stage.REVIEW_PHOTO);
}
// Controls which camera is visible in the video
c.selectedCamera = undefined;
// Changing this value triggers the camera to take a picture
c.photoRequested = undefined;
// When the camera takes a photo, it creates
// a data URL with a Base64 encoding of the image file
// stored in this variable
c.photo = undefined;
takePhoto
function that sets the photoRequested
to the current date and time to trigger a new photo being taken by the camera directive and then navigates to the next UI state.- Clean separation: Each directive has a single responsibility
- Reactive updates: Camera switching happens automatically via scope watching
- Declarative capture: Photo taking uses data binding rather than imperative calls
- Error propagation: Camera errors bubble up to widget state management
Problems and Solutions
Logos, Chests, and other impropriety
- Logos like Superman and Green Lantern appearing on people's chests
- Shirtless and otherwise beach bodied avatars
- Comic book... exaggerations of certain body parts
- Facial recognition errors that resulted in no image at all
.cameraOverlay {
position: absolute;
width: 100%;
height: 100%;
background: radial-gradient(ellipse 50% 60% at 50% 50%,
transparent 60%, rgba(0,0,0,0.40) 50px);
z-index: 1;
}
Front Facing Camera Inversion
video {
transform: scaleX(-1);
}
ctx.scale(-1,1); // Flip the final image back to correct orientation
Camera Access Errors
$scope.$on('CAMERA_ERROR', (evt, err) => {
console.log('Camera error: ', err);
goToStage(c.Stage.CAMERA_ERROR);
$scope.$apply();
});
Cropping the Image Square
video {
max-height: 50vh;
aspect-ratio: 1 / 1;
object-fit: cover;
display: block;
}
getPhoto
function.// Get the intrinsic dimensions of the video stream
const vidWidth = videoEl.videoWidth;
const vidHeight = videoEl.videoHeight;
// We will resize the image to a cropped square
// using the shorter dimension of the video stream
const size = Math.min(vidWidth, vidHeight);
// Set canvas to the desired square size
canvasEl.width = size;
canvasEl.height = size;
// Find the x and y coordinates
// that will allow centering
// the cropped region
const sx = (vidWidth - size) / 2;
const sy = (vidHeight - size) / 2;
let ctx = canvasEl.getContext('2d');
ctx.scale(-1,1); // Mirror image to reflect mirrored video
// Select the region of the video stream
// from the top left coordinate (sx, sy)
// extending to the legnth and width = size
// and draw that region to the canvas
// starting at the top left coordinate (0, 0)
// and drawing to the legnth and width = size
ctx.drawImage(videoEl, sx, sy, size, size, 0, 0, size * -1, size);
Conclusion
- Camera integration is more complex than it appears what works in testing may not work in production
- Mobile device diversity creates challenges that can't be fully anticipated in controlled environments
- User experience psychology (like our CSS overlay) can solve technical problems more effectively than complex algorithms
- Pragmatic solutions (staff assistance, kiosk alternatives) may be more valuable than perfect technical implementations
- Error handling must be workflow-integrated from the beginning, not retrofitted after problems emerge
- 525 Views
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.