From 7bd13b060b43a9d2314feaf5d1ec80eaa3e20f77 Mon Sep 17 00:00:00 2001 From: Kenneth Rohde Christiansen Date: Wed, 9 Oct 2019 14:59:36 +0200 Subject: [PATCH 1/4] Create camera-capture element and an element for logging - These new elements uses material design and are build into build/elements using rollupjs. --- babel.config.js | 5 + elements/camera-capture/camera-capture.js | 320 +++++++++++++++++++++ elements/camera-capture/index.html | 36 +++ elements/camera-capture/settings-pane.js | 291 +++++++++++++++++++ elements/camera-capture/settings-slider.js | 73 +++++ elements/demo-logger/demo-logger.js | 26 ++ elements/index.html | 12 + es-dev-server.config.js | 11 + package.json | 25 +- rollup.config.js | 76 +++++ samples/camera/README.md | 20 ++ samples/camera/index.html | 114 ++------ samples/camera/js/index.js | 93 ------ samples/camera/js/ui.js | 192 ------------- samples/index.html | 2 +- 15 files changed, 919 insertions(+), 377 deletions(-) create mode 100644 babel.config.js create mode 100644 elements/camera-capture/camera-capture.js create mode 100644 elements/camera-capture/index.html create mode 100644 elements/camera-capture/settings-pane.js create mode 100644 elements/camera-capture/settings-slider.js create mode 100644 elements/demo-logger/demo-logger.js create mode 100644 elements/index.html create mode 100644 es-dev-server.config.js create mode 100644 rollup.config.js delete mode 100644 samples/camera/js/index.js delete mode 100644 samples/camera/js/ui.js diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..c2974f6 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,5 @@ +const plugins = [ + '@babel/plugin-proposal-class-properties', +]; + +module.exports = { plugins }; diff --git a/elements/camera-capture/camera-capture.js b/elements/camera-capture/camera-capture.js new file mode 100644 index 0000000..16d4e1d --- /dev/null +++ b/elements/camera-capture/camera-capture.js @@ -0,0 +1,320 @@ +import { html, css, LitElement } from '../../node_modules/lit-element'; + +import '../../node_modules/@material/mwc-icon'; +import '../../node_modules/@material/mwc-ripple'; + +import './settings-pane.js'; + +class CameraCapture extends LitElement { + static styles = css` + :host { + display: block; + width: 100vw; + height: 100vh; + overflow: hidden; + } + + .hidden { + display: none !important; + } + + #mainContent { + margin: 0 auto; + } + + video { + display: inline; + } + + video:hover { + cursor: pointer; + } + + #resetButton { + position: relative; + padding: 15px; + color: white; + text-shadow: -1px 0 black, 0 1px black, 1px 0 black, 0 -1px black; + font-size: 18px; + background: none; + border: none; + z-index: 15; + outline: none; + } + + .canvas-wrapper { + position: relative; + width: 100%; + } + + .settings-wrapper { + position: absolute; + top: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-end; + } + + #cameraBar { + height: 100px; + background-color: transparent; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: space-around; + align-items: center; + } + + #gallery { + width: 48px; + height: 48px; + } + + #takePhotoButton { + height: 72px; + width: 72px; + } + + #takePhotoButton mwc-icon { + --mdc-icon-size: 48px; + } + + .camera-bar-icon { + height: 52px; + width: 52px; + display: flex; + justify-content: center; + outline: none; + border: 2px solid white; + border-radius: 52px; + color: white; + background-color: transparent; + } + `; + + facingMode = "user"; + + _onResetClicked(e) { + const settings = this.shadowRoot.querySelector('settings-pane'); + settings.reset(); + + const resetButton = this.shadowRoot.querySelector('#resetButton'); + resetButton.classList.add('hidden'); + } + + async _onConstraintsChange(e) { + try { + await this.videoTrack.applyConstraints(e.detail.constraints); + + const settings = this.shadowRoot.querySelector('settings-pane'); + settings.applyFromTrack(this.videoTrack); + } catch(err) { + console.error(err); + } + + const resetButton = this.shadowRoot.querySelector('#resetButton'); + resetButton.classList.remove('hidden'); + } + + _onSettingsBackgroundClicked(e) { + const settings = this.shadowRoot.querySelector('settings-pane'); + settings.hide(); + + const resetButton = this.shadowRoot.querySelector('#resetButton'); + resetButton.classList.add('hidden'); + } + + async _onFacingModeClicked() { + this.selectedCamera = (this.selectedCamera + 1) % this.cameras.length; + const camera = this.cameras[this.selectedCamera]; + this.constraints.deviceId = { exact: camera.deviceId }; + this.facingMode = this.getFacingMode(camera); + this.requestUpdate(); + + this.stopCamera(); + + const videoElement = this.shadowRoot.querySelector('video'); + await this.startCamera(videoElement, this.constraints); + + // Timeout needed in Chrome, see https://crbug.com/711524. + const settings = this.shadowRoot.querySelector('settings-pane'); + setTimeout(async () => { + await customElements.whenDefined('settings-pane'); + settings.applyFromTrack(this.videoTrack); + }, 500); + } + + async takePhoto() { + try { + const blob = await this.imageCapturer.takePhoto(); + const img = await createImageBitmap(blob); + + const canvas = this.shadowRoot.querySelector('#gallery'); + canvas.width = getComputedStyle(canvas).width.split('px')[0]; + canvas.height = getComputedStyle(canvas).height.split('px')[0]; + let ratio = Math.max(canvas.width / img.width, canvas.height / img.height); + let x = (canvas.width - img.width * ratio) / 2; + let y = (canvas.height - img.height * ratio) / 2; + canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height); + canvas.getContext('2d').drawImage(img, 0, 0, img.width, img.height, + x, y, img.width * ratio, img.height * ratio); + } catch(err) { + console.error("takePhoto() failed: ", err) + } + } + + startCamera(target, constraints) { + return new Promise(async (resolve, reject) => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: constraints, + audio: false + }); + + this.video = target; + this.stream = stream; + + target.srcObject = stream; + target.addEventListener('canplay', resolve, { once: true }); + target.play(); + } catch(err) { + reject(err); + } + }); + } + + stopCamera() { + if (this.video) { + this.video.pause(); + this.video.srcObject = null; + } + if (this.stream) { + this.stream.getVideoTracks()[0].stop(); + } + } + + getFacingMode(device) { + if (device.facingMode == "environment" + || device.label.indexOf("facing back") >= 0) { + return "environment"; + } + // We assume by default that cameras are user facing + // which is mostly the case for desktop. + return "user"; + } + + async firstUpdated() { + this.constraints = {}; + + const devices = await navigator.mediaDevices.enumerateDevices(); + + this.cameras = []; + this.selectedCamera = 0; + + devices.forEach(device => { + if (device.kind == 'videoinput') { + if (this.getFacingMode(device) == "user") { + this.cameras.push(device); + } else { + this.cameras.unshift(device); + } + } + }); + + // Android bug, doesn't work with DevTools emulation. + if (navigator.userAgent.includes("Android") && + screen.orientation.type.includes("portrait")) { + this.constraints.width = Math.ceil(visualViewport.height); + this.constraints.height = Math.ceil(visualViewport.width); + } else { + this.constraints.width = Math.ceil(visualViewport.width); + this.constraints.height = Math.ceil(visualViewport.height); + } + + // Disable facingModeButton if there is no environment or user mode. + let facingModeButton = this.shadowRoot.getElementById('facingModeButton'); + if (this.cameras.length < 2) { + facingModeButton.style.color = 'gray'; + facingModeButton.style.border = '2px solid gray'; + } else { + facingModeButton.disabled = false; + } + + this.facingMode = this.getFacingMode(this.cameras[0]); + this.requestUpdate(); + this.constraints.deviceId = { exact: this.cameras[0].deviceId}; + + const videoElement = this.shadowRoot.querySelector('video'); + + await this.startCamera(videoElement, this.constraints); + + this.videoTrack = videoElement.srcObject.getVideoTracks()[0]; + this.imageCapturer = new ImageCapture(this.videoTrack); + + let cameraBar = this.shadowRoot.querySelector('#cameraBar'); + cameraBar.style.width = `${videoElement.videoWidth}px`; + + let mainContent = this.shadowRoot.getElementById('mainContent'); + mainContent.style.width = `${videoElement.videoWidth}px`; + mainContent.classList.remove('hidden'); + + this.shadowRoot.querySelector('.canvas-wrapper').style.height = + `${videoElement.videoHeight}px`; + + let resetButton = this.shadowRoot.querySelector('#resetButton'); + resetButton.classList.remove('hidden'); + resetButton.style.left = `${videoElement.videoWidth - resetButton.offsetWidth}px`; + resetButton.style.bottom = `${videoElement.videoHeight}px`; + resetButton.classList.add('hidden'); + + this.shadowRoot.getElementById('takePhotoButton').disabled = false; + + // Timeout needed in Chrome, see https://crbug.com/711524. + const settings = this.shadowRoot.querySelector('settings-pane'); + setTimeout(async () => { + await customElements.whenDefined('settings-pane'); + settings.applyFromTrack(this.videoTrack); + }, 500); + } + + render() { + return html` + + `; + } +} + +customElements.define('camera-capture', CameraCapture); \ No newline at end of file diff --git a/elements/camera-capture/index.html b/elements/camera-capture/index.html new file mode 100644 index 0000000..30c5ad9 --- /dev/null +++ b/elements/camera-capture/index.html @@ -0,0 +1,36 @@ + + + + + + + Pro Camera + + + + + + + + + + + + diff --git a/elements/camera-capture/settings-pane.js b/elements/camera-capture/settings-pane.js new file mode 100644 index 0000000..f85aa90 --- /dev/null +++ b/elements/camera-capture/settings-pane.js @@ -0,0 +1,291 @@ +import { html, css, LitElement } from '../../node_modules/lit-element'; + +import '../../node_modules/@material/mwc-icon-button'; +import './settings-slider.js'; + +class SettingsPane extends LitElement { + static styles = css` + :host { + width: 100%; + height: 100%; + display: inline-flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; + } + + mwc-icon-button { + color: white; + --mdc-theme-text-disabled-on-light: gray; + //background-color: rgba(0, 0, 0, 0.1); + border-radius: 50%; + } + + .pro-icons { + width: 100%; + padding: 20px; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: space-around; + align-items: center; + align-content: stretch; + box-sizing: border-box; + } + + .hidden { + display: none !important; + } + + .settings-bar { + position: relative; + width: 100%; + padding: 10px; + box-sizing: border-box; + } + + .settings, .pro-settings { + display: flex; + flex-direction: column; + align-items: center; + color: white; + font-size: 16px; + text-shadow: -1px 0 black, 0 1px black, 1px 0 black, 0 -1px black; + text-align: center; + } + + #wbIcon { + display: inline; + font-size: 16px; + } + + @media screen and (max-width: 960px) { + .settings select { + font-size: 12px; + } + .settings { + font-size: 14px; + } + } + @media screen and (max-width: 400px) { + .settings select { + font-size: 10px; + } + .settings { + font-size: 12px; + } + } + `; + + static get properties() { + return { + flash: { type: Boolean } + }; + } + + activePane = null; + + async applyFromTrack(videoTrack) { + // Make sure elements have been rendered before accessing them. + await this.updateComplete; + + const capabilities = videoTrack.getCapabilities(); + + // Boolean abilitites + ['torch'].forEach(id => { + if (!capabilities[id]) { + const control = this.shadowRoot.querySelector(`#${id}`); + control.disabled = true; + } + }); + + // Range abilities + ['iso', 'exposureTime', 'focusDistance', 'colorTemperature', 'zoom', + 'contrast', 'saturation', 'sharpness', 'brightness', + 'exposureCompensation'].forEach(id => { + const control = this.shadowRoot.querySelector(`#${id}`); + + if (capabilities[id]) { + const {min, max, step} = capabilities[id]; + control.step = step; + control.max = max; + control.min = min; + } else { + console.log(id, 'is not supported.'); + control.disabled = true; + } + }); + } + + hide() { + const bar = this.shadowRoot.querySelector('#settings-bar'); + let panes = Array.from(bar.children); + panes.forEach(pane => { + pane.classList.add('hidden'); + }); + } + + _toggleGroup(e) { + const id = e.target.getAttribute('for'); + if (!id) return; + + this.activePane = null; + + if (id == "torch") { + const torch = this.shadowRoot.querySelector("#torch"); + if (!torch.disabled) { + this.flash = !this.flash; + const constraints = { advanced: [{ torch: this.flash}] }; + this.dispatchEvent(new CustomEvent('constraintschange', { detail: constraints })); + } + this.hide(); + return; + } + + const bar = this.shadowRoot.querySelector('#settings-bar'); + const pane = this.shadowRoot.querySelector(`#${id}Settings`); + + let panes = Array.from(bar.children).filter(el => el != pane); + panes.forEach(pane => { + pane.classList.add('hidden'); + }); + + pane.classList.toggle('hidden'); + if (!pane.classList.contains('hidden')) { + this.activePane = pane; + Array.from(pane.children).forEach(child => { + if ("layout" in child) child.layout(); + }) + } + } + + _colorTemperatureChange(value) { + // See WB ranges: https://w3c.github.io/mediacapture-image/#white-balance-mode + let iconName = ''; + switch (true) { + case (value >= 9000): + break; + case (value >= 8000): // Twilight mode. + iconName = 'brightness_3'; + break; + case (value >= 6500): // Cloudy-daylight mode. + iconName = 'wb_cloudy'; + break; + case (value >= 5500): // Daylight mode. + iconName = 'wb_sunny'; + break; + case (value >= 5000): + break; + case (value >= 4000): // Fluorescent mode. + iconName = 'wb_iridescent'; + break; + case (value >= 3500): + break; + case (value >= 2500): // Incandescent mode. + iconName = 'wb_incandescent'; + break; + case (value == 0): // Continuous mode. + iconName = 'wb_auto'; + break; + } + this.shadowRoot.getElementById('wbIcon').innerText = iconName; + } + + firstUpdated() { + const controls = this.shadowRoot.querySelectorAll('settings-slider'); + controls.forEach(control => { + control.onchange = e => { + const id = e.target.getAttribute('id'); + const value = e.detail.value; + console.log(id, ":", value); + + let constraints = { advanced: [{}] }; + constraints.advanced[0][id] = value; + if (id == 'exposureTime') { + constraints.advanced[0]['exposureMode'] = 'manual'; + } else if (id == 'focusDistance') { + constraints.advanced[0]['focusMode'] = 'manual'; + } else if (id == 'colorTemperature') { + this._colorTemperatureChange(value); + constraints.advanced[0]['whiteBalanceMode'] = 'manual'; + } + + this.dispatchEvent(new CustomEvent('constraintschange', { detail: { constraints } })); + }; + }); + } + + reset() { + const id = this.activePane.getAttribute("id"); + let constraints = { advanced: [{}] }; + + if (id == 'standardSettings') { + Array.from(this.activePane.children).forEach(child => { + child.value = (child.max - child.min) / 2 + Number(child.min); + if (!child.disabled) { + constraints.advanced[0][id] = child.value; + } + }); + } else if (id == 'exposureTimeSettings') { + constraints.advanced[0]['exposureMode'] = 'continuous'; + this.activePane.children[0].value = 0; + } else if (id == 'focusDistanceSettings') { + constraints.advanced[0]['focusMode'] = 'continuous'; + this.activePane.children[0].value = 0; + } else if (id == 'colorTemperatureSettings') { + constraints.advanced[0]['whiteBalanceMode'] = 'continuous'; + this.activePane.children[0].value = 0; + this._colorTemperatureChange(0); + } else if (id == 'zoomSettings') { + constraints.advanced[0]['zoom'] = 1; + this.activePane.children[0].value = 1; + } + + // The handler of 'constrainstchange' should call applyFromTrack after applying + // reset settings. + this.dispatchEvent(new CustomEvent('constraintschange', { detail: { constraints } })); + } + + render() { + return html` + +
e.stopPropagation()}> + + + + + + +
+
{ this._toggleGroup(e); e.stopPropagation()}}> + + + + + + + +
+ `; + } +} + +customElements.define('settings-pane', SettingsPane); \ No newline at end of file diff --git a/elements/camera-capture/settings-slider.js b/elements/camera-capture/settings-slider.js new file mode 100644 index 0000000..27e0e81 --- /dev/null +++ b/elements/camera-capture/settings-slider.js @@ -0,0 +1,73 @@ +import { html, css, LitElement } from '../../node_modules/lit-element'; +import { classMap } from '../../node_modules/lit-html/directives/class-map'; +import '../../node_modules/@material/mwc-slider'; + +class SettingsSlider extends LitElement { + static styles = css` + :host { + display: inline-flex; + align-items: center; + justify-content: flex-end; + margin: 6px; + } + + mwc-slider { + min-width: 140px; + padding: 0px 12px; + } + + .title { + width: 120px; + } + + .value { + min-width: 60px; + text-align: left; + } + + .disabled { + opacity: 0.5; + color: gray; + } + `; + + static get properties() { + return { + label: { type: String }, + value: { type: Number, reflect: true }, + min: { type: Number }, + max: { type: Number }, + step: { type: Number }, + disabled: { type: Boolean } + }; + } + + _onchange(e) { + this.value = e.detail.value; + console.log(this.value); + this.dispatchEvent(new CustomEvent('change', { detail: {value: this.value} })); + } + + layout() { + this.shadowRoot.querySelector('#slider').layout(); + } + + render() { + return html` +
${this.label}:
+ + +
+ ${this.value} + +
+ `; + } +} + +customElements.define('settings-slider', SettingsSlider); \ No newline at end of file diff --git a/elements/demo-logger/demo-logger.js b/elements/demo-logger/demo-logger.js new file mode 100644 index 0000000..ea3965d --- /dev/null +++ b/elements/demo-logger/demo-logger.js @@ -0,0 +1,26 @@ +import { html, css, LitElement } from '../../node_modules/lit-element'; + +import '../../node_modules/@material/mwc-snackbar'; + +class DemoLogger extends LitElement { + firstUpdated() { + const snackbar = this.shadowRoot.querySelector('#errorSnackbar'); + + let log = console.error; + console.error = (...messages) => { + snackbar.labelText = messages.join(" "); + snackbar.open(); + log.call(console, ...messages); + } + } + + render() { + return html` + + + `; + } +} + +customElements.define('demo-logger', DemoLogger); \ No newline at end of file diff --git a/elements/index.html b/elements/index.html new file mode 100644 index 0000000..a543982 --- /dev/null +++ b/elements/index.html @@ -0,0 +1,12 @@ + + + Elements collection + + + + + \ No newline at end of file diff --git a/es-dev-server.config.js b/es-dev-server.config.js new file mode 100644 index 0000000..d206e79 --- /dev/null +++ b/es-dev-server.config.js @@ -0,0 +1,11 @@ +module.exports = { + middlewares: [ + function rewriteIndex(context, next) { + if (context.url.endsWith('/')) { + context.url += 'index.html'; + } + + return next(); + } + ], +}; \ No newline at end of file diff --git a/package.json b/package.json index 6f4b9dd..9e5382b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "description": "", "main": "samples/js/main.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "start": "es-dev-server --app-index elements/index.html --node-resolve --watch --open", + "start:build": "es-dev-server --app-index samples/index.html --open", + "build": "rimraf docs && rollup -c rollup.config.js" }, "repository": { "type": "git", @@ -15,5 +17,24 @@ "bugs": { "url": "https://github.com/riju/WebCamera/issues" }, - "homepage": "https://github.com/riju/WebCamera#readme" + "homepage": "https://github.com/riju/WebCamera#readme", + "devDependencies": { + "@babel/core": "^7.2.2", + "@open-wc/building-rollup": "^0.6.3", + "@webcomponents/webcomponentsjs": "^2.2.4", + "es-dev-server": "^1.18.4", + "rimraf": "^3.0.0", + "rollup": "^1.23.1", + "rollup-plugin-babel": "^4.3.0", + "rollup-plugin-node-resolve": "^4.0.0" + }, + "dependencies": { + "@babel/plugin-proposal-class-properties": "^7.5.5", + "@material/mwc-icon": "^0.9.1", + "@material/mwc-icon-button": "^0.9.1", + "@material/mwc-ripple": "^0.9.1", + "@material/mwc-slider": "^0.9.1", + "@material/mwc-snackbar": "^0.9.1", + "lit-element": "^2.2.1" + } } diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..9f49d0f --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,76 @@ +const resolve = require('rollup-plugin-node-resolve'); +const babel = require('rollup-plugin-babel'); +const { DEFAULT_EXTENSIONS } = require('@babel/core'); +const { findSupportedBrowsers } = require('@open-wc/building-utils'); +const customMinifyCss = require('@open-wc/building-utils/custom-minify-css'); + +const production = !process.env.ROLLUP_WATCH; + +const plugins = [ + resolve(), + + // run code through babel + babel({ + extensions: DEFAULT_EXTENSIONS, + plugins: [ + '@babel/plugin-syntax-dynamic-import', + '@babel/plugin-syntax-import-meta', + // rollup rewrites import.meta.url, but makes them point to the file location after bundling + // we want the location before bundling + 'bundled-import-meta', + production && [ + 'template-html-minifier', + { + modules: { + 'lit-html': ['html'], + 'lit-element': ['html', { name: 'css', encapsulation: 'style' }], + }, + htmlMinifier: { + collapseWhitespace: true, + removeComments: true, + caseSensitive: true, + minifyCSS: customMinifyCss, + }, + }, + ], + ].filter(_ => !!_), + + presets: [ + [ + '@babel/preset-env', + { + targets: findSupportedBrowsers(), + // preset-env compiles template literals for safari 12 due to a small bug which + // doesn't affect most use cases. for example lit-html handles it: (https://github.com/Polymer/lit-html/issues/575) + exclude: ['@babel/plugin-transform-template-literals'], + useBuiltIns: false, + modules: false, + }, + ], + ], + }) +]; + +const outputOptions = [ + { + input: './elements/camera-capture/camera-capture.js', + output: { + file: './build/elements/camera-capture.js', + sourcemap: true, + format: 'esm', + name: 'CameraCapture' + }, + plugins + },{ + input: './elements/demo-logger/demo-logger.js', + output: { + file: './build/elements/demo-logger.js', + sourcemap: true, + format: 'esm', + name: 'CameraCapture' + }, + plugins + } +]; + +export default outputOptions; \ No newline at end of file diff --git a/samples/camera/README.md b/samples/camera/README.md index 9df6773..6f0a41f 100644 --- a/samples/camera/README.md +++ b/samples/camera/README.md @@ -28,6 +28,26 @@ will also be useful for Focus Stacking. Implementation information for other properties can be found in [here](https://github.com/w3c/mediacapture-image/blob/master/implementation-status.md). +## How to run + +To get the development dependencies (like local dev server) and project +dependencies like the Material web components, please run + +`npm run install` + +To test locally run the below command. This will spin up a local dev server +that opens the project in the browser tab and reloads it when changes are saved + +`npm run start` + +To build the project into /docs which GitHub pages supports, please run + +`npm run build` + +To check that the build is working perfectly, run + +`npm run start:build` + ## Useful Links diff --git a/samples/camera/index.html b/samples/camera/index.html index 7484d2c..8fe422b 100644 --- a/samples/camera/index.html +++ b/samples/camera/index.html @@ -4,99 +4,35 @@ - Image Capture Capabilities - - - - + Pro Camera + + - -

-