blob: bf0c7d5479c1ea49dc8f1b90d8de2d072edbc57d [file] [log] [blame]
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as barcodeChip from '../../barcode_chip.js';
import * as dom from '../../dom.js';
import {BarcodeScanner} from '../../models/barcode.js';
import {DeviceOperator, parseMetadata} from '../../mojo/device_operator.js';
import * as nav from '../../nav.js';
import * as state from '../../state.js';
import * as util from '../../util.js';
import {Mode} from '../../type.js';
/**
* Creates a controller for the video preview of Camera view.
*/
export class Preview {
/**
* @param {function()} onNewStreamNeeded Callback to request new stream.
*/
constructor(onNewStreamNeeded) {
/**
* @type {function()}
* @private
*/
this.onNewStreamNeeded_ = onNewStreamNeeded;
/**
* Video element to capture the stream.
* @type {!HTMLVideoElement}
* @private
*/
this.video_ = dom.get('#preview-video', HTMLVideoElement);
/**
* Element that shows the preview metadata.
* @type {!HTMLElement}
* @private
*/
this.metadata_ = dom.get('#preview-metadata', HTMLElement);
/**
* The observer id for preview metadata.
* @type {?number}
* @private
*/
this.metadataObserverId_ = null;
/**
* Current active stream.
* @type {?MediaStream}
* @private
*/
this.stream_ = null;
/**
* Watchdog for stream-end.
* @type {?number}
* @private
*/
this.watchdog_ = null;
/**
* Promise for the current applying focus.
* @type {?Promise}
* @private
*/
this.focus_ = null;
/**
* @type {?BarcodeScanner}
* @private
*/
this.scanner_ = null;
window.addEventListener('resize', () => this.onWindowResize_());
[state.State.EXPERT, state.State.SHOW_METADATA].forEach((s) => {
state.addObserver(s, this.updateShowMetadata_.bind(this));
});
[state.State.EXPERT, state.State.SCAN_BARCODE].forEach((s) => {
state.addObserver(s, this.updateScanBarcode_.bind(this));
});
}
/**
* Current active stream.
* @return {?MediaStream}
*/
get stream() {
return this.stream_;
}
/**
* @return {!HTMLVideoElement}
*/
get video() {
return this.video_;
}
/**
* @override
*/
toString() {
const {videoWidth, videoHeight} = this.video_;
return videoHeight ? `${videoWidth} x ${videoHeight}` : '';
}
/**
* Sets video element's source.
* @param {!MediaStream} stream Stream to be the source.
* @return {!Promise} Promise for the operation.
*/
async setSource_(stream) {
const tpl = util.instantiateTemplate('#preview-video-template');
const video = dom.getFrom(tpl, 'video', HTMLVideoElement);
await new Promise((resolve) => {
const handler = () => {
video.removeEventListener('canplay', handler);
resolve();
};
video.addEventListener('canplay', handler);
video.srcObject = stream;
});
await video.play();
this.video_.parentElement.replaceChild(tpl, this.video_);
this.video_.removeAttribute('srcObject');
this.video_.load();
this.video_ = video;
video.addEventListener('resize', () => this.onIntrinsicSizeChanged_());
video.addEventListener('click', (event) => this.onFocusClicked_(event));
return this.onIntrinsicSizeChanged_();
}
/**
* Opens preview stream.
* @param {!MediaStreamConstraints} constraints Constraints of preview stream.
* @return {!Promise<!MediaStream>} Promise resolved to opened preview stream.
*/
async open(constraints) {
this.stream_ = await navigator.mediaDevices.getUserMedia(constraints);
try {
await this.setSource_(this.stream_);
// Use a watchdog since the stream.onended event is unreliable in the
// recent version of Chrome. As of 55, the event is still broken.
this.watchdog_ = setInterval(() => {
// Check if video stream is ended (audio stream may still be live).
if (this.stream_.getVideoTracks().length === 0 ||
this.stream_.getVideoTracks()[0].readyState === 'ended') {
clearInterval(this.watchdog_);
this.watchdog_ = null;
this.stream_ = null;
this.onNewStreamNeeded_();
}
}, 100);
this.scanner_ = new BarcodeScanner(this.video_, (value) => {
barcodeChip.show(value);
});
this.updateScanBarcode_();
this.updateShowMetadata_();
state.set(state.State.STREAMING, true);
} catch (e) {
await this.close();
throw e;
}
return this.stream_;
}
/**
* Closes the preview.
* @return {!Promise}
*/
async close() {
if (this.watchdog_ !== null) {
clearInterval(this.watchdog_);
this.watchdog_ = null;
}
// Pause video element to avoid black frames during transition.
this.video_.pause();
if (this.stream_ !== null) {
const track = this.stream_.getVideoTracks()[0];
const {deviceId} = track.getSettings();
track.stop();
const deviceOperator = await DeviceOperator.getInstance();
if (deviceOperator !== null) {
await deviceOperator.waitForDeviceClose(deviceId);
}
this.stream_ = null;
}
if (this.scanner_ !== null) {
this.scanner_.stop();
this.scanner_ = null;
}
state.set(state.State.STREAMING, false);
}
/**
* Checks whether to scan barcode on preview or not.
* @private
*/
updateScanBarcode_() {
if (this.scanner_ === null) {
return;
}
if (state.get(Mode.PHOTO) && state.get(state.State.SCAN_BARCODE)) {
this.scanner_.start();
} else {
this.scanner_.stop();
}
}
/**
* Checks preview whether to show preview metadata or not.
* @private
*/
updateShowMetadata_() {
if (state.get(state.State.EXPERT) && state.get(state.State.SHOW_METADATA)) {
this.enableShowMetadata_();
} else {
this.disableShowMetadata_();
}
}
/**
* Creates an image blob of the current frame.
* @return {!Promise<!Blob>} Promise for the result.
*/
toImage() {
const {canvas, ctx} = util.newDrawingCanvas(
{width: this.video_.videoWidth, height: this.video_.videoHeight});
ctx.drawImage(this.video_, 0, 0);
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Photo blob error.'));
}
}, 'image/jpeg');
});
}
/**
* Displays preview metadata on preview screen.
* @return {!Promise} Promise for the operation.
* @private
*/
async enableShowMetadata_() {
if (!this.stream_) {
return;
}
document.querySelectorAll('.metadata-value').forEach((element) => {
element.style.display = 'none';
});
const displayCategory = (selector, enabled) => {
document.querySelector(selector).classList.toggle('mode-on', enabled);
};
const showValue = (selector, val) => {
const element = document.querySelector(selector);
element.style.display = '';
element.textContent = val;
};
/**
* @param {!Object<string, number>} obj
* @param {string} prefix
* @return {!Map<number, string>}
*/
const buildInverseMap = (obj, prefix) => {
const map = new Map();
for (const [key, val] of Object.entries(obj)) {
if (!key.startsWith(prefix)) {
continue;
}
if (map.has(val)) {
console.error(`Duplicated value: ${val}`);
continue;
}
map.set(val, key.slice(prefix.length));
}
return map;
};
const afStateName = buildInverseMap(
cros.mojom.AndroidControlAfState, 'ANDROID_CONTROL_AF_STATE_');
const aeStateName = buildInverseMap(
cros.mojom.AndroidControlAeState, 'ANDROID_CONTROL_AE_STATE_');
const awbStateName = buildInverseMap(
cros.mojom.AndroidControlAwbState, 'ANDROID_CONTROL_AWB_STATE_');
const aeAntibandingModeName = buildInverseMap(
cros.mojom.AndroidControlAeAntibandingMode,
'ANDROID_CONTROL_AE_ANTIBANDING_MODE_');
const tag = cros.mojom.CameraMetadataTag;
const metadataEntryHandlers = {
[tag.ANDROID_LENS_FOCUS_DISTANCE]: ([value]) => {
if (value === 0) {
// Fixed-focus camera
return;
}
const focusDistance = (100 / value).toFixed(1);
showValue('#preview-focus-distance', `${focusDistance} cm`);
},
[tag.ANDROID_CONTROL_AF_STATE]: ([value]) => {
showValue('#preview-af-state', afStateName.get(value));
},
[tag.ANDROID_SENSOR_SENSITIVITY]: ([value]) => {
const sensitivity = value;
showValue('#preview-sensitivity', `ISO ${sensitivity}`);
},
[tag.ANDROID_SENSOR_EXPOSURE_TIME]: ([value]) => {
const shutterSpeed = Math.round(1e9 / value);
showValue('#preview-exposure-time', `1/${shutterSpeed}`);
},
[tag.ANDROID_SENSOR_FRAME_DURATION]: ([value]) => {
const frameFrequency = Math.round(1e9 / value);
showValue('#preview-frame-duration', `${frameFrequency} Hz`);
},
[tag.ANDROID_CONTROL_AE_ANTIBANDING_MODE]: ([value]) => {
showValue(
'#preview-ae-antibanding-mode', aeAntibandingModeName.get(value));
},
[tag.ANDROID_CONTROL_AE_STATE]: ([value]) => {
showValue('#preview-ae-state', aeStateName.get(value));
},
[tag.ANDROID_COLOR_CORRECTION_GAINS]: ([valueRed, , , valueBlue]) => {
const wbGainRed = valueRed.toFixed(2);
showValue('#preview-wb-gain-red', `${wbGainRed}x`);
const wbGainBlue = valueBlue.toFixed(2);
showValue('#preview-wb-gain-blue', `${wbGainBlue}x`);
},
[tag.ANDROID_CONTROL_AWB_STATE]: ([value]) => {
showValue('#preview-awb-state', awbStateName.get(value));
},
[tag.ANDROID_CONTROL_AF_MODE]: ([value]) => {
displayCategory(
'#preview-af',
value !==
cros.mojom.AndroidControlAfMode.ANDROID_CONTROL_AF_MODE_OFF);
},
[tag.ANDROID_CONTROL_AE_MODE]: ([value]) => {
displayCategory(
'#preview-ae',
value !==
cros.mojom.AndroidControlAeMode.ANDROID_CONTROL_AE_MODE_OFF);
},
[tag.ANDROID_CONTROL_AWB_MODE]: ([value]) => {
displayCategory(
'#preview-awb',
value !==
cros.mojom.AndroidControlAwbMode.ANDROID_CONTROL_AWB_MODE_OFF);
},
};
// These should be per session static information and we don't need to
// recalculate them in every callback.
const {videoWidth, videoHeight} = this.video_;
const resolution = `${videoWidth}x${videoHeight}`;
const videoTrack = this.stream_.getVideoTracks()[0];
const deviceName = videoTrack.label;
// Currently there is no easy way to calculate the fps of a video element.
// Here we use the metadata events to calculate a reasonable approximation.
const updateFps = (() => {
const FPS_MEASURE_FRAMES = 100;
const timestamps = [];
return () => {
const now = performance.now();
timestamps.push(now);
if (timestamps.length > FPS_MEASURE_FRAMES) {
timestamps.shift();
}
if (timestamps.length === 1) {
return null;
}
return (timestamps.length - 1) / (now - timestamps[0]) * 1000;
};
})();
const callback = (metadata) => {
showValue('#preview-resolution', resolution);
showValue('#preview-device-name', deviceName);
const fps = updateFps();
if (fps !== null) {
showValue('#preview-fps', `${fps.toFixed(0)} FPS`);
}
for (const entry of metadata.entries) {
if (entry.count === 0) {
continue;
}
const handler = metadataEntryHandlers[entry.tag];
if (handler === undefined) {
continue;
}
handler(parseMetadata(entry));
}
};
const deviceOperator = await DeviceOperator.getInstance();
if (!deviceOperator) {
return;
}
const deviceId = videoTrack.getSettings().deviceId;
this.metadataObserverId_ = await deviceOperator.addMetadataObserver(
deviceId, callback, cros.mojom.StreamType.PREVIEW_OUTPUT);
}
/**
* Hide display preview metadata on preview screen.
* @return {!Promise} Promise for the operation.
* @private
*/
async disableShowMetadata_() {
if (!this.stream_ || this.metadataObserverId_ === null) {
return;
}
const deviceOperator = await DeviceOperator.getInstance();
if (!deviceOperator) {
return;
}
const deviceId = this.stream_.getVideoTracks()[0].getSettings().deviceId;
const isSuccess = await deviceOperator.removeMetadataObserver(
deviceId, this.metadataObserverId_);
if (!isSuccess) {
console.error(`Failed to remove metadata observer with id: ${
this.metadataObserverId_}`);
}
this.metadataObserverId_ = null;
}
/**
* Handles resizing the window for preview's aspect ratio changes.
* @private
*/
onWindowResize_() {
nav.onWindowResized();
}
/**
* Handles changed intrinsic size (first loaded or orientation changes).
* @return {!Promise}
* @private
*/
async onIntrinsicSizeChanged_() {
if (this.video_.videoWidth && this.video_.videoHeight) {
this.onWindowResize_();
}
this.cancelFocus_();
}
/**
* Handles clicking for focus.
* @param {!Event} event Click event.
* @private
*/
onFocusClicked_(event) {
this.cancelFocus_();
// Normalize to square space coordinates by W3C spec.
const x = event.offsetX / this.video_.offsetWidth;
const y = event.offsetY / this.video_.offsetHeight;
const constraints = {advanced: [{pointsOfInterest: [{x, y}]}]};
const track = this.video_.srcObject.getVideoTracks()[0];
const focus =
track.applyConstraints(constraints)
.then(() => {
if (focus !== this.focus_) {
return; // Focus was cancelled.
}
const aim = dom.get('#preview-focus-aim', HTMLObjectElement);
const clone = aim.cloneNode(true);
clone.style.left = `${event.offsetX + this.video_.offsetLeft}px`;
clone.style.top = `${event.offsetY + this.video_.offsetTop}px`;
clone.hidden = false;
aim.parentElement.replaceChild(clone, aim);
})
.catch(console.error);
this.focus_ = focus;
}
/**
* Cancels the current applying focus.
* @private
*/
cancelFocus_() {
this.focus_ = null;
const aim = dom.get('#preview-focus-aim', HTMLObjectElement);
aim.hidden = true;
}
}