blob: e67d8da52be9dbb97e05848b4b642f1194c8d8b7 [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 {assert, assertInstanceof} from '../../chrome_util.js';
import * as dom from '../../dom.js';
import {reportError} from '../../error.js';
import {FaceOverlay} from '../../face.js';
import {DeviceOperator, parseMetadata} from '../../mojo/device_operator.js';
import * as nav from '../../nav.js';
import * as state from '../../state.js';
import {
ErrorLevel,
ErrorType,
Facing,
Resolution,
} from '../../type.js';
import * as util from '../../util.js';
import {windowController} from '../../window_controller.js';
/**
* Creates a controller for the video preview of Camera view.
*/
export class Preview {
/**
* @param {function(): !Promise} onNewStreamNeeded Callback to request new
* stream.
*/
constructor(onNewStreamNeeded) {
/**
* @type {function(): !Promise}
* @private
*/
this.onNewStreamNeeded_ = onNewStreamNeeded;
/**
* Video element to capture the stream.
* @type {!HTMLVideoElement}
* @private
*/
this.video_ = dom.get('#preview-video', HTMLVideoElement);
/**
* The observer id for preview metadata.
* @type {?number}
* @private
*/
this.metadataObserverId_ = null;
/**
* The face overlay for showing faces over preview.
* @type {?FaceOverlay}
* @private
*/
this.faceOverlay_ = 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 {!Facing}
* @private
*/
this.facing_ = Facing.NOT_SET;
/**
* @type {?string}
* @private
*/
this.vidPid_ = null;
window.addEventListener('resize', () => this.onWindowStatusChanged_());
windowController.addListener(() => this.onWindowStatusChanged_());
[state.State.EXPERT, state.State.SHOW_METADATA].forEach((s) => {
state.addObserver(s, this.updateShowMetadata_.bind(this));
});
}
/**
* @return {!HTMLVideoElement}
*/
get video() {
return this.video_;
}
/**
* Current active stream.
* @return {?MediaStream}
*/
get stream() {
return this.stream_;
}
/**
* @return {!MediaStreamTrack}
*/
getVideoTrack_() {
const stream = assertInstanceof(this.stream, MediaStream);
return stream.getVideoTracks()[0];
}
/**
* @return {!Facing}
*/
getFacing() {
return this.facing_;
}
/**
* USB camera vid:pid identifier of the opened stream.
* @return {?string} Identifier formatted as "vid:pid" or null for non-USB
* camera.
*/
getVidPid() {
return this.vidPid_;
}
/**
* @private
*/
async updateFacing_() {
if (!(await DeviceOperator.isSupported())) {
this.facing_ = Facing.NOT_SET;
return;
}
const {facingMode} = this.getVideoTrack_().getSettings();
if (facingMode === undefined) {
this.facing_ = Facing.EXTERNAL;
return;
}
switch (facingMode) {
case 'user':
this.facing_ = Facing.USER;
return;
case 'environment':
this.facing_ = Facing.ENVIRONMENT;
return;
default:
throw new Error('Unknown facing: ' + facingMode);
}
}
/**
* If the preview camera support PTZ controls.
* @return {boolean}
*/
isSupportPTZ() {
const {pan, tilt, zoom} = this.getVideoTrack_().getCapabilities();
return pan !== undefined || tilt !== undefined || zoom !== undefined;
}
/**
* Preview resolution.
* @return {!Resolution}
*/
getResolution() {
const {videoWidth, videoHeight} = this.video_;
return new Resolution(videoWidth, videoHeight);
}
/**
* @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_.srcObject = null;
this.video_ = video;
video.addEventListener('resize', () => this.onIntrinsicSizeChanged_());
video.addEventListener(
'click',
(event) => this.onFocusClicked_(assertInstanceof(event, MouseEvent)));
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);
await this.updateFacing_();
this.updateShowMetadata_();
const deviceOperator = await DeviceOperator.getInstance();
if (deviceOperator !== null) {
const {deviceId} = this.getVideoTrack_().getSettings();
const isSuccess =
await deviceOperator.setCameraFrameRotationEnabledAtSource(
deviceId, false);
if (!isSuccess) {
reportError(
ErrorType.FRAME_ROTATION_NOT_DISABLED, ErrorLevel.WARNING,
new Error(
'Cannot disable camera frame rotation. ' +
'The camera is probably being used by another app.'));
}
this.vidPid_ = await deviceOperator.getVidPid(deviceId);
}
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();
this.disableShowMetadata_();
if (this.stream_ !== null) {
const track = this.getVideoTrack_();
const {deviceId} = track.getSettings();
track.stop();
const deviceOperator = await DeviceOperator.getInstance();
if (deviceOperator !== null) {
deviceOperator.dropConnection(deviceId);
}
this.stream_ = null;
}
state.set(state.State.STREAMING, false);
}
/**
* 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;
}
dom.getAll('.metadata.value', HTMLElement).forEach((element) => {
element.style.display = 'none';
});
const displayCategory = (selector, enabled) => {
dom.get(selector, HTMLElement).classList.toggle('mode-on', enabled);
};
const showValue = (selector, val) => {
const element = dom.get(selector, HTMLElement);
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)) {
reportError(
ErrorType.METADATA_MAPPING_FAILURE, ErrorLevel.ERROR,
new 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.getVideoTrack_();
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 deviceOperator = await DeviceOperator.getInstance();
if (!deviceOperator) {
return;
}
const deviceId = videoTrack.getSettings().deviceId;
const activeArraySize = await deviceOperator.getActiveArraySize(deviceId);
const sensorOrientation =
await deviceOperator.getSensorOrientation(deviceId);
this.faceOverlay_ = new FaceOverlay(activeArraySize, sensorOrientation);
const updateFace = (mode, rects) => {
if (mode ===
cros.mojom.AndroidStatisticsFaceDetectMode
.ANDROID_STATISTICS_FACE_DETECT_MODE_OFF) {
dom.get('#preview-num-faces', HTMLDivElement).style.display = 'none';
return;
}
assert(rects.length % 4 === 0);
const numFaces = rects.length / 4;
const label = numFaces >= 2 ? 'Faces' : 'Face';
showValue('#preview-num-faces', `${numFaces} ${label}`);
this.faceOverlay_.show(rects);
};
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`);
}
let faceMode = cros.mojom.AndroidStatisticsFaceDetectMode
.ANDROID_STATISTICS_FACE_DETECT_MODE_OFF;
let faceRects = [];
const tryParseFaceEntry = (entry) => {
switch (entry.tag) {
case tag.ANDROID_STATISTICS_FACE_DETECT_MODE: {
const data = parseMetadata(entry);
assert(data.length === 1);
faceMode = data;
return true;
}
case tag.ANDROID_STATISTICS_FACE_RECTANGLES: {
faceRects = parseMetadata(entry);
return true;
}
}
return false;
};
for (const entry of metadata.entries) {
if (entry.count === 0) {
continue;
}
if (tryParseFaceEntry(entry)) {
continue;
}
const handler = metadataEntryHandlers[entry.tag];
if (handler === undefined) {
continue;
}
handler(parseMetadata(entry));
}
// We always need to run updateFace() even if face rectangles are obsent
// in the metadata, which may happen if there is no face detected.
updateFace(faceMode, faceRects);
};
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.getVideoTrack_().getSettings();
const isSuccess = await deviceOperator.removeMetadataObserver(
deviceId, this.metadataObserverId_);
if (!isSuccess) {
reportError(
ErrorType.REMOVE_METADATA_OBSERVER_FAILURE, ErrorLevel.ERROR,
new Error(`Failed to remove metadata observer with id: ${
this.metadataObserverId_}`));
}
this.metadataObserverId_ = null;
if (this.faceOverlay_ !== null) {
this.faceOverlay_.clear();
this.faceOverlay_ = null;
}
}
/**
* Handles the the window state or window size changed.
* @private
*/
onWindowStatusChanged_() {
nav.onWindowStatusChanged();
}
/**
* Handles changed intrinsic size (first loaded or orientation changes).
* @return {!Promise}
* @private
*/
async onIntrinsicSizeChanged_() {
if (this.video_.videoWidth && this.video_.videoHeight) {
this.onWindowStatusChanged_();
}
this.cancelFocus_();
}
/**
* Handles clicking for focus.
* @param {!MouseEvent} 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.getVideoTrack_();
const focus = (async () => {
try {
await track.applyConstraints(constraints);
} catch {
// The device might not support setting pointsOfInterest. Ignore the
// error and return.
return;
}
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);
})();
this.focus_ = focus;
}
/**
* Cancels the current applying focus.
* @private
*/
cancelFocus_() {
this.focus_ = null;
const aim = dom.get('#preview-focus-aim', HTMLObjectElement);
aim.hidden = true;
}
}