├── .eslintignore ├── app ├── .eslintignore ├── client │ ├── about │ │ ├── github.png │ │ ├── about.js │ │ ├── about.css │ │ └── about.html │ ├── .eslintrc │ ├── elements │ │ ├── img │ │ │ ├── button-grey.png │ │ │ └── button-red.png │ │ ├── atem-composition-box.js │ │ ├── atem-size.html │ │ ├── atem-supersource-box.html │ │ ├── atem-status.js │ │ ├── atem-size.js │ │ ├── atem-composition-box.html │ │ ├── atem-app.js │ │ ├── atem-crop.js │ │ ├── atem-anchor-selector.js │ │ ├── atem-status.html │ │ ├── atem-supersource-box.js │ │ ├── atem-usk-selector.html │ │ ├── atem-settings │ │ │ ├── atem-settings.js │ │ │ └── atem-settings.html │ │ ├── atem-crop.html │ │ ├── atem-usk-selector.js │ │ ├── atem-anchor-selector.html │ │ ├── atem-app.html │ │ ├── atem-usk-box.html │ │ ├── atem-usk-box.js │ │ ├── atem-composition-box-properties.html │ │ └── atem-composition-box-properties.js │ ├── main.html │ ├── connection.html │ ├── lib │ │ └── colorize-label.js │ └── style │ │ └── atem-controller-theme.html ├── .npmrc ├── server │ ├── util.js │ ├── updater.js │ ├── main.js │ ├── connection-window.js │ ├── atem.js │ └── menu.js ├── package.json └── package-lock.json ├── .gitignore ├── .bowerrc ├── media ├── full_screenshot.png └── readme_screenshot.png ├── .eslintrc ├── .editorconfig ├── .gitattributes ├── .travis.yml ├── appveyor.yml ├── LICENSE ├── NOTES.md ├── bower.json ├── README.md ├── package.json └── CHANGELOG.md /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bower_components 3 | -------------------------------------------------------------------------------- /app/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bower_components 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | bower_components 4 | dist 5 | -------------------------------------------------------------------------------- /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "app/client/bower_components/" 3 | } 4 | -------------------------------------------------------------------------------- /app/client/about/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TipoftheHats/atem-compositor/HEAD/app/client/about/github.png -------------------------------------------------------------------------------- /media/full_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TipoftheHats/atem-compositor/HEAD/media/full_screenshot.png -------------------------------------------------------------------------------- /media/readme_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TipoftheHats/atem-compositor/HEAD/media/readme_screenshot.png -------------------------------------------------------------------------------- /app/.npmrc: -------------------------------------------------------------------------------- 1 | target=1.7.5 2 | disturl=https://atom.io/download/atom-shell 3 | runtime=electron 4 | build_from_source=true 5 | -------------------------------------------------------------------------------- /app/client/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | }, 5 | "globals": { 6 | "Polymer": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /app/client/elements/img/button-grey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TipoftheHats/atem-compositor/HEAD/app/client/elements/img/button-grey.png -------------------------------------------------------------------------------- /app/client/elements/img/button-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TipoftheHats/atem-compositor/HEAD/app/client/elements/img/button-red.png -------------------------------------------------------------------------------- /app/server/util.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const isDev = require('electron-is-dev'); 5 | 6 | module.exports = { 7 | isDev, 8 | version: (function () { 9 | const packagePath = path.resolve(__dirname, '../package.json'); 10 | return require(packagePath).version; 11 | })() 12 | }; 13 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "xo/esnext", 3 | "plugins": [ 4 | "html" 5 | ], 6 | "rules": { 7 | "new-cap": [ 8 | 2, 9 | { 10 | "capIsNewExceptions": [ 11 | "Polymer", 12 | "Polymer.MutableData", 13 | "Polymer.GestureEventListeners" 14 | ] 15 | } 16 | ], 17 | "capitalized-comments": [0] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | charset = utf-8 4 | 5 | [*] 6 | indent_style = tab 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | indent_size = 4 12 | 13 | [{package.json,*.yml}] 14 | indent_style = space 15 | indent_size = 2 16 | 17 | # Don't remove trailing whitespace from Markdown 18 | [*.md] 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /app/client/about/about.js: -------------------------------------------------------------------------------- 1 | const {shell} = require('electron'); 2 | 3 | const {version} = require('../../server/util'); 4 | 5 | document.getElementById('header').textContent = `ATEM Compositor v${version}`; 6 | 7 | // Open links externally by default 8 | document.addEventListener('click', e => { 9 | if (e.target.tagName === 'A' && e.target.target === '_blank') { 10 | e.preventDefault(); 11 | shell.openExternal(e.target.href); 12 | } 13 | }); 14 | 15 | -------------------------------------------------------------------------------- /app/client/elements/atem-composition-box.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | 'use strict'; 3 | 4 | /** 5 | * @customElement 6 | * @polymer 7 | */ 8 | class AtemCompositionBox extends Polymer.Element { 9 | static get is() { 10 | return 'atem-composition-box'; 11 | } 12 | 13 | static get properties() { 14 | return { 15 | label: String, 16 | enabled: { 17 | type: Boolean, 18 | notify: true 19 | } 20 | }; 21 | } 22 | } 23 | 24 | customElements.define(AtemCompositionBox.is, AtemCompositionBox); 25 | })(); 26 | -------------------------------------------------------------------------------- /app/client/main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/server/updater.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Packages 4 | const {autoUpdater} = require('electron-updater'); 5 | const {ipcMain} = require('electron'); 6 | const log = require('electron-log'); 7 | 8 | // Ours 9 | const {isDev} = require('./util'); 10 | 11 | autoUpdater.logger = log; 12 | 13 | module.exports = function (mainWindow) { 14 | if (isDev) { 15 | log.debug('Detected dev environment, autoupdate disabled.'); 16 | return; 17 | } 18 | 19 | autoUpdater.on('update-downloaded', info => { 20 | mainWindow.webContents.send('updateDownloaded', info); 21 | }); 22 | 23 | log.debug('Checking for updates...'); 24 | autoUpdater.checkForUpdates(); 25 | 26 | ipcMain.on('installUpdateNow', () => { 27 | autoUpdater.quitAndInstall(); 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text=auto 3 | 4 | # Explicitly declare text files you want to always be normalized and converted 5 | # to native line endings on checkout. 6 | *.css text eol=lf 7 | *.html text eol=lf 8 | *.js text eol=lf 9 | *.json text eol=lf 10 | *.jsx text eol=lf 11 | *.md text eol=lf 12 | 13 | # Force images/fonts to be handled as binaries 14 | *.data binary 15 | *.eof binary 16 | *.eot binary 17 | *.exe binary 18 | *.flv binary 19 | *.gif binary 20 | *.icns binary 21 | *.ico binary 22 | *.jpeg binary 23 | *.jpg binary 24 | *.mov binary 25 | *.mp3 binary 26 | *.mp4 binary 27 | *.ogg binary 28 | *.png binary 29 | *.swf binary 30 | *.t3d binary 31 | *.t3x binary 32 | *.ttf binary 33 | *.webm binary 34 | *.woff binary 35 | *.woff2 binary 36 | -------------------------------------------------------------------------------- /app/client/connection.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/client/elements/atem-size.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "atem-compositor", 3 | "version": "1.1.2", 4 | "description": "An alternate UI for rapidly creating precise compositions on a Blackmagic ATEM video switcher.", 5 | "homepage": "https://github.com/tipofthehats/atem-compositor", 6 | "dependencies": { 7 | "atem-connection": "^0.2.1", 8 | "decimal.js": "^10.0.1", 9 | "electron-debug": "^2.0.0", 10 | "electron-is-dev": "^0.3.0", 11 | "electron-log": "^2.2.16", 12 | "electron-updater": "^2.23.3", 13 | "electron-window-state": "^4.1.1", 14 | "get-port": "^3.2.0", 15 | "tinycolor2": "^1.4.1" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/lange/atem-compositor.git" 20 | }, 21 | "license": "MIT", 22 | "author": "Alex Van Camp ", 23 | "main": "server/main.js" 24 | } 25 | -------------------------------------------------------------------------------- /app/client/elements/atem-supersource-box.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/client/elements/atem-status.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | 'use strict'; 3 | const {ipcRenderer} = require('electron'); 4 | 5 | /** 6 | * @customElement 7 | * @polymer 8 | */ 9 | class AtemStatus extends Polymer.Element { 10 | static get is() { 11 | return 'atem-status'; 12 | } 13 | 14 | static get properties() { 15 | return { 16 | status: { 17 | type: String, 18 | reflectToAttribute: true, 19 | notify: true, 20 | value: 'offline' 21 | } 22 | }; 23 | } 24 | 25 | ready() { 26 | super.ready(); 27 | ipcRenderer.on('atem-connection-status', (event, status) => { 28 | this.status = status; 29 | console.log('new status:', status); 30 | }); 31 | 32 | this.addEventListener('click', () => { 33 | ipcRenderer.send('openConnectionWindow'); 34 | }); 35 | } 36 | } 37 | 38 | customElements.define(AtemStatus.is, AtemStatus); 39 | })(); 40 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | osx_image: xcode9.3 2 | 3 | dist: trusty 4 | sudo: false 5 | 6 | language: node_js 7 | node_js: "10" 8 | 9 | env: 10 | global: 11 | - ELECTRON_CACHE=%HOME/.cache/electron 12 | - ELECTRON_BUILDER_CACHE=$HOME/.cache/electron-builder 13 | # Deployment & code signing environment variables are declared in the project settings on Travis CI. 14 | 15 | os: 16 | - linux 17 | - osx 18 | 19 | cache: 20 | directories: 21 | - node_modules 22 | - app/node_modules 23 | - app/bower_components 24 | - $HOME/.cache/electron 25 | - $HOME/.cache/electron-builder 26 | - $HOME/.npm/_prebuilds 27 | 28 | addons: 29 | apt: 30 | packages: 31 | - libsecret-1-0 32 | 33 | install: 34 | - npm install -g bower 35 | - npm install 36 | - bower install 37 | 38 | script: 39 | - npm test && npm run dist 40 | 41 | branches: 42 | except: 43 | - '/^chore\(release\)/' 44 | -------------------------------------------------------------------------------- /app/client/about/about.css: -------------------------------------------------------------------------------- 1 | body { 2 | display: flex; 3 | flex-direction: column; 4 | height: 100vh; 5 | margin: 0; 6 | font-family: 'Roboto', sans-serif; 7 | } 8 | 9 | #header { 10 | width: 100%; 11 | height: 66px; 12 | background-color: #00bebe; 13 | display: flex; 14 | justify-content: center; 15 | align-items: center; 16 | color: white; 17 | font-size: 24px; 18 | } 19 | 20 | #main { 21 | display: flex; 22 | flex-direction: column; 23 | justify-content: center; 24 | align-items: center; 25 | padding: 20px 26px; 26 | flex-grow: 1; 27 | } 28 | 29 | #license { 30 | display: block; 31 | text-align: center; 32 | margin-top: 15px; 33 | flex-grow: 1; 34 | } 35 | 36 | #icons { 37 | display: flex; 38 | width: 100%; 39 | justify-content: center; 40 | } 41 | 42 | .icon { 43 | display: flex; 44 | justify-content: center; 45 | align-items: center; 46 | } 47 | 48 | .icon a { 49 | padding: 0 8px; 50 | } 51 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | platform: 2 | - x64 3 | 4 | environment: 5 | ELECTRON_CACHE: '%USERPROFILE%\.cache\electron' 6 | ELECTRON_BUILDER_CACHE: '%USERPROFILE%\.cache\electron-builder' 7 | # Deployment & code signing environment variables are declared in the project settings on AppVeyor. 8 | 9 | cache: 10 | - node_modules 11 | - 'app\node_modules' 12 | - 'app\bower_components' 13 | - '%USERPROFILE%\.cache\electron' 14 | - '%USERPROFILE%\.cache\electron-builder' 15 | - '%USERPROFILE%\.npm\_prebuilds' 16 | 17 | init: 18 | - git config --global core.autocrlf input 19 | 20 | install: 21 | - ps: Install-Product node 10 x64 22 | - git reset --hard HEAD 23 | - npm install -g bower 24 | - npm install 25 | - bower install 26 | 27 | build_script: 28 | - node --version 29 | - npm --version 30 | - npm test && npm run dist 31 | 32 | test: off 33 | 34 | branches: 35 | except: 36 | - '/^chore\(release\)/' 37 | -------------------------------------------------------------------------------- /app/client/elements/atem-size.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | 'use strict'; 3 | 4 | const Decimal = require('decimal.js'); 5 | 6 | /** 7 | * @customElement 8 | * @polymer 9 | */ 10 | class AtemSize extends Polymer.Element { 11 | static get is() { 12 | return 'atem-size'; 13 | } 14 | 15 | static get properties() { 16 | return { 17 | min: Number, 18 | max: Number, 19 | step: Number, 20 | value: { 21 | type: Number, 22 | notify: true 23 | }, 24 | boxState: Number 25 | }; 26 | } 27 | 28 | _calcSizePixels(size) { 29 | if (typeof size !== 'number' || isNaN(size)) { 30 | return; 31 | } 32 | 33 | const width = new Decimal(1920).times(size).dividedBy(1000); 34 | const height = new Decimal(1080).times(size).dividedBy(1000); 35 | return `${width.toDecimalPlaces(0)}x${height.toDecimalPlaces(0)}`; 36 | } 37 | } 38 | 39 | customElements.define(AtemSize.is, AtemSize); 40 | })(); 41 | -------------------------------------------------------------------------------- /app/client/elements/atem-composition-box.html: -------------------------------------------------------------------------------- 1 | 2 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /app/client/about/about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 |
14 |
Developed by Alex Van Camp
15 |
Designed by Chris Hanel
16 | 17 | 18 | Released under the MIT license 19 | 20 | 21 |
22 |
23 | GitHub Repo 24 | 25 |
26 |
27 |
28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Alex Van Camp 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /app/client/elements/atem-app.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | 'use strict'; 3 | 4 | const {ipcRenderer} = require('electron'); 5 | Polymer.setPassiveTouchGestures(true); // Added in Polymer v2.1.0 6 | 7 | /** 8 | * @customElement 9 | * @polymer 10 | */ 11 | class AtemApp extends Polymer.Element { 12 | static get is() { 13 | return 'atem-app'; 14 | } 15 | 16 | static get properties() { 17 | return { 18 | atemState: Object 19 | }; 20 | } 21 | 22 | ready() { 23 | super.ready(); 24 | 25 | ipcRenderer.on('atem:stateChanged', (event, state) => { 26 | this.atemState = state; 27 | console.log('atem:stateChanged:', state); 28 | }); 29 | 30 | ipcRenderer.on('updateDownloaded', (event, info) => { 31 | this.$['updateDialog-label'].innerText = `A new version (${info.version}) is ready to install. Would you like to install it now?`; 32 | this.$.updateDialog.open(); 33 | }); 34 | 35 | ipcRenderer.send('init'); 36 | } 37 | 38 | _handleUpdateDialogClosed(e) { 39 | if (e.detail.confirmed) { 40 | ipcRenderer.send('installUpdateNow'); 41 | } 42 | } 43 | } 44 | 45 | customElements.define(AtemApp.is, AtemApp); 46 | })(); 47 | -------------------------------------------------------------------------------- /app/client/elements/atem-crop.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | 'use strict'; 3 | 4 | /** 5 | * @customElement 6 | * @polymer 7 | */ 8 | class AtemCrop extends Polymer.Element { 9 | static get is() { 10 | return 'atem-crop'; 11 | } 12 | 13 | static get properties() { 14 | return { 15 | label: { 16 | type: String, 17 | value: 'Crop' 18 | }, 19 | sideMin: { 20 | type: Number, 21 | value: 0 22 | }, 23 | sideMax: { 24 | type: Number, 25 | value: 32 26 | }, 27 | endMin: { 28 | type: Number, 29 | value: 0 30 | }, 31 | endMax: { 32 | type: Number, 33 | value: 18 34 | }, 35 | top: { 36 | type: Number, 37 | notify: true, 38 | value: 0 39 | }, 40 | bottom: { 41 | type: Number, 42 | notify: true, 43 | value: 0 44 | }, 45 | left: { 46 | type: Number, 47 | notify: true, 48 | value: 0 49 | }, 50 | right: { 51 | type: Number, 52 | notify: true, 53 | value: 0 54 | }, 55 | enabled: { 56 | type: Boolean, 57 | notify: true 58 | } 59 | }; 60 | } 61 | } 62 | 63 | customElements.define(AtemCrop.is, AtemCrop); 64 | })(); 65 | -------------------------------------------------------------------------------- /NOTES.md: -------------------------------------------------------------------------------- 1 | # ATEM Controller notes 2 | 3 | ## SKAARHOJ protocol notes 4 | 5 | See http://skaarhoj.com/fileadmin/BMDPROTOCOL.html 6 | 7 | - KeBP (get) 20 bytes Keyer Base 8 | - Can use this to get info on the current configuration of the keyers. I think this is for upstream keyers only? 9 | - CKTp (set) 8 bytes 10 | - Can use this to set some of the keyer properties. 11 | - CKMs (set) 12 bytes Key Mask 12 | - This is useful, this is what is used to set the mask (aka cropping) of the key. 13 | - KeDV (get) 60 bytes Key DVE 14 | - Used to get the DVE paramaters (such as size and position) of an upstream key. We'll need this. 15 | - CKDV (set) 64 bytes Key DVE 16 | - This is the money: this is how we set the size and position of an upstream key. 17 | - SSBP (get) 20 bytes Super Source Box Parameters 18 | - The first half of what we came for: getting Super Source Box parameters. 19 | - CSBP (set) 24 bytes Super Source Box Parameters 20 | - The second half of what we came for: setting Super Source Box parameters. 21 | 22 | ## FAQ (aka, notes to self) 23 | 24 | ### Q: Why aren't DSKs on this list? 25 | **A:** DSKs can't be re-sized and positioned: they can only be cropped. This isn't very useful for us. 26 | -------------------------------------------------------------------------------- /app/client/elements/atem-anchor-selector.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | 'use strict'; 3 | 4 | /** 5 | * @customElement 6 | * @polymer 7 | */ 8 | class AtemAnchorSelector extends Polymer.Element { 9 | static get is() { 10 | return 'atem-anchor-selector'; 11 | } 12 | 13 | static get properties() { 14 | return { 15 | selected: { 16 | type: String, 17 | value: 'center-center', 18 | observer: '_selectedChanged' 19 | }, 20 | xAnchor: { 21 | type: Number, 22 | notify: true 23 | }, 24 | yAnchor: { 25 | type: Number, 26 | notify: true 27 | } 28 | }; 29 | } 30 | 31 | _selectedChanged(newVal) { 32 | if (!newVal) { 33 | return; 34 | } 35 | 36 | newVal.split('-').forEach((string, index) => { 37 | let result = 0.5; 38 | 39 | if (string === 'top' || string === 'right') { 40 | result = 1; 41 | } else if (string === 'bottom' || string === 'left') { 42 | result = 0; 43 | } 44 | 45 | this[index === 0 ? 'yAnchor' : 'xAnchor'] = result; 46 | }); 47 | } 48 | 49 | _calcPrettyLabel(selected) { 50 | if (!selected || selected === 'center-center') { 51 | return 'Center'; 52 | } 53 | 54 | return selected.split('-').map(str => { 55 | return str.charAt(0).toUpperCase() + str.substr(1); 56 | }).join(' '); 57 | } 58 | } 59 | 60 | customElements.define(AtemAnchorSelector.is, AtemAnchorSelector); 61 | })(); 62 | -------------------------------------------------------------------------------- /app/client/elements/atem-status.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "atem-controller", 3 | "description": "An alternate UI for rapidly creating precise compositions on a Blackmagic ATEM video switcher.", 4 | "main": "", 5 | "authors": [ 6 | "Alex Van Camp " 7 | ], 8 | "license": "MIT", 9 | "homepage": "", 10 | "private": true, 11 | "ignore": [ 12 | "**/.*", 13 | "node_modules", 14 | "bower_components", 15 | "app/client/bower_components/", 16 | "test", 17 | "tests" 18 | ], 19 | "dependencies": { 20 | "polymer": "Polymer/polymer#^2.2.0", 21 | "font-roboto": "PolymerElements/font-roboto#^1.0.3", 22 | "iron-flex-layout": "PolymerElements/iron-flex-layout#^2.0.1", 23 | "paper-input": "PolymerElements/paper-input#^2.0.3", 24 | "paper-button": "PolymerElements/paper-button#^2.0.0", 25 | "paper-dialog": "PolymerElements/paper-dialog#^2.0.0", 26 | "iron-icons": "PolymerElements/iron-icons#^2.0.1", 27 | "iron-selector": "PolymerElements/iron-selector#^2.0.0", 28 | "paper-checkbox": "PolymerElements/paper-checkbox#^2.0.4", 29 | "paper-dropdown-menu": "PolymerElements/paper-dropdown-menu#^2.1.0", 30 | "paper-listbox": "PolymerElements/paper-listbox#^2.1.1", 31 | "paper-item": "PolymerElements/paper-item#^2.1.1", 32 | "web-animations-js": "web-animations/web-animations-js#^2.3.1", 33 | "paper-toggle-button": "PolymerElements/paper-toggle-button#^2.1.1", 34 | "paper-slider": "PolymerElements/paper-slider#^2.0.6", 35 | "iron-pages": "PolymerElements/iron-pages#^2.1.1" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/client/elements/atem-supersource-box.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | 'use strict'; 3 | 4 | const {ipcRenderer} = require('electron'); 5 | 6 | const DEFAULT_SIZE = 500; 7 | 8 | /** 9 | * @customElement 10 | * @polymer 11 | */ 12 | class AtemSupersourceBox extends Polymer.Element { 13 | static get is() { 14 | return 'atem-supersource-box'; 15 | } 16 | 17 | static get properties() { 18 | return { 19 | atemState: Object, 20 | boxId: Number, 21 | boxState: Object 22 | }; 23 | } 24 | 25 | resetCrop() { 26 | ipcRenderer.send('atem:takeSuperSourceBoxProperties', { 27 | boxId: this.boxId, 28 | properties: { 29 | cropTop: 0, 30 | cropBottom: 0, 31 | cropLeft: 0, 32 | cropRight: 0 33 | } 34 | }); 35 | } 36 | 37 | resetPosition() { 38 | ipcRenderer.send('atem:takeSuperSourceBoxProperties', { 39 | boxId: this.boxId, 40 | properties: { 41 | x: 0, 42 | y: 0 43 | } 44 | }); 45 | } 46 | 47 | resetSize() { 48 | ipcRenderer.send('atem:takeSuperSourceBoxProperties', { 49 | boxId: this.boxId, 50 | properties: { 51 | size: DEFAULT_SIZE 52 | } 53 | }); 54 | } 55 | 56 | takeEnabled(e) { 57 | ipcRenderer.send('atem:takeSuperSourceBoxProperties', { 58 | boxId: this.boxId, 59 | properties: { 60 | enabled: e.detail.value 61 | } 62 | }); 63 | } 64 | 65 | takeProperties(e) { 66 | ipcRenderer.send('atem:takeSuperSourceBoxProperties', { 67 | boxId: this.boxId, 68 | properties: e.detail.properties 69 | }); 70 | } 71 | 72 | _addOne(number) { 73 | return number + 1; 74 | } 75 | } 76 | 77 | customElements.define(AtemSupersourceBox.is, AtemSupersourceBox); 78 | })(); 79 | -------------------------------------------------------------------------------- /app/client/elements/atem-usk-selector.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /app/client/elements/atem-settings/atem-settings.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | 'use strict'; 3 | 4 | const {ipcRenderer} = require('electron'); 5 | const recentConnections = ipcRenderer.sendSync('getRecentConnectionsSync'); 6 | 7 | /** 8 | * @customElement 9 | * @polymer 10 | */ 11 | class ATEMSettings extends Polymer.Element { 12 | static get is() { 13 | return 'atem-settings'; 14 | } 15 | 16 | static get properties() { 17 | return { 18 | recentConnections: { 19 | type: Array, 20 | value: recentConnections.slice(0, 5) 21 | }, 22 | ip: String, 23 | port: { 24 | type: Number, 25 | value: 9910 26 | } 27 | }; 28 | } 29 | 30 | static get observers() { 31 | return [ 32 | '_updateSelectedRecentConnection(ip, port)' 33 | ]; 34 | } 35 | 36 | connect() { 37 | ipcRenderer.sendSync('submitIpPort', this.ip, parseInt(this.port, 10)); 38 | } 39 | 40 | cancel() { 41 | ipcRenderer.send('closeConnectionWindow'); 42 | } 43 | 44 | formatTimestamp(timestamp) { 45 | return new Date(timestamp).toLocaleString({ 46 | year: 'numeric', 47 | month: 'numeric', 48 | day: 'numeric', 49 | hour: 'numeric', 50 | minute: '2-digit', 51 | second: '2-digit' 52 | }); 53 | } 54 | 55 | _updateSelectedRecentConnection(ip, port) { 56 | this.$.recentConnections.selected = this.recentConnections.findIndex(rc => { 57 | return rc.ip === ip && rc.port === parseInt(port, 10); 58 | }); 59 | } 60 | 61 | _handleInputKeyDown(e) { 62 | // Enter key 63 | if (e.which === 13) { 64 | this.submit(); 65 | } 66 | } 67 | 68 | _calcRecentConnectionsHidden(recentConnections) { 69 | return !recentConnections || recentConnections.length <= 0; 70 | } 71 | 72 | _selectedRecentConnectionChanged(e) { 73 | const selectedRecentConnection = this.recentConnections[e.detail.value]; 74 | if (selectedRecentConnection) { 75 | this.ip = selectedRecentConnection.ip; 76 | this.port = selectedRecentConnection.port; 77 | } 78 | } 79 | } 80 | 81 | customElements.define(ATEMSettings.is, ATEMSettings); 82 | })(); 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ATEM Compositor 2 | 3 | [![Windows Status](https://ci.appveyor.com/api/projects/status/qr7b0at8kahdinow/branch/master?svg=true)](https://ci.appveyor.com/project/supportclass/atem-compositor/branch/master) 4 | [![Linux & OSX Status](https://travis-ci.com/TipoftheHats/atem-compositor.svg?branch=master)](https://travis-ci.com/TipoftheHats/atem-compositor) 5 | 6 | > An alternate UI for rapidly creating precise compositions on a Blackmagic ATEM video switcher. 7 | 8 | [![screenshot](media/readme_screenshot.png)](https://raw.githubusercontent.com/TipoftheHats/atem-compositor/master/media/full_screenshot.png) 9 | 10 | ## Motivation 11 | ATEMs are powerful vision mixers. However, some of that power is hamstrung by an at-times awkward UI. 12 | 13 | Specifically, the UI for SuperSources is not very operator-friendly. This program aims to be an alternative UI which smooths out some of the rough edges present in the official first-party ATEM Software Control interface. 14 | 15 | ## Features 16 | - All SuperSource box features of the stock UI. 17 | - Configure the X and Y position of SuperSource boxes using pixel values. 18 | - Size and position SuperSource boxes using one of 9 anchor points, instead of only being limited to a center anchor point. 19 | - Use a USK DVE as a sort of fifth SuperSource box 20 | 21 | ## Planned Features 22 | - Configure all Upstream and Downstream keys with pixel values. 23 | - Configure all Upstream and Downstream keys with top-left anchoring, instead of center anchoring. 24 | 25 | ## Installation 26 | Check the [Releases](https://github.com/tipofthehats/atem-compositor/releases) page to grab the latest installer for your operating system. 27 | Once installed, the application will autoupdate. 28 | 29 | ## Credits 30 | - Developed by [Alex Van Camp](https://twitter.com/vancamp) 31 | - Assets and additional design by [Chris Hanel](https://twitter.com/chrishanel) 32 | - Feedback and support from [SuperFly.tv](http://superfly.tv/) 33 | 34 | ## License 35 | ATEM Compositor is provided under the MIT license, which is available to read in the 36 | [LICENSE](https://github.com/tipofthehats/atem-compositor/blob/master/LICENSE) file. 37 | -------------------------------------------------------------------------------- /app/server/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Native 4 | const path = require('path'); 5 | 6 | // Packages 7 | const windowStateKeeper = require('electron-window-state'); 8 | const {app, BrowserWindow} = require('electron'); 9 | const log = require('electron-log'); 10 | 11 | // Ours 12 | const {version} = require('./util'); 13 | 14 | log.transports.file.level = 'debug'; 15 | log.transports.console.level = 'debug'; 16 | require('electron-debug')({showDevTools: false}); 17 | 18 | // Keep a global reference of the window object, if you don't, the window will 19 | // be closed automatically when the JavaScript object is garbage collected. 20 | let mainWindow; 21 | 22 | // This method will be called when Electron has finished 23 | // initialization and is ready to create browser windows. 24 | app.on('ready', async () => { 25 | // Load the previous state with fallback to defaults 26 | const mainWindowState = windowStateKeeper(); 27 | 28 | // Create the browser window using the state information. 29 | mainWindow = new BrowserWindow({ 30 | x: mainWindowState.x, 31 | y: mainWindowState.y, 32 | width: 1441, 33 | height: 746, 34 | useContentSize: true, 35 | resizable: false, 36 | frame: true, 37 | title: `ATEM Compositor v${version}` 38 | }); 39 | 40 | // Quit when main window is closed. 41 | mainWindow.on('closed', () => { 42 | app.quit(); 43 | }); 44 | 45 | // Spin up the ATEM lib 46 | const atem = require('./atem'); 47 | await atem.init(mainWindow); 48 | 49 | // Spin up the connection-window lib 50 | require('./connection-window').init(mainWindow); 51 | 52 | // Spin up the menu lib 53 | require('./menu')(mainWindow, atem); 54 | 55 | // Spin up the autoupdater 56 | require('./updater')(mainWindow); 57 | 58 | // Let us register listeners on the window, so we can update the state 59 | // automatically (the listeners will be removed when the window is closed) 60 | // and restore the maximized or full screen state 61 | mainWindowState.manage(mainWindow); 62 | 63 | // And load the index.html of the app. 64 | const webviewPath = path.resolve(__dirname, '../client/main.html'); 65 | mainWindow.loadURL(`file:///${webviewPath}`); 66 | }); 67 | -------------------------------------------------------------------------------- /app/client/elements/atem-crop.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "atem-compositor", 3 | "version": "1.1.2", 4 | "description": "An alternate UI for rapidly creating precise compositions on a Blackmagic ATEM video switcher.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "npm run static", 8 | "static": "eslint app/**/*.js app/**/*.html", 9 | "postinstall": "electron-builder install-app-deps", 10 | "pack": "electron-builder --dir", 11 | "dist": "electron-builder", 12 | "start": "electron app/server/main.js --enable-logging", 13 | "prerelease": "npm t", 14 | "release": "standard-version", 15 | "postrelease": "git push --follow-tags" 16 | }, 17 | "standard-version": { 18 | "skip": { 19 | "bump": true, 20 | "tag": true 21 | } 22 | }, 23 | "keywords": [ 24 | "atem", 25 | "blackmagic", 26 | "ui", 27 | "interface", 28 | "control", 29 | "web", 30 | "browser", 31 | "alternative", 32 | "alternate" 33 | ], 34 | "author": "Alex Van Camp ", 35 | "license": "MIT", 36 | "devDependencies": { 37 | "electron": "^2.0.3", 38 | "electron-builder": "20.18.0", 39 | "eslint": "^4.19.1", 40 | "eslint-config-xo": "^0.22.0", 41 | "eslint-plugin-html": "^4.0.5", 42 | "standard-version": "^4.4.0" 43 | }, 44 | "optionalDependencies": { 45 | "7zip-bin-mac": "^1.0.1" 46 | }, 47 | "build": { 48 | "appId": "org.tipofthehats.atem-compositor", 49 | "productName": "ATEM Compositor", 50 | "publish": [ 51 | { 52 | "provider": "github", 53 | "owner": "tipofthehats", 54 | "repo": "atem-compositor" 55 | } 56 | ], 57 | "mac": { 58 | "category": "public.app-category.utilities", 59 | "type": "distribution" 60 | }, 61 | "dmg": { 62 | "iconSize": 128, 63 | "contents": [ 64 | { 65 | "x": 425, 66 | "y": 200, 67 | "type": "link", 68 | "path": "/Applications" 69 | }, 70 | { 71 | "x": 120, 72 | "y": 200, 73 | "type": "file" 74 | } 75 | ] 76 | }, 77 | "linux": { 78 | "category": "Audio", 79 | "target": [ 80 | "AppImage", 81 | "deb" 82 | ] 83 | } 84 | }, 85 | "dependencies": {} 86 | } 87 | -------------------------------------------------------------------------------- /app/server/connection-window.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const CONNECTION_PROMPT_WIDTH = 402; 4 | let mainWindow; 5 | let connectionWindow; 6 | 7 | // Native 8 | const path = require('path'); 9 | 10 | // Packages 11 | const {BrowserWindow, ipcMain} = require('electron'); 12 | 13 | // Ours 14 | const {recentConnections} = require('./menu'); 15 | 16 | module.exports.init = function (mw) { 17 | mainWindow = mw; 18 | }; 19 | 20 | module.exports.open = function () { 21 | const height = calcConnectionWindowHeight(recentConnections); 22 | 23 | // Calculate the position of the urlPromptWindow. 24 | // It will appear in the center of the mainWindow. 25 | const mainWindowPosition = mainWindow.getPosition(); 26 | const mainWindowSize = mainWindow.getSize(); 27 | const x = Math.round(mainWindowPosition[0] + (mainWindowSize[0] / 2) - (CONNECTION_PROMPT_WIDTH / 2)); 28 | const y = Math.round(mainWindowPosition[1] + (mainWindowSize[1] / 2) - (height / 2)); 29 | 30 | // If the urlPromptWindow is already open, focus and re-center it. 31 | if (connectionWindow) { 32 | connectionWindow.focus(); 33 | connectionWindow.setPosition(x, y); 34 | return; 35 | } 36 | 37 | connectionWindow = new BrowserWindow({ 38 | x, 39 | y, 40 | width: CONNECTION_PROMPT_WIDTH, 41 | height, 42 | useContentSize: true, 43 | resizable: false, 44 | fullscreen: false, 45 | fullscreenable: false, 46 | frame: true, 47 | minimizable: false, 48 | maximizable: false, 49 | autoHideMenuBar: true, 50 | title: 'Connect' 51 | }); 52 | 53 | connectionWindow.on('closed', () => { 54 | connectionWindow = null; 55 | }); 56 | 57 | // Remove the menu from the urlPromptWindow. 58 | connectionWindow.setMenu(null); 59 | 60 | const promptPath = path.resolve(__dirname, '../client/connection.html'); 61 | connectionWindow.loadURL(`file:///${promptPath}`); 62 | }; 63 | 64 | module.exports.close = function () { 65 | if (connectionWindow) { 66 | connectionWindow.close(); 67 | } 68 | }; 69 | 70 | ipcMain.on('openConnectionWindow', () => { 71 | module.exports.open(); 72 | }); 73 | 74 | ipcMain.on('closeConnectionWindow', () => { 75 | module.exports.close(); 76 | }); 77 | 78 | function calcConnectionWindowHeight(recentConnections) { 79 | const BASE_HEIGHT = 80; 80 | const RECENT_LIST_OVERHEAD = 26; 81 | const HEIGHT_PER_RECENT = 18; 82 | 83 | let height = BASE_HEIGHT; 84 | if (recentConnections && recentConnections.length > 0) { 85 | height += RECENT_LIST_OVERHEAD; 86 | height += HEIGHT_PER_RECENT * Math.min(recentConnections.length, 5); 87 | } 88 | 89 | return height; 90 | } 91 | -------------------------------------------------------------------------------- /app/client/elements/atem-usk-selector.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | 'use strict'; 3 | 4 | const AtemEnums = require('atem-connection').Enums; 5 | 6 | /** 7 | * @customElement 8 | * @polymer 9 | */ 10 | class AtemUskSelector extends Polymer.Element { 11 | static get is() { 12 | return 'atem-usk-selector'; 13 | } 14 | 15 | static get properties() { 16 | return { 17 | atemState: Object, 18 | selectedMeIndex: { 19 | type: Number, 20 | notify: true 21 | }, 22 | selectedUskIndex: { 23 | type: Number, 24 | notify: true 25 | }, 26 | selectedMe: { 27 | type: Object, 28 | notify: true, 29 | computed: '_computeSelectedMe(selectedMeIndex, atemState)' 30 | }, 31 | activeDveMe: { 32 | type: Object, 33 | notify: true, 34 | computed: '_computeActiveDveMe(atemState)', 35 | observer: '_activeDveMeChanged' 36 | }, 37 | activeDveUsk: { 38 | type: Object, 39 | notify: true, 40 | computed: '_computeActiveDveUsk(activeDveMe)', 41 | observer: '_activeDveUskChanged' 42 | } 43 | }; 44 | } 45 | 46 | _computeSelectedMe(selectedMeIndex, atemState) { 47 | selectedMeIndex = parseInt(selectedMeIndex, 10); 48 | if (!atemState || !atemState.video || !atemState.video.ME || typeof selectedMeIndex !== 'number') { 49 | return; 50 | } 51 | 52 | return atemState.video.ME[selectedMeIndex]; 53 | } 54 | 55 | _computeActiveDveMe(atemState) { 56 | if (!atemState || !atemState.video || !atemState.video.ME) { 57 | return; 58 | } 59 | 60 | let activeDveMe; 61 | atemState.video.ME.forEach(me => { 62 | me.upstreamKeyers.forEach(usk => { 63 | if (usk.mixEffectKeyType === AtemEnums.MixEffectKeyType.DVE) { 64 | activeDveMe = me; 65 | } 66 | }); 67 | }); 68 | 69 | return activeDveMe; 70 | } 71 | 72 | _activeDveMeChanged(activeDveMe) { 73 | this.$.me.selected = activeDveMe ? activeDveMe.index : -1; 74 | } 75 | 76 | _computeActiveDveUsk(activeDveMe) { 77 | if (!activeDveMe) { 78 | return; 79 | } 80 | 81 | let activeDveUsk; 82 | activeDveMe.upstreamKeyers.forEach(usk => { 83 | if (usk.mixEffectKeyType === AtemEnums.MixEffectKeyType.DVE) { 84 | activeDveUsk = usk; 85 | } 86 | }); 87 | 88 | return activeDveUsk; 89 | } 90 | 91 | _activeDveUskChanged(activeDveUsk) { 92 | this.$.usk.selected = activeDveUsk ? activeDveUsk.upstreamKeyerId : -1; 93 | } 94 | 95 | _plusOne(number) { 96 | return number + 1; 97 | } 98 | } 99 | 100 | customElements.define(AtemUskSelector.is, AtemUskSelector); 101 | })(); 102 | -------------------------------------------------------------------------------- /app/client/elements/atem-anchor-selector.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 100 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /app/client/lib/colorize-label.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const tinycolor = require('tinycolor2'); 4 | 5 | module.exports = function (colorCode, labelElement) { 6 | let primaryColor; 7 | let gradientFgColor; 8 | let gradientBgColor; 9 | let textColor; 10 | const inverted = colorCode && colorCode.endsWith('i'); 11 | switch (colorCode) { 12 | case 'BL': 13 | textColor = '#05084B'; 14 | /* falls through */ 15 | case 'BLi': 16 | primaryColor = '#5454FF'; 17 | gradientFgColor = 'rgba(0, 0, 0, 0)'; 18 | gradientBgColor = '#05084B'; 19 | break; 20 | 21 | case 'CY': 22 | textColor = '#1A195E'; 23 | /* falls through */ 24 | case 'CYi': 25 | primaryColor = '#00FFFF'; 26 | gradientFgColor = '#4C3CFF'; 27 | gradientBgColor = '#11133F'; 28 | break; 29 | 30 | case 'GN': 31 | textColor = '#113F17'; 32 | /* falls through */ 33 | case 'GNi': 34 | primaryColor = '#0AFF5C'; 35 | gradientFgColor = '#27AA31'; 36 | gradientBgColor = '#113F17'; 37 | break; 38 | 39 | case 'MG': 40 | textColor = '#11133F'; 41 | /* falls through */ 42 | case 'MGi': 43 | primaryColor = '#FF00FF'; 44 | gradientFgColor = 'rgba(255, 60, 247, 0.5)'; 45 | gradientBgColor = '#11133F'; 46 | break; 47 | 48 | case 'OFF': 49 | textColor = '#000000'; 50 | /* falls through */ 51 | case 'OFFi': 52 | primaryColor = '#757575'; 53 | gradientFgColor = 'rgba(0, 0, 0, 0)'; 54 | gradientBgColor = '#000000'; 55 | break; 56 | 57 | case 'RD': 58 | textColor = '#3F1111'; 59 | /* falls through */ 60 | case 'RDi': 61 | primaryColor = '#FF2020'; 62 | gradientFgColor = 'rgba(255, 60, 60, 0.3)'; 63 | gradientBgColor = '#3F1111'; 64 | break; 65 | 66 | case 'WH': 67 | textColor = '#000000'; 68 | /* falls through */ 69 | case 'WHi': 70 | primaryColor = '#FFFFFF'; 71 | gradientFgColor = 'rgba(255, 255, 255, 0.3)'; 72 | gradientBgColor = '#343434'; 73 | break; 74 | 75 | case 'YE': 76 | textColor = '#3F3F11'; 77 | /* falls through */ 78 | case 'YEi': 79 | primaryColor = '#FFEB0A'; 80 | gradientFgColor = '#989200'; 81 | gradientBgColor = '#3F3F11'; 82 | break; 83 | 84 | default: 85 | // Do nothing. 86 | } 87 | 88 | if (inverted) { 89 | textColor = primaryColor; 90 | const tcGradientFgColor = tinycolor(gradientFgColor); 91 | const gradientFgEndColor = tcGradientFgColor.setAlpha(0).toRgbString(); 92 | labelElement.style.background = ` 93 | radial-gradient( 94 | closest-side at 54.61% 50%, 95 | ${gradientFgColor} 0%, 96 | ${gradientFgEndColor} 100% 97 | ), 98 | ${gradientBgColor} 99 | `; 100 | } else { 101 | labelElement.style.background = primaryColor; 102 | } 103 | 104 | labelElement.style.color = textColor; 105 | labelElement.style.border = `2px solid ${primaryColor}`; 106 | 107 | return { 108 | primaryColor, 109 | textColor, 110 | gradientFgColor, 111 | gradientBgColor 112 | }; 113 | }; 114 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | 6 | ## [1.1.2](https://github.com/TipoftheHats/atem-compositor/compare/v1.1.1...v1.1.2) (2018-08-02) 7 | 8 | 9 | ### Bug Fixes 10 | 11 | * fix USK DVE positioning not working as expected when using a non-center anchor point ([11225d7](https://github.com/TipoftheHats/atem-compositor/commit/11225d7)) 12 | 13 | 14 | 15 | 16 | ## [1.1.1](https://github.com/TipoftheHats/atem-compositor/compare/v1.1.0...v1.1.1) (2018-07-07) 17 | 18 | 19 | ### Bug Fixes 20 | 21 | * **ui:** fix autoupdate dialog being impossible to read ([b11e9ac](https://github.com/TipoftheHats/atem-compositor/commit/b11e9ac)) 22 | 23 | 24 | 25 | 26 | # [1.1.0](https://github.com/TipoftheHats/atem-compositor/compare/v1.0.0...v1.1.0) (2018-07-07) 27 | 28 | 29 | ### Bug Fixes 30 | 31 | * **ui:** add padding ([a60fd7f](https://github.com/TipoftheHats/atem-compositor/commit/a60fd7f)) 32 | * **usk:** fix several cases where changing the USK DVE would not work ([fc655a0](https://github.com/TipoftheHats/atem-compositor/commit/fc655a0)) 33 | 34 | 35 | 36 | 37 | # [1.0.0](https://github.com/TipoftheHats/atem-compositor/compare/v0.2.0...v1.0.0) (2018-07-03) 38 | 39 | 40 | 41 | 42 | # [0.2.0](https://github.com/TipoftheHats/atem-compositor/compare/v0.1.0...v0.2.0) (2018-07-03) 43 | 44 | 45 | ### Bug Fixes 46 | 47 | * fix responsiveness of some inputs not being what it should be ([3f5bade](https://github.com/TipoftheHats/atem-compositor/commit/3f5bade)) 48 | * **atem-crop:** prevent hover label from appearing ([b8ddb9c](https://github.com/TipoftheHats/atem-compositor/commit/b8ddb9c)) 49 | * **package:** update atem-connection ([482c303](https://github.com/TipoftheHats/atem-compositor/commit/482c303)) 50 | * **package:** update dev deps ([3272470](https://github.com/TipoftheHats/atem-compositor/commit/3272470)) 51 | * **package:** use eslint 4 ([3c32ce6](https://github.com/TipoftheHats/atem-compositor/commit/3c32ce6)) 52 | 53 | 54 | ### Features 55 | 56 | * implement USK "DVE in use" cover/prompt ([d84223e](https://github.com/TipoftheHats/atem-compositor/commit/d84223e)) 57 | * implement USK DVE controls ([9a659b6](https://github.com/TipoftheHats/atem-compositor/commit/9a659b6)) 58 | 59 | 60 | 61 | 62 | # 0.1.0 (2018-05-01) 63 | 64 | 65 | ### Bug Fixes 66 | 67 | * eliminate rounding errors ([ad65dbe](https://github.com/TipoftheHats/atem-controller/commit/ad65dbe)) 68 | * fix deps ([a7b336f](https://github.com/TipoftheHats/atem-controller/commit/a7b336f)) 69 | * fix source selection ([32c3341](https://github.com/TipoftheHats/atem-controller/commit/32c3341)) 70 | * reduce frequency of floating point rounding errors ([30d8fe1](https://github.com/TipoftheHats/atem-controller/commit/30d8fe1)) 71 | * resetSize was 10x too small ([8b45ad7](https://github.com/TipoftheHats/atem-controller/commit/8b45ad7)) 72 | 73 | 74 | ### Features 75 | 76 | * allow scaling in smaller increments ([3796dd3](https://github.com/TipoftheHats/atem-controller/commit/3796dd3)) 77 | * implement pixel values ([28b8d89](https://github.com/TipoftheHats/atem-controller/commit/28b8d89)) 78 | -------------------------------------------------------------------------------- /app/client/style/atem-controller-theme.html: -------------------------------------------------------------------------------- 1 | 2 | 125 | 126 | -------------------------------------------------------------------------------- /app/client/elements/atem-app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 121 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /app/client/elements/atem-usk-box.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 124 | 125 | 126 | 127 | -------------------------------------------------------------------------------- /app/client/elements/atem-usk-box.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | 'use strict'; 3 | 4 | const {ipcRenderer} = require('electron'); 5 | 6 | const DEFAULT_SIZE = 500; 7 | 8 | /** 9 | * @customElement 10 | * @polymer 11 | */ 12 | class AtemUskBox extends Polymer.Element { 13 | static get is() { 14 | return 'atem-usk-box'; 15 | } 16 | 17 | static get properties() { 18 | return { 19 | atemState: Object, 20 | activeDveUsk: { 21 | type: Object, 22 | value: null 23 | }, 24 | uskState: { 25 | type: Object, 26 | notify: true, 27 | computed: '_computeUskState(atemState, selectedMeIndex, selectedUskIndex)' 28 | } 29 | }; 30 | } 31 | 32 | resetCrop() { 33 | ipcRenderer.send('atem:takeUskDveSettings', { 34 | mixEffect: this.selectedMeIndex, 35 | upstreamKeyerId: this.selectedUskIndex, 36 | properties: { 37 | maskTop: 0, 38 | maskBottom: 0, 39 | maskLeft: 0, 40 | maskRight: 0 41 | } 42 | }); 43 | } 44 | 45 | resetPosition() { 46 | ipcRenderer.send('atem:takeUskDveSettings', { 47 | mixEffect: this.selectedMeIndex, 48 | upstreamKeyerId: this.selectedUskIndex, 49 | properties: { 50 | positionX: 0, 51 | positionY: 0 52 | } 53 | }); 54 | } 55 | 56 | resetSize() { 57 | ipcRenderer.send('atem:takeUskDveSettings', { 58 | mixEffect: this.selectedMeIndex, 59 | upstreamKeyerId: this.selectedUskIndex, 60 | properties: { 61 | sizeX: DEFAULT_SIZE, 62 | sizeY: DEFAULT_SIZE 63 | } 64 | }); 65 | } 66 | 67 | takeEnabled(e) { 68 | ipcRenderer.send('atem:takeUskOnAir', { 69 | mixEffect: this.selectedMeIndex, 70 | upstreamKeyerId: this.selectedUskIndex, 71 | onAir: e.detail.value 72 | }); 73 | } 74 | 75 | takeProperties(e) { 76 | const newProps = e.detail.properties; 77 | 78 | ipcRenderer.send('atem:takeUskFillSource', { 79 | mixEffect: this.selectedMeIndex, 80 | upstreamKeyerId: this.selectedUskIndex, 81 | fillSource: newProps.source 82 | }); 83 | 84 | ipcRenderer.send('atem:takeUskDveSettings', { 85 | mixEffect: this.selectedMeIndex, 86 | upstreamKeyerId: this.selectedUskIndex, 87 | properties: { 88 | positionX: newProps.x, 89 | positionY: newProps.y, 90 | sizeX: newProps.size, 91 | sizeY: newProps.size, 92 | maskEnabled: newProps.cropped, 93 | maskTop: newProps.cropTop, 94 | maskBottom: newProps.cropBottom, 95 | maskLeft: newProps.cropLeft, 96 | maskRight: newProps.cropRight 97 | } 98 | }); 99 | } 100 | 101 | acceptCover() { 102 | ipcRenderer.send('atem:setUskAsDve', { 103 | mixEffect: this.selectedMeIndex, 104 | upstreamKeyerId: this.selectedUskIndex 105 | }); 106 | } 107 | 108 | _computeUskState(atemState, selectedMeIndex, selectedUskIndex) { 109 | selectedMeIndex = parseInt(selectedMeIndex, 10); 110 | selectedUskIndex = parseInt(selectedUskIndex, 10); 111 | 112 | if (!atemState || typeof selectedMeIndex !== 'number' || typeof selectedUskIndex !== 'number') { 113 | return; 114 | } 115 | 116 | const meState = atemState.video.ME[selectedMeIndex]; 117 | if (!meState) { 118 | return; 119 | } 120 | 121 | const uskState = atemState.video.ME[selectedMeIndex].upstreamKeyers[selectedUskIndex]; 122 | if (!uskState) { 123 | return; 124 | } 125 | 126 | return uskState; 127 | } 128 | 129 | _isDveTaken(activeDveUsk, selectedUsk) { 130 | if (!activeDveUsk || !selectedUsk) { 131 | return false; 132 | } 133 | 134 | return activeDveUsk !== selectedUsk; 135 | } 136 | 137 | _calcBoxState(uskState) { 138 | if (!uskState) { 139 | return; 140 | } 141 | 142 | return { 143 | source: uskState.fillSource, 144 | x: uskState.dveSettings.positionX, 145 | y: uskState.dveSettings.positionY, 146 | size: uskState.dveSettings.sizeX, 147 | cropped: uskState.dveSettings.maskEnabled, 148 | cropTop: uskState.dveSettings.maskTop, 149 | cropBottom: uskState.dveSettings.maskBottom, 150 | cropLeft: uskState.dveSettings.maskLeft, 151 | cropRight: uskState.dveSettings.maskRight 152 | }; 153 | } 154 | 155 | _addOne(number) { 156 | return parseInt(number, 10) + 1; 157 | } 158 | } 159 | 160 | customElements.define(AtemUskBox.is, AtemUskBox); 161 | })(); 162 | -------------------------------------------------------------------------------- /app/client/elements/atem-settings/atem-settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 164 | 165 | 166 | 167 | -------------------------------------------------------------------------------- /app/client/elements/atem-composition-box-properties.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 162 | 163 | 164 | 165 | -------------------------------------------------------------------------------- /app/server/atem.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Packages 4 | const {Atem, Enums: AtemEnums} = require('atem-connection'); 5 | const log = require('electron-log'); 6 | const {ipcMain} = require('electron'); 7 | 8 | const atem = new Atem(); 9 | let mainWindow; 10 | let lastAtemStatus = 'offline'; 11 | let stateChangesWaiting = false; 12 | 13 | atem.socket.on('disconnect', () => { 14 | setATEMConnectionStatus('offline'); 15 | }); 16 | 17 | atem.socket.on('reconnect', () => { 18 | setATEMConnectionStatus('connecting'); 19 | }); 20 | 21 | atem.socket.on('connect', () => { 22 | setATEMConnectionStatus('connected'); 23 | }); 24 | 25 | module.exports = { 26 | async init(mw) { 27 | mainWindow = mw; 28 | 29 | atem.on('stateChanged', () => { 30 | stateChangesWaiting = true; 31 | }); 32 | 33 | ipcMain.on('init', () => { 34 | log.debug('Received "init" message from main window'); 35 | sendToMainWindow('atem-connection-status', lastAtemStatus); 36 | sendToMainWindow('atem:stateChanged', atem.state); 37 | }); 38 | 39 | ipcMain.on('atem:takeSuperSourceBoxProperties', (event, {boxId, properties}) => { 40 | const idString = `SSBP #${boxId}...`; 41 | log.debug(`Attempting to take ${idString}...`); 42 | atem.setSuperSourceBoxSettings(properties, boxId).then(() => { 43 | log.debug(`Successfully took ${idString}`); 44 | }).catch(e => { 45 | log.error(`Failed to take ${idString}:`, e); 46 | }); 47 | }); 48 | 49 | ipcMain.on('atem:takeUskDveSettings', (event, {mixEffect, upstreamKeyerId, properties}) => { 50 | const idString = `USK DVE Settings #${mixEffect}:${upstreamKeyerId}`; 51 | log.debug(`Attempting to take ${idString}...`); 52 | atem.setUpstreamKeyerDVESettings(properties, mixEffect, upstreamKeyerId).then(() => { 53 | log.debug(`Successfully took ${idString}`); 54 | }).catch(e => { 55 | log.error(`Failed to take ${idString}:`, e); 56 | }); 57 | }); 58 | 59 | ipcMain.on('atem:takeUskOnAir', async (event, {mixEffect, upstreamKeyerId, onAir}) => { 60 | mixEffect = parseInt(mixEffect, 10); 61 | upstreamKeyerId = parseInt(upstreamKeyerId, 10); 62 | 63 | if (isNaN(mixEffect) || isNaN(upstreamKeyerId)) { 64 | return; 65 | } 66 | 67 | if (mixEffect < 0 || upstreamKeyerId < 0) { 68 | return; 69 | } 70 | 71 | // If the DVE isn't taken, set this USK as the DVE. 72 | let activeDveMe; 73 | atem.state.video.ME.forEach(me => { 74 | me.upstreamKeyers.forEach(usk => { 75 | if (usk.mixEffectKeyType === AtemEnums.MixEffectKeyType.DVE) { 76 | activeDveMe = me; 77 | } 78 | }); 79 | }); 80 | if (!activeDveMe) { 81 | log.debug(`No DVE active, setting USK #${mixEffect}:${upstreamKeyerId} as the DVE...`); 82 | await setUskAsDve({mixEffect, upstreamKeyerId}); 83 | } 84 | 85 | const idString = `USK onAir #${mixEffect}:${upstreamKeyerId}`; 86 | log.debug(`Attempting to take ${idString}...`); 87 | atem.setUpstreamKeyerOnAir(onAir, mixEffect, upstreamKeyerId).then(() => { 88 | log.debug(`Successfully took ${idString}`); 89 | }).catch(e => { 90 | log.error(`Failed to take ${idString}:`, e); 91 | }); 92 | }); 93 | 94 | ipcMain.on('atem:takeUskFillSource', (event, {mixEffect, upstreamKeyerId, fillSource}) => { 95 | const idString = `USK fill source #${mixEffect}:${upstreamKeyerId}`; 96 | log.debug(`Attempting to take ${idString}...`); 97 | atem.setUpstreamKeyerFillSource(fillSource, mixEffect, upstreamKeyerId).then(() => { 98 | log.debug(`Successfully took ${idString}`); 99 | }).catch(e => { 100 | log.error(`Failed to take ${idString}:`, e); 101 | }); 102 | }); 103 | 104 | ipcMain.on('atem:setUskAsDve', (event, data) => { 105 | setUskAsDve(data); 106 | }); 107 | 108 | // Send state updates at most 60 times a second. 109 | setInterval(() => { 110 | if (stateChangesWaiting) { 111 | sendToMainWindow('atem:stateChanged', atem.state); 112 | stateChangesWaiting = false; 113 | } 114 | }, 1000 / 60); 115 | }, 116 | 117 | setIpPort(ip, port) { 118 | atem.connect(ip, port); 119 | setATEMConnectionStatus('connecting'); 120 | } 121 | }; 122 | 123 | async function setUskAsDve({mixEffect, upstreamKeyerId}) { 124 | const idString = `USK #${mixEffect}:${upstreamKeyerId}`; 125 | log.debug(`Attempting to set ${idString} as DVE...`); 126 | 127 | let activeDveMe; 128 | atem.state.video.ME.forEach(me => { 129 | me.upstreamKeyers.forEach(usk => { 130 | if (usk.mixEffectKeyType === AtemEnums.MixEffectKeyType.DVE) { 131 | activeDveMe = me; 132 | } 133 | }); 134 | }); 135 | 136 | let activeDveUsk; 137 | if (activeDveMe) { 138 | activeDveMe.upstreamKeyers.forEach(usk => { 139 | if (usk.mixEffectKeyType === AtemEnums.MixEffectKeyType.DVE) { 140 | activeDveUsk = usk; 141 | } 142 | }); 143 | } 144 | 145 | try { 146 | if (activeDveMe && activeDveUsk) { 147 | log.debug(`ME: ${activeDveMe.index}, USK: ${activeDveUsk.upstreamKeyerId}`); 148 | atem.setUpstreamKeyerOnAir(false, activeDveMe.index, activeDveUsk.upstreamKeyerId); 149 | await atem.setUpstreamKeyerType( 150 | {keyType: AtemEnums.MixEffectKeyType.Pattern}, 151 | activeDveMe.index, 152 | activeDveUsk.upstreamKeyerId 153 | ); 154 | } 155 | 156 | atem.setUpstreamKeyerType( 157 | {keyType: AtemEnums.MixEffectKeyType.DVE}, 158 | mixEffect, 159 | upstreamKeyerId 160 | ); 161 | atem.setUpstreamKeyerOnAir(true, mixEffect, upstreamKeyerId); 162 | 163 | log.debug(`Successfully set ${idString} as DVE`); 164 | } catch (e) { 165 | log.error(`Failed to set ${idString} as DVE:`, e); 166 | } 167 | } 168 | 169 | function sendToMainWindow(...args) { 170 | if (!mainWindow || mainWindow.isDestroyed()) { 171 | return; 172 | } 173 | 174 | mainWindow.webContents.send(...args); 175 | } 176 | 177 | function setATEMConnectionStatus(newStatus) { 178 | console.log('setATEMConnectionStatus:', newStatus); 179 | if (newStatus !== lastAtemStatus) { 180 | sendToMainWindow('atem-connection-status', newStatus); 181 | lastAtemStatus = newStatus; 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /app/server/menu.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ABOUT_WIDTH = 455; 4 | const ABOUT_HEIGHT = 230; 5 | 6 | // Native 7 | const fs = require('fs'); 8 | const path = require('path'); 9 | 10 | // Packages 11 | const {app, BrowserWindow, ipcMain, Menu, shell} = require('electron'); 12 | const log = require('electron-log'); 13 | 14 | // Ours 15 | const ipPrompt = require('./connection-window'); 16 | 17 | const userDataPath = app.getPath('userData'); 18 | const recentPath = path.join(userDataPath, 'recentConnections.json'); 19 | const recentConnections = (function () { 20 | if (fs.existsSync(recentPath)) { 21 | try { 22 | return JSON.parse(fs.readFileSync(recentPath, 'utf-8')); 23 | } catch (e) { 24 | log.error(e); 25 | return []; 26 | } 27 | } 28 | 29 | return []; 30 | })(); 31 | 32 | let aboutWindow; 33 | let mainWindow; 34 | let atem; 35 | 36 | module.exports = function (mw, a) { 37 | mainWindow = mw; 38 | atem = a; 39 | 40 | if (recentConnections && recentConnections.length > 0) { 41 | const recentUrl = recentConnections[0]; 42 | log.info(`Restoring connection to ${recentUrl.ip}:${recentUrl.port}`); 43 | recentUrl.lastOpened = Date.now(); 44 | atem.setIpPort(recentUrl.ip, recentUrl.port); 45 | } 46 | 47 | regenerateMenu(); 48 | }; 49 | 50 | module.exports.recentConnections = recentConnections; 51 | 52 | ipcMain.on('submitIpPort', (event, ip, port) => { 53 | let recentUrl = recentConnections.find(r => r.ip === ip && r.port === port); 54 | if (!recentUrl) { 55 | recentUrl = {ip, port}; 56 | recentConnections.push(recentUrl); 57 | 58 | if (recentConnections.length > 10) { 59 | recentConnections.length = 10; 60 | } 61 | } 62 | recentUrl.lastOpened = Date.now(); 63 | sortRecentConnections(); 64 | 65 | try { 66 | fs.writeFileSync(recentPath, JSON.stringify(recentConnections), 'utf-8'); 67 | } catch (e) { 68 | log.error(e); 69 | } 70 | 71 | atem.setIpPort(ip, port); 72 | ipPrompt.close(); 73 | regenerateMenu(); 74 | }); 75 | 76 | ipcMain.on('getRecentConnectionsSync', event => { 77 | event.returnValue = recentConnections; 78 | }); 79 | 80 | function regenerateMenu() { 81 | const fileTemplate = { 82 | label: 'File', 83 | submenu: [{ 84 | label: 'Open...', 85 | click() { 86 | ipPrompt.open(); 87 | } 88 | }, { 89 | label: 'Open Recent', 90 | submenu: recentConnections.map((r, index) => { 91 | return { 92 | label: `${r.ip}:${r.port}`, 93 | click() { 94 | atem.setIpPort(r.ip, r.port); 95 | recentConnections[index].lastUpdated = Date.now(); 96 | sortRecentConnections(); 97 | regenerateMenu(); 98 | } 99 | }; 100 | }) 101 | }] 102 | }; 103 | 104 | const viewTemplate = { 105 | label: 'View', 106 | submenu: [{ 107 | label: 'Reload', 108 | accelerator: 'CmdOrCtrl+R', 109 | click(item, focusedWindow) { 110 | if (focusedWindow) { 111 | focusedWindow.reload(); 112 | } 113 | } 114 | }, { 115 | label: 'Toggle Full Screen', 116 | accelerator: (function () { 117 | if (process.platform === 'darwin') { 118 | return 'Ctrl+Command+F'; 119 | } 120 | 121 | return 'F11'; 122 | })(), 123 | click(item, focusedWindow) { 124 | if (focusedWindow) { 125 | focusedWindow.setFullScreen(!focusedWindow.isFullScreen()); 126 | } 127 | } 128 | }, { 129 | label: 'Toggle Developer Tools', 130 | accelerator: (function () { 131 | if (process.platform === 'darwin') { 132 | return 'Alt+Command+I'; 133 | } 134 | 135 | return 'Ctrl+Shift+I'; 136 | })(), 137 | click(item, focusedWindow) { 138 | if (focusedWindow) { 139 | focusedWindow.toggleDevTools(); 140 | } 141 | } 142 | }] 143 | }; 144 | 145 | const windowTemplate = { 146 | label: 'Window', 147 | role: 'window', 148 | submenu: [{ 149 | label: 'Minimize', 150 | accelerator: 'CmdOrCtrl+M', 151 | role: 'minimize' 152 | }, { 153 | label: 'Close', 154 | accelerator: 'CmdOrCtrl+W', 155 | role: 'close' 156 | }] 157 | }; 158 | 159 | const helpTemplate = { 160 | label: 'Help', 161 | role: 'help', 162 | submenu: [{ 163 | label: 'About', 164 | click() { 165 | // Calculate the position of the aboutWindow. 166 | // It will appear in the center of the mainWindow. 167 | const mainWindowPosition = mainWindow.getPosition(); 168 | const mainWindowSize = mainWindow.getSize(); 169 | const x = Math.round(mainWindowPosition[0] + (mainWindowSize[0] / 2) - (ABOUT_WIDTH / 2)); 170 | const y = Math.round(mainWindowPosition[1] + (mainWindowSize[1] / 2) - (ABOUT_HEIGHT / 2)); 171 | 172 | // If the aboutWindow is already open, focus and re-center it. 173 | if (aboutWindow) { 174 | aboutWindow.focus(); 175 | aboutWindow.setPosition(x, y); 176 | return; 177 | } 178 | 179 | aboutWindow = new BrowserWindow({ 180 | x, 181 | y, 182 | width: ABOUT_WIDTH, 183 | height: ABOUT_HEIGHT, 184 | useContentSize: true, 185 | resizable: false, 186 | fullscreen: false, 187 | fullscreenable: false, 188 | frame: true, 189 | minimizable: false, 190 | maximizable: false, 191 | autoHideMenuBar: true, 192 | title: 'About ATEM Compositor' 193 | }); 194 | 195 | aboutWindow.on('closed', () => { 196 | aboutWindow = null; 197 | }); 198 | 199 | // Remove the menu from the aboutWindow. 200 | aboutWindow.setMenu(null); 201 | 202 | const promptPath = path.resolve(__dirname, '../client/about/about.html'); 203 | aboutWindow.loadURL(`file:///${promptPath}`); 204 | } 205 | }, { 206 | label: 'Report A Bug', 207 | click() { 208 | shell.openExternal('https://github.com/lange/atem-controller/issues/new'); 209 | } 210 | }] 211 | }; 212 | 213 | const template = [fileTemplate, viewTemplate, windowTemplate, helpTemplate]; 214 | 215 | // Add Mac-specific menu items 216 | if (process.platform === 'darwin') { 217 | const name = app.getName(); 218 | template.unshift({ 219 | label: name, 220 | submenu: [{ 221 | label: `About ${name}`, 222 | role: 'about' 223 | }, { 224 | type: 'separator' 225 | }, { 226 | label: 'Services', 227 | role: 'services', 228 | submenu: [] 229 | }, { 230 | type: 'separator' 231 | }, { 232 | label: `Hide ${name}`, 233 | accelerator: 'Command+H', 234 | role: 'hide' 235 | }, { 236 | label: 'Hide Others', 237 | accelerator: 'Command+Alt+H', 238 | role: 'hideothers' 239 | }, { 240 | label: 'Show All', 241 | role: 'unhide' 242 | }, { 243 | type: 'separator' 244 | }, { 245 | label: 'Quit', 246 | accelerator: 'Command+Q', 247 | click() { 248 | app.quit(); 249 | } 250 | }] 251 | }); 252 | 253 | windowTemplate.submenu.push({ 254 | type: 'separator' 255 | }, { 256 | label: 'Bring All to Front', 257 | role: 'front' 258 | }); 259 | } 260 | 261 | const menu = Menu.buildFromTemplate(template); 262 | Menu.setApplicationMenu(menu); 263 | } 264 | 265 | function sortRecentConnections() { 266 | recentConnections.sort((a, b) => { 267 | return b.lastOpened - a.lastOpened; 268 | }); 269 | } 270 | -------------------------------------------------------------------------------- /app/client/elements/atem-composition-box-properties.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | 'use strict'; 3 | 4 | const Decimal = require('decimal.js'); 5 | 6 | const PIXEL_HEIGHT = 1080; 7 | const PIXEL_WIDTH = 1920; 8 | const ATEM_WIDTH = 48; 9 | const ATEM_HEIGHT = 27; 10 | 11 | const awaitingBlur = new Map(); 12 | 13 | /** 14 | * @customElement 15 | * @polymer 16 | */ 17 | class AtemCompositionBoxProperties extends Polymer.Element { 18 | static get is() { 19 | return 'atem-composition-box-properties'; 20 | } 21 | 22 | static get properties() { 23 | return { 24 | atemState: Object, 25 | boxState: Object, 26 | positionScaleFactor: { 27 | type: Number, 28 | value: 100 29 | }, 30 | _xAnchor: Number, 31 | _yAnchor: Number, 32 | _usePixelValues: { 33 | type: Boolean, 34 | value: true 35 | } 36 | }; 37 | } 38 | 39 | static get observers() { 40 | return [ 41 | '_boxStateChanged(boxState.*, _usePixelValues, _xAnchor, _yAnchor)' 42 | ]; 43 | } 44 | 45 | ready() { 46 | super.ready(); 47 | this.take = this.take.bind(this); 48 | this._take = this._take.bind(this); 49 | 50 | this.addEventListener('change', this.take); 51 | this.addEventListener('selected-item-changed', this.take); 52 | } 53 | 54 | connectedCallback() { 55 | super.connectedCallback(); 56 | 57 | /* The "Input" event is needed to react to the number spinners being hit. 58 | * However, this event doesn't bubble up through the shadow boundary of paper-input. 59 | * Therefore, we need to pierce the shadow boundary and attach the listener directly to the input. */ 60 | [ 61 | ...this.shadowRoot.querySelectorAll('paper-input'), 62 | ...this.$.crop.shadowRoot.querySelectorAll('paper-input'), 63 | ...this.$.size.shadowRoot.querySelectorAll('paper-input') 64 | ].forEach(paperInput => { 65 | paperInput.shadowRoot.querySelector('input').addEventListener('input', () => { 66 | this.take(); 67 | }); 68 | }); 69 | } 70 | 71 | resetCrop() { 72 | this.dispatchEvent(new CustomEvent('reset-crop', { 73 | bubbles: true, 74 | composed: true 75 | })); 76 | } 77 | 78 | resetPosition() { 79 | this.dispatchEvent(new CustomEvent('reset-position', { 80 | bubbles: true, 81 | composed: true 82 | })); 83 | } 84 | 85 | resetSize() { 86 | this.dispatchEvent(new CustomEvent('reset-size', { 87 | bubbles: true, 88 | composed: true 89 | })); 90 | } 91 | 92 | resetAll() { 93 | this.resetCrop(); 94 | this.resetPosition(); 95 | this.resetSize(); 96 | } 97 | 98 | take() { 99 | this._takeDebouncer = Polymer.Debouncer.debounce( 100 | this._takeDebouncer, 101 | Polymer.Async.timeOut.after(0), 102 | this._take 103 | ); 104 | } 105 | 106 | _take() { 107 | if (this._xAnchor === 'undefined' || this._yAnchor === 'undefined') { 108 | return; 109 | } 110 | 111 | if (this.$.x.value === '-' || this.$.x.value === '' || 112 | this.$.y.value === '-' || this.$.y.value === '') { 113 | return; 114 | } 115 | 116 | let x = this.$.x.value; 117 | let y = this.$.y.value; 118 | 119 | if (this._usePixelValues) { 120 | x = convertRange(x, [-PIXEL_WIDTH, PIXEL_WIDTH * 2], [-ATEM_WIDTH, ATEM_WIDTH]); 121 | y = convertRange(y, [-PIXEL_HEIGHT, PIXEL_HEIGHT * 2], [ATEM_HEIGHT, -ATEM_HEIGHT]); 122 | } 123 | 124 | x = this._anchorDecode(x, 16, this._xAnchor, this.$.size.value); 125 | y = this._anchorDecode(y, 9, this._yAnchor, this.$.size.value); 126 | 127 | this.dispatchEvent(new CustomEvent('take', { 128 | detail: { 129 | properties: { 130 | source: parseInt(this.$.source.selected, 10), 131 | x, 132 | y, 133 | size: this._multiplyBy1000(this.$.size.value), 134 | cropped: this.$.crop.enabled, 135 | cropTop: this._multiplyBy1000(this.$.crop.top), 136 | cropBottom: this._multiplyBy1000(this.$.crop.bottom), 137 | cropLeft: this._multiplyBy1000(this.$.crop.left), 138 | cropRight: this._multiplyBy1000(this.$.crop.right) 139 | } 140 | }, 141 | bubbles: true, 142 | composed: true 143 | })); 144 | } 145 | 146 | _boxStateChanged() { 147 | if (!this.boxState || this._xAnchor === 'undefined' || this._yAnchor === 'undefined') { 148 | return; 149 | } 150 | 151 | const newState = this.boxState; 152 | 153 | let x = this._anchorEncode(newState.x, 16, this._xAnchor, newState.size); 154 | if (this._usePixelValues) { 155 | x = convertRange(x, [-ATEM_WIDTH, ATEM_WIDTH], [-PIXEL_WIDTH, PIXEL_WIDTH * 2]); 156 | } 157 | 158 | let y = this._anchorEncode(newState.y, 9, this._yAnchor, newState.size); 159 | if (this._usePixelValues) { 160 | y = convertRange(y, [ATEM_HEIGHT, -ATEM_HEIGHT], [-PIXEL_HEIGHT, PIXEL_HEIGHT * 2]); 161 | } 162 | 163 | this._setInputValue(this.$.x, x); 164 | this._setInputValue(this.$.y, y); 165 | this._setInputValue(this.$.size, this._divideBy1000(newState.size)); 166 | this._setInputValue(this.$.crop.$.top, this._divideBy1000(newState.cropTop)); 167 | this._setInputValue(this.$.crop.$.bottom, this._divideBy1000(newState.cropBottom)); 168 | this._setInputValue(this.$.crop.$.left, this._divideBy1000(newState.cropLeft)); 169 | this._setInputValue(this.$.crop.$.right, this._divideBy1000(newState.cropRight)); 170 | } 171 | 172 | _divideBy100(number) { 173 | return number / 100; 174 | } 175 | 176 | _divideBy1000(number) { 177 | return number / 1000; 178 | } 179 | 180 | _multiplyBy100(number) { 181 | return Math.floor(number * 100); 182 | } 183 | 184 | _multiplyBy1000(number) { 185 | return Math.floor(number * 1000); 186 | } 187 | 188 | _setInputValue(input, newValue) { 189 | if (awaitingBlur.has(input)) { 190 | awaitingBlur.set(input, newValue); 191 | return; 192 | } 193 | 194 | if (input.focused) { 195 | input.addEventListener('blur', () => { 196 | newValue = awaitingBlur.get(input); 197 | awaitingBlur.delete(input); 198 | this._setInputValue(input, newValue); 199 | }, {once: true, passive: true}); 200 | awaitingBlur.set(input, newValue); 201 | return; 202 | } 203 | 204 | if (input.value === newValue) { 205 | return; 206 | } 207 | 208 | input.value = newValue; 209 | } 210 | 211 | _ternary(condition, a, b) { 212 | return condition ? a : b; 213 | } 214 | 215 | _anchorEncode(value, maxValue, anchor, size) { 216 | value = new Decimal(value); 217 | anchor = new Decimal(anchor); 218 | size = new Decimal(size).dividedBy(1000 / this.positionScaleFactor); 219 | 220 | const result = value.minus(size.times(maxValue)).plus(size.times(maxValue * 2).times(anchor)); 221 | return result.dividedBy(this.positionScaleFactor).toDecimalPlaces(2).toNumber(); 222 | } 223 | 224 | _anchorDecode(value, maxValue, anchor, size) { 225 | const one = new Decimal(1); 226 | value = new Decimal(value); 227 | anchor = new Decimal(anchor); 228 | size = new Decimal(size); 229 | 230 | const z1 = value.minus(size.times(maxValue * 2).times(anchor)); 231 | const z2 = value.plus(size.times(maxValue * 2).times(one.minus(anchor))); 232 | const result = z1.plus(z2).dividedBy(2); 233 | return result.times(this.positionScaleFactor).toDecimalPlaces(0).toNumber(); 234 | } 235 | } 236 | 237 | function convertRange(value, fromRange, toRange) { // eslint-disable-line no-unused-vars 238 | if (typeof value === 'string') { 239 | value = parseFloat(value); 240 | } 241 | 242 | value = new Decimal(value); 243 | const fr0 = new Decimal(fromRange[0]); 244 | const fr1 = new Decimal(fromRange[1]); 245 | const tr0 = new Decimal(toRange[0]); 246 | const tr1 = new Decimal(toRange[1]); 247 | 248 | const result = ( 249 | value 250 | .minus(fr0) 251 | .times(tr1.minus(tr0).dividedBy(fr1.minus(fr0))) 252 | ).plus(tr0); 253 | 254 | return result.toDecimalPlaces(2).toNumber(); 255 | } 256 | 257 | customElements.define(AtemCompositionBoxProperties.is, AtemCompositionBoxProperties); 258 | })(); 259 | -------------------------------------------------------------------------------- /app/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "atem-compositor", 3 | "version": "1.1.2", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "argparse": { 8 | "version": "1.0.10", 9 | "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", 10 | "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", 11 | "requires": { 12 | "sprintf-js": "~1.0.2" 13 | } 14 | }, 15 | "atem-connection": { 16 | "version": "0.2.1", 17 | "resolved": "https://registry.npmjs.org/atem-connection/-/atem-connection-0.2.1.tgz", 18 | "integrity": "sha1-hHGm9vz0Z99kKMzNfqJ0PyeXs5A=", 19 | "requires": { 20 | "pngjs": "^3.3.2", 21 | "tslib": "^1.9.0" 22 | } 23 | }, 24 | "bluebird": { 25 | "version": "3.5.1", 26 | "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.1.tgz", 27 | "integrity": "sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA==" 28 | }, 29 | "bluebird-lst": { 30 | "version": "1.0.5", 31 | "resolved": "https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.5.tgz", 32 | "integrity": "sha512-Ey0bDNys5qpYPhZ/oQ9vOEvD0TYQDTILMXWP2iGfvMg7rSDde+oV4aQQgqRH+CvBFNz2BSDQnPGMUl6LKBUUQA==", 33 | "requires": { 34 | "bluebird": "^3.5.1" 35 | } 36 | }, 37 | "buffer-from": { 38 | "version": "1.1.0", 39 | "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.0.tgz", 40 | "integrity": "sha512-c5mRlguI/Pe2dSZmpER62rSCu0ryKmWddzRYsuXc50U2/g8jMOulc31VZMa4mYx31U5xsmSOpDCgH88Vl9cDGQ==" 41 | }, 42 | "builder-util-runtime": { 43 | "version": "4.4.0", 44 | "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-4.4.0.tgz", 45 | "integrity": "sha512-tkTF1o7XAX79ZkMo8822ZdQMpEBGSgfJ9kEYgyTAja90BPu7HO8C02pb8iSlFXfmK0Q0UA6D8MmnSNNPi0JLeg==", 46 | "requires": { 47 | "bluebird-lst": "^1.0.5", 48 | "debug": "^3.1.0", 49 | "fs-extra-p": "^4.6.1", 50 | "sax": "^1.2.4" 51 | }, 52 | "dependencies": { 53 | "debug": { 54 | "version": "3.1.0", 55 | "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", 56 | "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", 57 | "requires": { 58 | "ms": "2.0.0" 59 | } 60 | } 61 | } 62 | }, 63 | "debug": { 64 | "version": "2.6.9", 65 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", 66 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 67 | "requires": { 68 | "ms": "2.0.0" 69 | } 70 | }, 71 | "decimal.js": { 72 | "version": "10.0.1", 73 | "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.0.1.tgz", 74 | "integrity": "sha512-vklWB5C4Cj423xnaOtsUmAv0/7GqlXIgDv2ZKDyR64OV3OSzGHNx2mk4p/1EKnB5s70k73cIOOEcG9YzF0q4Lw==" 75 | }, 76 | "deep-equal": { 77 | "version": "1.0.1", 78 | "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", 79 | "integrity": "sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=" 80 | }, 81 | "electron-debug": { 82 | "version": "2.0.0", 83 | "resolved": "https://registry.npmjs.org/electron-debug/-/electron-debug-2.0.0.tgz", 84 | "integrity": "sha512-orGlw9uErUztdD7cgdKz78txq3czpOnKG/zvvsINkUsugqL+dn77UFrbwRGVgPwuLJ7Ejbjjk9EcxIcgTivMbA==", 85 | "requires": { 86 | "electron-is-dev": "^0.3.0", 87 | "electron-localshortcut": "^3.0.0" 88 | } 89 | }, 90 | "electron-is-accelerator": { 91 | "version": "0.1.2", 92 | "resolved": "https://registry.npmjs.org/electron-is-accelerator/-/electron-is-accelerator-0.1.2.tgz", 93 | "integrity": "sha1-UJ5RDCala1Xhf4Y6SwThEYRqsns=" 94 | }, 95 | "electron-is-dev": { 96 | "version": "0.3.0", 97 | "resolved": "https://registry.npmjs.org/electron-is-dev/-/electron-is-dev-0.3.0.tgz", 98 | "integrity": "sha1-FOb9pcaOnk7L7/nM8DfL18BcWv4=" 99 | }, 100 | "electron-localshortcut": { 101 | "version": "3.1.0", 102 | "resolved": "https://registry.npmjs.org/electron-localshortcut/-/electron-localshortcut-3.1.0.tgz", 103 | "integrity": "sha512-MgL/j5jdjW7iA0R6cI7S045B0GlKXWM1FjjujVPjlrmyXRa6yH0bGSaIAfxXAF9tpJm3pLEiQzerYHkRh9JG/A==", 104 | "requires": { 105 | "debug": "^2.6.8", 106 | "electron-is-accelerator": "^0.1.0", 107 | "keyboardevent-from-electron-accelerator": "^1.1.0", 108 | "keyboardevents-areequal": "^0.2.1" 109 | } 110 | }, 111 | "electron-log": { 112 | "version": "2.2.16", 113 | "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-2.2.16.tgz", 114 | "integrity": "sha512-0zFLIJ7lMuM/HBbsHDp57RevuyMJxyfMD8KnN5z0A7gmoWJR/iqX00z/aSjQAY2OGLK7I5TpgGYhEt9crt9Z1w==" 115 | }, 116 | "electron-updater": { 117 | "version": "2.23.3", 118 | "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-2.23.3.tgz", 119 | "integrity": "sha512-ZoQZpiEbhT3xA5Oxn7a5o4Z9adRaYs901pnTKBVBxPWmc0Qw5sZXAHkRjftlRmEn3RiEVkJtBPQSfx8kIkRcUA==", 120 | "requires": { 121 | "bluebird-lst": "^1.0.5", 122 | "builder-util-runtime": "~4.4.0", 123 | "electron-is-dev": "^0.3.0", 124 | "fs-extra-p": "^4.6.1", 125 | "js-yaml": "^3.12.0", 126 | "lazy-val": "^1.0.3", 127 | "lodash.isequal": "^4.5.0", 128 | "semver": "^5.5.0", 129 | "source-map-support": "^0.5.6" 130 | } 131 | }, 132 | "electron-window-state": { 133 | "version": "4.1.1", 134 | "resolved": "https://registry.npmjs.org/electron-window-state/-/electron-window-state-4.1.1.tgz", 135 | "integrity": "sha1-azT9wxs4UU3+yLfI97XUrdtnYy0=", 136 | "requires": { 137 | "deep-equal": "^1.0.1", 138 | "jsonfile": "^2.2.3", 139 | "mkdirp": "^0.5.1" 140 | } 141 | }, 142 | "esprima": { 143 | "version": "4.0.0", 144 | "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.0.tgz", 145 | "integrity": "sha512-oftTcaMu/EGrEIu904mWteKIv8vMuOgGYo7EhVJJN00R/EED9DCua/xxHRdYnKtcECzVg7xOWhflvJMnqcFZjw==" 146 | }, 147 | "fs-extra": { 148 | "version": "6.0.1", 149 | "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-6.0.1.tgz", 150 | "integrity": "sha512-GnyIkKhhzXZUWFCaJzvyDLEEgDkPfb4/TPvJCJVuS8MWZgoSsErf++QpiAlDnKFcqhRlm+tIOcencCjyJE6ZCA==", 151 | "requires": { 152 | "graceful-fs": "^4.1.2", 153 | "jsonfile": "^4.0.0", 154 | "universalify": "^0.1.0" 155 | }, 156 | "dependencies": { 157 | "jsonfile": { 158 | "version": "4.0.0", 159 | "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", 160 | "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", 161 | "requires": { 162 | "graceful-fs": "^4.1.6" 163 | } 164 | } 165 | } 166 | }, 167 | "fs-extra-p": { 168 | "version": "4.6.1", 169 | "resolved": "https://registry.npmjs.org/fs-extra-p/-/fs-extra-p-4.6.1.tgz", 170 | "integrity": "sha512-IsTMbUS0svZKZTvqF4vDS9c/L7Mw9n8nZQWWeSzAGacOSe+8CzowhUN0tdZEZFIJNP5HC7L9j3MMikz/G4hDeQ==", 171 | "requires": { 172 | "bluebird-lst": "^1.0.5", 173 | "fs-extra": "^6.0.1" 174 | } 175 | }, 176 | "get-port": { 177 | "version": "3.2.0", 178 | "resolved": "https://registry.npmjs.org/get-port/-/get-port-3.2.0.tgz", 179 | "integrity": "sha1-3Xzn3hh8Bsi/NTeWrHHgmfCYDrw=" 180 | }, 181 | "graceful-fs": { 182 | "version": "4.1.11", 183 | "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", 184 | "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=" 185 | }, 186 | "js-yaml": { 187 | "version": "3.12.0", 188 | "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.12.0.tgz", 189 | "integrity": "sha512-PIt2cnwmPfL4hKNwqeiuz4bKfnzHTBv6HyVgjahA6mPLwPDzjDWrplJBMjHUFxku/N3FlmrbyPclad+I+4mJ3A==", 190 | "requires": { 191 | "argparse": "^1.0.7", 192 | "esprima": "^4.0.0" 193 | } 194 | }, 195 | "jsonfile": { 196 | "version": "2.4.0", 197 | "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz", 198 | "integrity": "sha1-NzaitCi4e72gzIO1P6PWM6NcKug=", 199 | "requires": { 200 | "graceful-fs": "^4.1.6" 201 | } 202 | }, 203 | "keyboardevent-from-electron-accelerator": { 204 | "version": "1.1.0", 205 | "resolved": "https://registry.npmjs.org/keyboardevent-from-electron-accelerator/-/keyboardevent-from-electron-accelerator-1.1.0.tgz", 206 | "integrity": "sha512-VDC4vKWGrR3VgIKCE4CsXnvObGgP8C2idnTKEMUkuEuvDGE1GEBX9FtNdJzrD00iQlhI3xFxRaeItsUmlERVng==" 207 | }, 208 | "keyboardevents-areequal": { 209 | "version": "0.2.2", 210 | "resolved": "https://registry.npmjs.org/keyboardevents-areequal/-/keyboardevents-areequal-0.2.2.tgz", 211 | "integrity": "sha512-Nv+Kr33T0mEjxR500q+I6IWisOQ0lK1GGOncV0kWE6n4KFmpcu7RUX5/2B0EUtX51Cb0HjZ9VJsSY3u4cBa0kw==" 212 | }, 213 | "lazy-val": { 214 | "version": "1.0.3", 215 | "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.3.tgz", 216 | "integrity": "sha512-pjCf3BYk+uv3ZcPzEVM0BFvO9Uw58TmlrU0oG5tTrr9Kcid3+kdKxapH8CjdYmVa2nO5wOoZn2rdvZx2PKj/xg==" 217 | }, 218 | "lodash.isequal": { 219 | "version": "4.5.0", 220 | "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", 221 | "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=" 222 | }, 223 | "minimist": { 224 | "version": "0.0.8", 225 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", 226 | "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" 227 | }, 228 | "mkdirp": { 229 | "version": "0.5.1", 230 | "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", 231 | "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", 232 | "requires": { 233 | "minimist": "0.0.8" 234 | } 235 | }, 236 | "ms": { 237 | "version": "2.0.0", 238 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 239 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" 240 | }, 241 | "pngjs": { 242 | "version": "3.3.3", 243 | "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.3.3.tgz", 244 | "integrity": "sha512-1n3Z4p3IOxArEs1VRXnZ/RXdfEniAUS9jb68g58FIXMNkPJeZd+Qh4Uq7/e0LVxAQGos1eIUrqrt4FpjdnEd+Q==" 245 | }, 246 | "sax": { 247 | "version": "1.2.4", 248 | "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", 249 | "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" 250 | }, 251 | "semver": { 252 | "version": "5.5.0", 253 | "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz", 254 | "integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==" 255 | }, 256 | "source-map": { 257 | "version": "0.6.1", 258 | "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", 259 | "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" 260 | }, 261 | "source-map-support": { 262 | "version": "0.5.6", 263 | "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.6.tgz", 264 | "integrity": "sha512-N4KXEz7jcKqPf2b2vZF11lQIz9W5ZMuUcIOGj243lduidkf2fjkVKJS9vNxVWn3u/uxX38AcE8U9nnH9FPcq+g==", 265 | "requires": { 266 | "buffer-from": "^1.0.0", 267 | "source-map": "^0.6.0" 268 | } 269 | }, 270 | "sprintf-js": { 271 | "version": "1.0.3", 272 | "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", 273 | "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" 274 | }, 275 | "tinycolor2": { 276 | "version": "1.4.1", 277 | "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.1.tgz", 278 | "integrity": "sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g=" 279 | }, 280 | "tslib": { 281 | "version": "1.9.3", 282 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz", 283 | "integrity": "sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==" 284 | }, 285 | "universalify": { 286 | "version": "0.1.2", 287 | "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", 288 | "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==" 289 | } 290 | } 291 | } 292 | --------------------------------------------------------------------------------