├── .prettierignore ├── .husky └── pre-commit ├── .yarnrc.yml ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── companion-module-checks.yaml │ └── node.yaml ├── tsconfig.json ├── src ├── models │ ├── __tests__ │ │ └── model-generation.test.ts │ ├── miniproiso.ts │ ├── tvshd8iso.ts │ ├── miniextremeiso.ts │ ├── constellation4K1Me.ts │ ├── constellation4K2Me.ts │ ├── constellation4K4Me.ts │ ├── minipro.ts │ ├── sdiproiso.ts │ ├── sdi.ts │ ├── mini.ts │ ├── tvspro4k.ts │ ├── tvs.ts │ ├── miniextreme.ts │ ├── sdiextremeiso.ts │ ├── ps4k.ts │ ├── tvshd.ts │ ├── tvsprohd.ts │ ├── 1me.ts │ ├── types.ts │ ├── util │ │ └── fairlight.ts │ ├── 1me4k.ts │ ├── constellationHd2Me.ts │ ├── constellationHd1Me.ts │ ├── constellation8kas8k.ts │ ├── constellationHd4Me.ts │ ├── constellation4K4MePlus.ts │ ├── constellation8kAsHdOr4k.ts │ ├── 2me.ts │ ├── 2me4k.ts │ ├── tvs4k8.ts │ ├── miniextremeisog2.ts │ ├── tvshd8.ts │ ├── 4me4k.ts │ └── auto.ts ├── variables │ ├── timecode.ts │ ├── audioRouting.ts │ └── util.ts ├── feedback │ ├── timecode.ts │ ├── streaming.ts │ ├── recording.ts │ ├── mixeffect │ │ ├── fadeToBlack.ts │ │ └── tally.ts │ ├── FeedbackId.ts │ ├── aux-outputs.ts │ ├── macro.ts │ ├── index.ts │ ├── types.ts │ ├── dsk.ts │ ├── wrapper.ts │ └── mediaPlayer.ts ├── actions │ ├── mixeffect │ │ └── fadeToBlack.ts │ ├── cameraControl │ │ ├── display.ts │ │ └── media.ts │ ├── aux-outputs.ts │ ├── timecode.ts │ ├── types.ts │ ├── macro.ts │ ├── wrapper.ts │ ├── streaming.ts │ ├── settings.ts │ └── recording.ts ├── presets │ ├── recording.ts │ ├── aux-outputs.ts │ ├── streaming.ts │ ├── multiviewer.ts │ ├── index.ts │ ├── macro.ts │ ├── types.ts │ ├── wrapper.ts │ ├── mixeffect │ │ └── programPreview.ts │ ├── mediaPlayer.ts │ ├── fadeToBlack.ts │ ├── superSource.ts │ └── downstreamKeyer.ts ├── __tests__ │ └── util.test.ts ├── batching.ts ├── config.ts ├── state.ts ├── common.ts └── util.ts ├── tsconfig.build.json ├── .gitignore ├── eslint.config.mjs ├── .yarn └── patches │ └── @companion-module-tools-npm-2.4.2-17b2d4310e.patch ├── dump-model.ts ├── LICENSE ├── companion ├── manifest.json └── HELP.md └── package.json /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | yarn lint-staged 2 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: julusian 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "include": ["src/**/*.ts", "dump-model.ts"], 4 | "exclude": ["node_modules/**"], 5 | "compilerOptions": { 6 | // "types": ["jest", "node"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/models/__tests__/model-generation.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { ALL_MODELS } from '../index.js' 3 | 4 | for (const model of ALL_MODELS) { 5 | test(`Check ${model.label} (${model.id})`, () => { 6 | model.inputs.sort((a, b) => a.id - b.id) 7 | expect(model).toMatchSnapshot() 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /src/models/miniproiso.ts: -------------------------------------------------------------------------------- 1 | import type { ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | 4 | import { ModelSpecMiniPro } from './minipro.js' 5 | 6 | export const ModelSpecMiniProISO: ModelSpec = { 7 | ...ModelSpecMiniPro, 8 | id: Enums.Model.MiniProISO, 9 | label: 'Mini Pro ISO', 10 | recordISO: true, 11 | } 12 | -------------------------------------------------------------------------------- /src/models/tvshd8iso.ts: -------------------------------------------------------------------------------- 1 | import type { ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | 4 | import { ModelSpecTVSHD8 } from './tvshd8.js' 5 | 6 | export const ModelSpecTVSHD8ISO: ModelSpec = { 7 | ...ModelSpecTVSHD8, 8 | id: Enums.Model.TelevisionStudioHD8ISO, 9 | label: 'Television Studio HD8 ISO', 10 | recordISO: true, 11 | } 12 | -------------------------------------------------------------------------------- /src/models/miniextremeiso.ts: -------------------------------------------------------------------------------- 1 | import type { ModelSpec } from './types.js' 2 | import { ModelSpecMiniExtreme } from './miniextreme.js' 3 | import { Enums } from 'atem-connection' 4 | 5 | export const ModelSpecMiniExtremeISO: ModelSpec = { 6 | ...ModelSpecMiniExtreme, 7 | id: Enums.Model.MiniExtremeISO, 8 | label: 'Mini Extreme ISO', 9 | recordISO: true, 10 | } 11 | -------------------------------------------------------------------------------- /src/models/constellation4K1Me.ts: -------------------------------------------------------------------------------- 1 | import type { ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { ModelSpecConstellationHD1ME } from './constellationHd1Me.js' 4 | 5 | export const ModelSpecConstellation4K1ME: ModelSpec = { 6 | ...ModelSpecConstellationHD1ME, 7 | id: Enums.Model.Constellation4K1ME, 8 | label: '1 M/E Constellation 4K', 9 | } 10 | -------------------------------------------------------------------------------- /src/models/constellation4K2Me.ts: -------------------------------------------------------------------------------- 1 | import type { ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { ModelSpecConstellationHD2ME } from './constellationHd2Me.js' 4 | 5 | export const ModelSpecConstellation4K2ME: ModelSpec = { 6 | ...ModelSpecConstellationHD2ME, 7 | id: Enums.Model.Constellation4K2ME, 8 | label: '2 M/E Constellation 4K', 9 | } 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'github-actions' 4 | directory: '/' 5 | schedule: 6 | interval: 'weekly' 7 | 8 | - package-ecosystem: 'npm' 9 | directory: '/' 10 | schedule: 11 | interval: 'weekly' 12 | # Disable version updates for npm dependencies (we only want security updates) 13 | open-pull-requests-limit: 0 14 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@companion-module/tools/tsconfig/node18/recommended", 3 | "include": ["src/**/*.ts"], 4 | "exclude": ["node_modules/**", "src/**/*spec.ts", "src/**/__tests__/*", "src/**/__mocks__/*"], 5 | "compilerOptions": { 6 | "outDir": "./dist", 7 | "baseUrl": "./", 8 | "paths": { 9 | "*": ["./node_modules/*"] 10 | }, 11 | "module": "es2020", 12 | "verbatimModuleSyntax": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/models/constellation4K4Me.ts: -------------------------------------------------------------------------------- 1 | import type { ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { ModelSpecConstellationHD4ME } from './constellationHd4Me.js' 4 | 5 | export const ModelSpecConstellation4K4ME: ModelSpec = { 6 | ...ModelSpecConstellationHD4ME, 7 | id: Enums.Model.Constellation4K4ME, 8 | label: '4 M/E Constellation 4K', 9 | media: { 10 | players: 4, 11 | stills: 64, 12 | clips: 4, 13 | captureStills: true, 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .yarn/* 2 | !.yarn/patches 3 | !.yarn/plugins 4 | !.yarn/releases 5 | !.yarn/sdks 6 | !.yarn/versions 7 | 8 | # Swap the comments on the following lines if you wish to use zero-installs 9 | # In that case, don't forget to run `yarn config set enableGlobalCache false`! 10 | # Documentation here: https://yarnpkg.com/features/caching#zero-installs 11 | 12 | #!.yarn/cache 13 | .pnp.* 14 | /node_modules 15 | 16 | dist/ 17 | *.log 18 | state.json 19 | raw-state.json 20 | test.json 21 | pkg 22 | /*.tgz 23 | DEBUG-* 24 | /.vscode 25 | -------------------------------------------------------------------------------- /.github/workflows/companion-module-checks.yaml: -------------------------------------------------------------------------------- 1 | name: Companion Module Checks 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | check: 8 | name: Check module 9 | 10 | if: ${{ !contains(github.repository, 'companion-module-template-') }} 11 | 12 | permissions: 13 | packages: read 14 | 15 | uses: bitfocus/actions/.github/workflows/module-checks.yaml@main 16 | # with: 17 | # upload-artifact: true # uncomment this to upload the built package as an artifact to this workflow that you can download and share with others 18 | 19 | -------------------------------------------------------------------------------- /src/models/minipro.ts: -------------------------------------------------------------------------------- 1 | import type { ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { ModelSpecMini } from './mini.js' 4 | 5 | export const ModelSpecMiniPro: ModelSpec = { 6 | ...ModelSpecMini, 7 | id: Enums.Model.MiniPro, 8 | label: 'Mini Pro', 9 | MVs: 1, 10 | streaming: true, 11 | recording: true, 12 | inputs: [ 13 | ...ModelSpecMini.inputs, 14 | { 15 | id: 9001, 16 | portType: Enums.InternalPortType.MultiViewer, 17 | sourceAvailability: Enums.SourceAvailability.Auxiliary, 18 | meAvailability: Enums.MeAvailability.None, 19 | }, 20 | ], 21 | } 22 | -------------------------------------------------------------------------------- /src/models/sdiproiso.ts: -------------------------------------------------------------------------------- 1 | import type { ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | 4 | import { ModelSpecSDI } from './sdi.js' 5 | 6 | export const ModelSpecSDIProISO: ModelSpec = { 7 | ...ModelSpecSDI, 8 | id: Enums.Model.SDIProISO, 9 | label: 'SDI Pro ISO', 10 | MVs: 1, 11 | streaming: true, 12 | recording: true, 13 | recordISO: true, 14 | inputs: [ 15 | ...ModelSpecSDI.inputs, 16 | { 17 | id: 9001, 18 | portType: Enums.InternalPortType.MultiViewer, 19 | sourceAvailability: Enums.SourceAvailability.Auxiliary, 20 | meAvailability: Enums.MeAvailability.None, 21 | }, 22 | ], 23 | } 24 | -------------------------------------------------------------------------------- /src/variables/timecode.ts: -------------------------------------------------------------------------------- 1 | import type { CompanionVariableValues } from '@companion-module/base' 2 | import type { AtemConfig } from '../config.js' 3 | import type { InstanceBaseExt } from '../util.js' 4 | import type { AtemState } from 'atem-connection' 5 | import { formatDurationSeconds } from './util.js' 6 | 7 | export function updateTimecodeVariables( 8 | instance: InstanceBaseExt, 9 | _state: AtemState, 10 | values: CompanionVariableValues, 11 | ): void { 12 | values['timecode'] = formatDurationSeconds(instance.timecodeSeconds).hms 13 | // values['timecode_ms'] = formatDurationSeconds(instance.timecodeSeconds).hms 14 | values['display_clock'] = formatDurationSeconds(instance.displayClockSeconds).hms 15 | } 16 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { generateEslintConfig } from '@companion-module/tools/eslint/config.mjs' 2 | 3 | const baseConfig = await generateEslintConfig({ 4 | enableTypescript: true, 5 | }) 6 | 7 | const customConfig = [ 8 | ...baseConfig, 9 | 10 | { 11 | rules: { 12 | '@typescript-eslint/no-unsafe-enum-comparison': 'off', 13 | // misconfiguration of ts or something? 14 | 'n/no-missing-import': 'off', 15 | // 'm/no-unpublished-import': 'off', 16 | }, 17 | }, 18 | 19 | { 20 | files: ['**/__tests__/**/*'], 21 | rules: { 22 | 'n/no-unpublished-import': [ 23 | 'error', 24 | { 25 | allowModules: ['vitest'], 26 | }, 27 | ], 28 | }, 29 | }, 30 | ] 31 | 32 | export default customConfig 33 | -------------------------------------------------------------------------------- /.yarn/patches/@companion-module-tools-npm-2.4.2-17b2d4310e.patch: -------------------------------------------------------------------------------- 1 | diff --git a/scripts/build.js b/scripts/build.js 2 | index 51d385fb10cf2c70ddc69300c62dc5af0190cfcb..2a233f41b36c40eae0cfd91151ab56321d0c031c 100755 3 | --- a/scripts/build.js 4 | +++ b/scripts/build.js 5 | @@ -231,6 +231,12 @@ if (fs.existsSync(prebuildDirName)) { 6 | } 7 | } 8 | 9 | +// Some hacks that need formalizing.. 10 | +const freetypeVendorDir = path.join(packageBaseDir, 'node_modules', '@julusian', 'freetype2', 'vendor') 11 | +if (fs.existsSync(freetypeVendorDir)) { 12 | + await fs.rm(freetypeVendorDir, { recursive: true, force: true }) 13 | +} 14 | + 15 | // Create tgz of the build 16 | let tgzFile = toSanitizedDirname(`${manifestJson.id}-${manifestJson.version}`) 17 | if (typeof argv['output'] === 'string') { 18 | -------------------------------------------------------------------------------- /dump-model.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable n/no-process-exit */ 2 | import * as fs from 'fs' 3 | import Atem from 'atem-connection' 4 | import { GetParsedModelSpec } from './src/models/index.js' 5 | 6 | const args = process.argv.slice(2) 7 | if (args.length < 1) { 8 | console.log('Usage: yarn tsx dump-model.ts ') 9 | console.log('eg: yarn tsx dump-model.ts 10.42.13.99') 10 | process.exit() 11 | } 12 | 13 | const atem = new Atem.Atem({ 14 | address: args[0], 15 | port: 9910, 16 | }) 17 | atem.on('disconnected', () => { 18 | console.log('disconnect') 19 | process.exit(1) 20 | }) 21 | 22 | atem.on('connected', () => { 23 | if (!atem.state) throw new Error('No state once connected!') 24 | 25 | fs.writeFileSync('raw-state.json', JSON.stringify(atem.state, undefined, 4)) 26 | 27 | const model = GetParsedModelSpec(atem.state) 28 | fs.writeFileSync('state.json', JSON.stringify(model, undefined, 4)) 29 | console.log('done') 30 | process.exit(0) 31 | }) 32 | 33 | atem.connect(args[0]).catch(console.error) 34 | console.log('connecting') 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2018 Bitfocus AS, William Viker & Håkon Nessjøen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /src/variables/audioRouting.ts: -------------------------------------------------------------------------------- 1 | import type { CompanionVariableValues } from '@companion-module/base' 2 | import type { AtemState } from 'atem-connection' 3 | import { formatAudioRoutingAsString } from '../util.js' 4 | 5 | export function updateFairlightAudioRoutingSourceVariables( 6 | state: AtemState, 7 | sourceId: number, 8 | values: CompanionVariableValues, 9 | ): void { 10 | const stringId = formatAudioRoutingAsString(sourceId) 11 | const sourceState = state.fairlight?.audioRouting?.sources?.[sourceId] 12 | 13 | values[`audio_routing_source_${stringId}_name`] = sourceState?.name 14 | } 15 | 16 | export function updateFairlightAudioRoutingOutputVariables( 17 | state: AtemState, 18 | outputId: number, 19 | values: CompanionVariableValues, 20 | ): void { 21 | const stringId = formatAudioRoutingAsString(outputId) 22 | const outputState = state.fairlight?.audioRouting?.outputs?.[outputId] 23 | 24 | values[`audio_routing_destinations_${stringId}_name`] = outputState?.name 25 | 26 | const sourceState = outputState && state.fairlight?.audioRouting?.sources?.[outputState?.sourceId] 27 | 28 | values[`audio_routing_destinations_${stringId}_source`] = outputState?.sourceId 29 | values[`audio_routing_destinations_${stringId}_source_name`] = sourceState?.name 30 | } 31 | -------------------------------------------------------------------------------- /companion/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@companion-module/base/assets/manifest.schema.json", 3 | "id": "bmd-atem", 4 | "name": "bmd-atem", 5 | "shortname": "atem", 6 | "description": "Module for controlling all models of Blackmagic Design ATEM", 7 | "version": "0.0.0", 8 | "license": "MIT", 9 | "repository": "git+https://github.com/bitfocus/companion-module-bmd-atem.git", 10 | "bugs": "https://github.com/bitfocus/companion-module-bmd-atem/issues", 11 | "maintainers": [ 12 | { 13 | "name": "Julian Waller", 14 | "email": "julian@superfly.tv" 15 | } 16 | ], 17 | "legacyIds": ["atem"], 18 | "runtime": { 19 | "type": "node22", 20 | "api": "nodejs-ipc", 21 | "apiVersion": "0.0.0", 22 | "entrypoint": "../dist/index.js", 23 | "permissions": { 24 | "native-addons": true, 25 | "worker-threads": true 26 | } 27 | }, 28 | "manufacturer": "Blackmagic Design", 29 | "products": ["ATEM"], 30 | "keywords": ["Vision Mixer"], 31 | "bonjourQueries": { 32 | "bonjourHost": [ 33 | { 34 | "type": "blackmagic", 35 | "protocol": "tcp", 36 | "txt": { 37 | "class": "AtemSwitcher" 38 | } 39 | }, 40 | { 41 | "type": "switcher_ctrl", 42 | "protocol": "udp", 43 | "txt": {} 44 | } 45 | ] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/variables/util.ts: -------------------------------------------------------------------------------- 1 | import type { Timecode } from 'atem-connection/dist/state/common.js' 2 | import { pad } from '../util.js' 3 | 4 | export function formatDuration(durationObj: Timecode | undefined): { hms: string; hm: string; ms: string } { 5 | let durationHMS = '00:00:00' 6 | let durationHM = '00:00' 7 | let durationMS = '00:00' 8 | 9 | if (durationObj) { 10 | durationHM = `${pad(`${durationObj.hours}`, '0', 2)}:${pad(`${durationObj.minutes}`, '0', 2)}` 11 | durationHMS = `${durationHM}:${pad(`${durationObj.seconds}`, '0', 2)}` 12 | durationMS = `${durationObj.hours * 60 + durationObj.minutes}:${pad(`${durationObj.seconds}`, '0', 2)}` 13 | } 14 | 15 | return { hm: durationHM, hms: durationHMS, ms: durationMS } 16 | } 17 | export function formatDurationSeconds(totalSeconds: number | undefined): { hms: string; hm: string; ms: string } { 18 | let timecode: Timecode | undefined 19 | 20 | if (totalSeconds) { 21 | timecode = { 22 | hours: 0, 23 | minutes: 0, 24 | seconds: 0, 25 | frames: 0, 26 | isDropFrame: false, 27 | } 28 | 29 | timecode.seconds = totalSeconds % 60 30 | totalSeconds = Math.floor(totalSeconds / 60) 31 | timecode.minutes = totalSeconds % 60 32 | totalSeconds = Math.floor(totalSeconds / 60) 33 | timecode.hours = totalSeconds 34 | } 35 | 36 | return formatDuration(timecode) 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/node.yaml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | tags: 8 | - 'v[0-9]+.[0-9]+.[0-9]+*' 9 | pull_request: 10 | 11 | jobs: 12 | lint: 13 | name: Lint 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 15 16 | 17 | steps: 18 | - uses: actions/checkout@v6 19 | - name: Use Node.js 22.x 20 | uses: actions/setup-node@v6 21 | with: 22 | node-version: 22.x 23 | - name: Prepare Environment 24 | run: | 25 | corepack enable 26 | yarn install 27 | yarn build 28 | env: 29 | CI: true 30 | - name: Run lint 31 | run: | 32 | yarn lint 33 | env: 34 | CI: true 35 | 36 | test: 37 | name: Test 38 | runs-on: ubuntu-latest 39 | timeout-minutes: 15 40 | 41 | steps: 42 | - uses: actions/checkout@v6 43 | - name: Use Node.js 22.x 44 | uses: actions/setup-node@v6 45 | with: 46 | node-version: 22.x 47 | - name: Prepare Environment 48 | run: | 49 | corepack enable 50 | yarn install 51 | env: 52 | CI: true 53 | - name: Run tests 54 | run: | 55 | yarn test 56 | env: 57 | CI: true 58 | - name: Send coverage 59 | uses: codecov/codecov-action@v5 60 | -------------------------------------------------------------------------------- /src/models/sdi.ts: -------------------------------------------------------------------------------- 1 | import { generateOutputs, type ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { AUDIO_FAIRLIGHT_INPUT_MINI_TS_JACKS, generateFairlightInputsOfType } from './util/fairlight.js' 4 | import { VideoInputGenerator } from './util/videoInput.js' 5 | 6 | export const ModelSpecSDI: ModelSpec = { 7 | id: Enums.Model.SDI, 8 | label: 'SDI', 9 | outputs: [ 10 | ...generateOutputs('Output', 2), 11 | { 12 | id: 2, 13 | name: 'Webcam (3)', 14 | }, 15 | ], 16 | MEs: 1, 17 | USKs: 1, 18 | DSKs: 1, 19 | MVs: 0, 20 | multiviewerFullGrid: false, 21 | DVEs: 1, 22 | SSrc: 0, 23 | macros: 100, 24 | displayClock: 0, 25 | media: { 26 | players: 1, 27 | stills: 20, 28 | clips: 0, 29 | captureStills: true, 30 | }, 31 | streaming: false, 32 | recording: false, 33 | recordISO: false, 34 | inputs: VideoInputGenerator.begin({ 35 | meCount: 1, 36 | baseSourceAvailability: Enums.SourceAvailability.Auxiliary, 37 | }) 38 | .addInternalColorsAndBlack(true) 39 | .addExternalInputs(4) 40 | .addMediaPlayers(1, true) 41 | .addAuxiliaryOutputs(2) 42 | .addProgramPreview() 43 | .addDirectInputForAux(1) 44 | .generate(), 45 | fairlightAudio: { 46 | monitor: null, 47 | inputs: [ 48 | ...generateFairlightInputsOfType(1, 4, Enums.ExternalPortType.SDI), 49 | ...AUDIO_FAIRLIGHT_INPUT_MINI_TS_JACKS, 50 | ], 51 | }, 52 | } 53 | -------------------------------------------------------------------------------- /src/models/mini.ts: -------------------------------------------------------------------------------- 1 | import { generateOutputs, type ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { AUDIO_FAIRLIGHT_INPUT_MINI_TS_JACKS, generateFairlightInputsOfType } from './util/fairlight.js' 4 | import { VideoInputGenerator } from './util/videoInput.js' 5 | 6 | export const ModelSpecMini: ModelSpec = { 7 | id: Enums.Model.Mini, 8 | label: 'Mini', 9 | outputs: [ 10 | ...generateOutputs('Output', 1), 11 | { 12 | id: 1, 13 | name: 'Webcam (2)', 14 | }, 15 | ], 16 | MEs: 1, 17 | USKs: 1, 18 | DSKs: 1, 19 | MVs: 0, 20 | multiviewerFullGrid: false, 21 | DVEs: 1, 22 | SSrc: 0, 23 | macros: 100, 24 | displayClock: 0, 25 | media: { 26 | players: 1, 27 | stills: 20, 28 | clips: 0, 29 | captureStills: true, 30 | }, 31 | streaming: false, 32 | recording: false, 33 | recordISO: false, 34 | inputs: VideoInputGenerator.begin({ 35 | meCount: 1, 36 | baseSourceAvailability: Enums.SourceAvailability.Auxiliary, 37 | }) 38 | .addInternalColorsAndBlack(true) 39 | .addExternalInputs(4) 40 | .addMediaPlayers(1, true) 41 | .addAuxiliaryOutputs(1) 42 | .addProgramPreview() 43 | .addDirectInputForAux(1) 44 | .generate(), 45 | fairlightAudio: { 46 | monitor: null, 47 | inputs: [ 48 | ...generateFairlightInputsOfType(1, 4, Enums.ExternalPortType.HDMI), 49 | ...AUDIO_FAIRLIGHT_INPUT_MINI_TS_JACKS, 50 | ], 51 | }, 52 | } 53 | -------------------------------------------------------------------------------- /src/models/tvspro4k.ts: -------------------------------------------------------------------------------- 1 | import { generateOutputs, type ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { 4 | AUDIO_FAIRLIGHT_INPUT_TS_JACK, 5 | AUDIO_FAIRLIGHT_INPUT_XLR, 6 | generateFairlightInputMediaPlayer, 7 | generateFairlightInputsOfType, 8 | } from './util/fairlight.js' 9 | import { VideoInputGenerator } from './util/videoInput.js' 10 | 11 | export const ModelSpecTVSPro4K: ModelSpec = { 12 | id: Enums.Model.TVSPro4K, 13 | label: 'TV Studio Pro 4K', 14 | outputs: generateOutputs('Aux', 1), 15 | MEs: 1, 16 | USKs: 1, 17 | DSKs: 2, 18 | MVs: 1, 19 | multiviewerFullGrid: false, 20 | DVEs: 1, 21 | SSrc: 0, 22 | macros: 100, 23 | displayClock: 0, 24 | media: { 25 | players: 2, 26 | stills: 20, 27 | clips: 2, 28 | captureStills: false, 29 | }, 30 | streaming: false, 31 | recording: false, 32 | recordISO: false, 33 | inputs: VideoInputGenerator.begin({ 34 | meCount: 1, 35 | baseSourceAvailability: Enums.SourceAvailability.Auxiliary | Enums.SourceAvailability.Multiviewer, 36 | }) 37 | .addInternalColorsAndBlack() 38 | .addExternalInputs(8) 39 | .addMediaPlayers(2) 40 | .addUpstreamKeyMasks(1) 41 | .addDownstreamKeyMasksAndClean(2) 42 | .addAuxiliaryOutputs(1) 43 | .addProgramPreview() 44 | .generate(), 45 | fairlightAudio: { 46 | monitor: 'combined', 47 | inputs: [ 48 | ...generateFairlightInputsOfType(1, 8, Enums.ExternalPortType.SDI), 49 | AUDIO_FAIRLIGHT_INPUT_XLR, 50 | AUDIO_FAIRLIGHT_INPUT_TS_JACK, 51 | ...generateFairlightInputMediaPlayer(2), 52 | ], 53 | }, 54 | } 55 | -------------------------------------------------------------------------------- /src/models/tvs.ts: -------------------------------------------------------------------------------- 1 | import { generateOutputs, type ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { VideoInputGenerator } from './util/videoInput.js' 4 | 5 | export const ModelSpecTVS: ModelSpec = { 6 | id: Enums.Model.TVS, 7 | label: 'TV Studio', 8 | outputs: generateOutputs('Aux', 1), 9 | MEs: 1, 10 | USKs: 1, 11 | DSKs: 2, 12 | MVs: 1, 13 | multiviewerFullGrid: false, 14 | DVEs: 0, 15 | SSrc: 0, 16 | macros: 100, 17 | displayClock: 0, 18 | media: { 19 | players: 2, 20 | stills: 20, 21 | clips: 0, 22 | captureStills: false, 23 | }, 24 | streaming: false, 25 | recording: false, 26 | recordISO: false, 27 | inputs: VideoInputGenerator.begin({ 28 | meCount: 1, 29 | baseSourceAvailability: Enums.SourceAvailability.Multiviewer, 30 | }) 31 | .addInternalColorsAndBlack() 32 | .addExternalInputs(6) 33 | .addMediaPlayers(2) 34 | .addCleanFeeds(2) 35 | .addProgramPreview() 36 | .generate(), 37 | classicAudio: { 38 | inputs: [ 39 | { 40 | id: 1, 41 | portType: Enums.ExternalPortType.HDMI, 42 | }, 43 | { 44 | id: 2, 45 | portType: Enums.ExternalPortType.HDMI, 46 | }, 47 | { 48 | id: 3, 49 | portType: Enums.ExternalPortType.HDMI, 50 | }, 51 | { 52 | id: 4, 53 | portType: Enums.ExternalPortType.HDMI, 54 | }, 55 | { 56 | id: 5, 57 | portType: Enums.ExternalPortType.SDI, 58 | }, 59 | { 60 | id: 6, 61 | portType: Enums.ExternalPortType.SDI, 62 | }, 63 | { 64 | id: 1101, 65 | portType: Enums.ExternalPortType.AESEBU, 66 | }, 67 | ], 68 | }, 69 | } 70 | -------------------------------------------------------------------------------- /src/feedback/timecode.ts: -------------------------------------------------------------------------------- 1 | import type { ModelSpec } from '../models/index.js' 2 | import type { MyFeedbackDefinitions } from './types.js' 3 | import { FeedbackId } from './FeedbackId.js' 4 | import { combineRgb } from '@companion-module/base' 5 | import type { StateWrapper } from '../state.js' 6 | import type { AtemConfig } from '../config.js' 7 | import { Enums } from 'atem-connection' 8 | 9 | export interface AtemTimecodeFeedbacks { 10 | [FeedbackId.TimecodeMode]: { 11 | mode: Enums.TimeMode 12 | } 13 | } 14 | 15 | export function createTimecodeFeedbacks( 16 | config: AtemConfig, 17 | _model: ModelSpec, 18 | state: StateWrapper, 19 | ): MyFeedbackDefinitions { 20 | if (!config.pollTimecode) { 21 | return { 22 | [FeedbackId.TimecodeMode]: undefined, 23 | } 24 | } 25 | return { 26 | [FeedbackId.TimecodeMode]: { 27 | type: 'boolean', 28 | name: 'Timecode: Mode', 29 | description: 'If the timecode mode is as specified', 30 | options: { 31 | mode: { 32 | id: 'mode', 33 | type: 'dropdown', 34 | label: 'Mode', 35 | choices: [ 36 | { id: Enums.TimeMode.FreeRun, label: 'Free run' }, 37 | { id: Enums.TimeMode.TimeOfDay, label: 'Time of Day' }, 38 | ], 39 | default: Enums.TimeMode.FreeRun, 40 | }, 41 | }, 42 | defaultStyle: { 43 | color: combineRgb(0, 0, 0), 44 | bgcolor: combineRgb(255, 255, 0), 45 | }, 46 | callback: ({ options }): boolean => { 47 | return state.state.settings.timeMode === options.getPlainNumber('mode') 48 | }, 49 | learn: ({ options }) => { 50 | return { 51 | ...options.getJson(), 52 | mode: state.state.settings.timeMode ?? Enums.TimeMode.FreeRun, 53 | } 54 | }, 55 | }, 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/actions/mixeffect/fadeToBlack.ts: -------------------------------------------------------------------------------- 1 | import { type Atem } from 'atem-connection' 2 | import { AtemMEPicker, AtemRatePicker } from '../../input.js' 3 | import type { ModelSpec } from '../../models/index.js' 4 | import { ActionId } from '../ActionId.js' 5 | import type { MyActionDefinitions } from './../types.js' 6 | import { getMixEffect, type StateWrapper } from '../../state.js' 7 | 8 | export interface AtemFadeToBlackActions { 9 | [ActionId.FadeToBlackAuto]: { 10 | mixeffect: number 11 | } 12 | [ActionId.FadeToBlackRate]: { 13 | mixeffect: number 14 | rate: number 15 | } 16 | } 17 | 18 | export function createFadeToBlackActions( 19 | atem: Atem | undefined, 20 | model: ModelSpec, 21 | state: StateWrapper, 22 | ): MyActionDefinitions { 23 | return { 24 | [ActionId.FadeToBlackAuto]: { 25 | name: 'Fade to black: Run AUTO Transition', 26 | options: { 27 | mixeffect: AtemMEPicker(model, 0), 28 | }, 29 | callback: async ({ options }) => { 30 | await atem?.fadeToBlack(options.getPlainNumber('mixeffect')) 31 | }, 32 | }, 33 | [ActionId.FadeToBlackRate]: { 34 | name: 'Fade to black: Change rate', 35 | options: { 36 | mixeffect: AtemMEPicker(model, 0), 37 | rate: AtemRatePicker('Rate'), 38 | }, 39 | callback: async ({ options }) => { 40 | await atem?.setFadeToBlackRate(options.getPlainNumber('rate'), options.getPlainNumber('mixeffect')) 41 | }, 42 | learn: ({ options }) => { 43 | const me = getMixEffect(state.state, options.getPlainNumber('mixeffect')) 44 | 45 | if (me?.fadeToBlack) { 46 | return { 47 | ...options.getJson(), 48 | rate: me.fadeToBlack.rate, 49 | } 50 | } else { 51 | return undefined 52 | } 53 | }, 54 | }, 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/models/miniextreme.ts: -------------------------------------------------------------------------------- 1 | import { generateOutputs, type ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { AUDIO_FAIRLIGHT_INPUT_MINI_TS_JACKS, generateFairlightInputsOfType } from './util/fairlight.js' 4 | import { VideoInputGenerator } from './util/videoInput.js' 5 | 6 | export const ModelSpecMiniExtreme: ModelSpec = { 7 | id: Enums.Model.MiniExtreme, 8 | label: 'Mini Extreme', 9 | inputs: VideoInputGenerator.begin({ 10 | meCount: 1, 11 | baseSourceAvailability: 12 | Enums.SourceAvailability.Auxiliary | 13 | Enums.SourceAvailability.Multiviewer | 14 | Enums.SourceAvailability.SuperSourceBox | 15 | Enums.SourceAvailability.SuperSourceArt | 16 | Enums.SourceAvailability.Auxiliary1 | 17 | Enums.SourceAvailability.Auxiliary2, 18 | }) 19 | .addInternalColorsAndBlack() 20 | .addExternalInputs(8) 21 | .addMediaPlayers(2) 22 | .addSuperSource() 23 | .addCleanFeeds(1) 24 | .addAuxiliaryOutputs(2) 25 | .addProgramPreview() 26 | .addDirectInputForAux(2) 27 | .addMultiviewers(1) 28 | .addMultiviewerStatusSources() 29 | .generate(), 30 | outputs: [ 31 | ...generateOutputs('Output', 2), 32 | { 33 | id: 2, 34 | name: 'Webcam (3)', 35 | }, 36 | ], 37 | MEs: 1, 38 | USKs: 4, 39 | DSKs: 2, 40 | MVs: 1, 41 | multiviewerFullGrid: true, 42 | DVEs: 2, 43 | SSrc: 1, 44 | macros: 100, 45 | displayClock: 0, 46 | media: { 47 | players: 2, 48 | stills: 20, 49 | clips: 0, 50 | captureStills: true, 51 | }, 52 | streaming: true, 53 | recording: true, 54 | recordISO: false, 55 | fairlightAudio: { 56 | monitor: 'split', 57 | inputs: [ 58 | ...generateFairlightInputsOfType(1, 8, Enums.ExternalPortType.HDMI), 59 | ...AUDIO_FAIRLIGHT_INPUT_MINI_TS_JACKS, 60 | ], 61 | }, 62 | } 63 | -------------------------------------------------------------------------------- /src/models/sdiextremeiso.ts: -------------------------------------------------------------------------------- 1 | import { generateOutputs, type ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { AUDIO_FAIRLIGHT_INPUT_MINI_TS_JACKS, generateFairlightInputsOfType } from './util/fairlight.js' 4 | import { VideoInputGenerator } from './util/videoInput.js' 5 | 6 | export const ModelSpecSDIExtremeISO: ModelSpec = { 7 | id: Enums.Model.SDIExtremeISO, 8 | label: 'SDI Extreme ISO', 9 | inputs: VideoInputGenerator.begin({ 10 | meCount: 1, 11 | baseSourceAvailability: 12 | Enums.SourceAvailability.Auxiliary | 13 | Enums.SourceAvailability.Multiviewer | 14 | Enums.SourceAvailability.SuperSourceBox | 15 | Enums.SourceAvailability.SuperSourceArt | 16 | Enums.SourceAvailability.Auxiliary1 | 17 | Enums.SourceAvailability.Auxiliary2, 18 | }) 19 | .addInternalColorsAndBlack() 20 | .addExternalInputs(8) 21 | .addMediaPlayers(2) 22 | .addSuperSource() 23 | .addCleanFeeds(1) 24 | .addAuxiliaryOutputs(4) 25 | .addProgramPreview() 26 | .addDirectInputForAux(2) 27 | .addMultiviewers(1) 28 | .addMultiviewerStatusSources() 29 | .generate(), 30 | outputs: [ 31 | ...generateOutputs('Output', 4), 32 | { 33 | id: 4, 34 | name: 'Webcam (5)', 35 | }, 36 | ], 37 | MEs: 1, 38 | USKs: 4, 39 | DSKs: 2, 40 | MVs: 1, 41 | multiviewerFullGrid: true, 42 | DVEs: 2, 43 | SSrc: 1, 44 | macros: 100, 45 | displayClock: 0, 46 | media: { 47 | players: 2, 48 | stills: 20, 49 | clips: 0, 50 | captureStills: true, 51 | }, 52 | streaming: true, 53 | recording: true, 54 | recordISO: true, 55 | fairlightAudio: { 56 | monitor: 'split', 57 | inputs: [ 58 | ...generateFairlightInputsOfType(1, 8, Enums.ExternalPortType.SDI), 59 | ...AUDIO_FAIRLIGHT_INPUT_MINI_TS_JACKS, 60 | ], 61 | }, 62 | } 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bmd-atem", 3 | "version": "3.20.1", 4 | "homepage": "https://github.com/bitfocus/companion-module-atem#readme", 5 | "main": "dist/index.js", 6 | "type": "module", 7 | "scripts": { 8 | "postinstall": "husky", 9 | "dev": "run build:main --watch", 10 | "build": "rimraf dist && yarn build:main", 11 | "build:main": "tsc -p tsconfig.build.json", 12 | "lint:raw": "eslint", 13 | "lint": "yarn lint:raw .", 14 | "dist": "yarn companion-module-build", 15 | "test": "vitest" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/bitfocus/companion-module-bmd-atem.git" 20 | }, 21 | "license": "MIT", 22 | "prettier": "@companion-module/tools/.prettierrc.json", 23 | "lint-staged": { 24 | "*.{css,json,md,scss}": [ 25 | "run prettier --write" 26 | ], 27 | "*.{ts,tsx,js,jsx}": [ 28 | "run lint:raw --fix" 29 | ] 30 | }, 31 | "engines": { 32 | "node": "^22.2" 33 | }, 34 | "dependencies": { 35 | "@atem-connection/camera-control": "^0.3.0", 36 | "@atem-connection/image-tools": "^1.1.1", 37 | "@companion-module/base": "~1.13.5", 38 | "@julusian/image-rs": "^2.1.1", 39 | "atem-connection": "3.8.0", 40 | "lodash-es": "^4.17.22", 41 | "type-fest": "^5.3.1" 42 | }, 43 | "devDependencies": { 44 | "@companion-module/tools": "patch:@companion-module/tools@npm%3A2.4.2#~/.yarn/patches/@companion-module-tools-npm-2.4.2-17b2d4310e.patch", 45 | "@types/lodash-es": "^4.17.12", 46 | "@types/node": "^22.19.3", 47 | "eslint": "^9.39.2", 48 | "husky": "^9.1.7", 49 | "lint-staged": "^16.2.7", 50 | "prettier": "^3.7.4", 51 | "rimraf": "^6.1.2", 52 | "tsx": "^4.21.0", 53 | "typescript": "~5.9.3", 54 | "typescript-eslint": "^8.50.0", 55 | "vitest": "^4.0.16" 56 | }, 57 | "packageManager": "yarn@4.12.0" 58 | } 59 | -------------------------------------------------------------------------------- /src/presets/recording.ts: -------------------------------------------------------------------------------- 1 | import { combineRgb } from '@companion-module/base' 2 | import { Enums } from 'atem-connection' 3 | import { ActionId } from '../actions/ActionId.js' 4 | import { FeedbackId } from '../feedback/FeedbackId.js' 5 | import type { MyPresetDefinitionCategory } from './types.js' 6 | import type { ActionTypes } from '../actions/index.js' 7 | import type { FeedbackTypes } from '../feedback/index.js' 8 | import type { ModelSpec } from '../models/types.js' 9 | 10 | export function createRecordingPresets(model: ModelSpec): MyPresetDefinitionCategory[] { 11 | if (!model.recording) return [] 12 | 13 | return [ 14 | { 15 | name: 'Streaming & Recording', 16 | presets: { 17 | [`recording_toggle`]: { 18 | name: 'Record', 19 | type: 'button', 20 | style: { 21 | text: 'Record\\n$(atem:record_duration_hm)', 22 | size: '18', 23 | color: combineRgb(255, 255, 255), 24 | bgcolor: combineRgb(0, 0, 0), 25 | }, 26 | feedbacks: [ 27 | { 28 | feedbackId: FeedbackId.RecordStatus, 29 | options: { 30 | state: Enums.RecordingStatus.Recording, 31 | }, 32 | style: { 33 | bgcolor: combineRgb(0, 255, 0), 34 | color: combineRgb(0, 0, 0), 35 | }, 36 | }, 37 | { 38 | feedbackId: FeedbackId.RecordStatus, 39 | options: { 40 | state: Enums.RecordingStatus.Stopping, 41 | }, 42 | style: { 43 | bgcolor: combineRgb(238, 238, 0), 44 | color: combineRgb(0, 0, 0), 45 | }, 46 | }, 47 | ], 48 | steps: [ 49 | { 50 | down: [ 51 | { 52 | actionId: ActionId.RecordStartStop, 53 | options: { 54 | record: 'toggle', 55 | }, 56 | }, 57 | ], 58 | up: [], 59 | }, 60 | ], 61 | }, 62 | }, 63 | }, 64 | ] 65 | } 66 | -------------------------------------------------------------------------------- /src/feedback/streaming.ts: -------------------------------------------------------------------------------- 1 | import { Enums } from 'atem-connection' 2 | import type { ModelSpec } from '../models/index.js' 3 | import type { MyFeedbackDefinitions } from './types.js' 4 | import { FeedbackId } from './FeedbackId.js' 5 | import { combineRgb, type CompanionInputFieldDropdown } from '@companion-module/base' 6 | import type { StateWrapper } from '../state.js' 7 | 8 | export interface AtemStreamingFeedbacks { 9 | [FeedbackId.StreamStatus]: { 10 | state: Enums.StreamingStatus 11 | } 12 | } 13 | 14 | export function createStreamingFeedbacks( 15 | model: ModelSpec, 16 | state: StateWrapper, 17 | ): MyFeedbackDefinitions { 18 | if (!model.streaming) { 19 | return { 20 | [FeedbackId.StreamStatus]: undefined, 21 | } 22 | } 23 | return { 24 | [FeedbackId.StreamStatus]: { 25 | type: 'boolean', 26 | name: 'Streaming: Active/Running', 27 | description: 'If the stream has the specified status, change style of the bank', 28 | options: { 29 | state: { 30 | id: 'state', 31 | label: 'State', 32 | type: 'dropdown', 33 | choices: Object.entries(Enums.StreamingStatus) 34 | .filter(([_k, v]) => typeof v === 'number') 35 | .map(([k, v]) => ({ 36 | id: v, 37 | label: k, 38 | })), 39 | default: Enums.StreamingStatus.Streaming, 40 | } satisfies CompanionInputFieldDropdown, 41 | }, 42 | defaultStyle: { 43 | color: combineRgb(0, 0, 0), 44 | bgcolor: combineRgb(0, 255, 0), 45 | }, 46 | callback: ({ options }): boolean => { 47 | const streaming = state.state.streaming?.status?.state 48 | return streaming === options.getPlainNumber('state') 49 | }, 50 | learn: ({ options }) => { 51 | if (state.state.streaming?.status) { 52 | return { 53 | ...options.getJson(), 54 | state: state.state.streaming.status.state, 55 | } 56 | } else { 57 | return undefined 58 | } 59 | }, 60 | }, 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/models/ps4k.ts: -------------------------------------------------------------------------------- 1 | import { generateOutputs, type ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { VideoInputGenerator } from './util/videoInput.js' 4 | 5 | export const ModelSpecPS4K: ModelSpec = { 6 | id: Enums.Model.PS4K, 7 | label: 'Production Studio 4K', 8 | outputs: generateOutputs('Aux', 1), 9 | MEs: 1, 10 | USKs: 1, 11 | DSKs: 2, 12 | MVs: 1, 13 | multiviewerFullGrid: false, 14 | DVEs: 0, 15 | SSrc: 0, 16 | macros: 100, 17 | displayClock: 0, 18 | media: { 19 | players: 2, 20 | stills: 20, 21 | clips: 0, 22 | captureStills: false, 23 | }, 24 | streaming: false, 25 | recording: false, 26 | recordISO: false, 27 | inputs: VideoInputGenerator.begin({ 28 | meCount: 1, 29 | baseSourceAvailability: Enums.SourceAvailability.Auxiliary | Enums.SourceAvailability.Multiviewer, 30 | }) 31 | .addInternalColorsAndBlack() 32 | .addExternalInputs(8) 33 | .addMediaPlayers(2) 34 | .addUpstreamKeyMasks(1) 35 | .addDownstreamKeyMasksAndClean(2) 36 | .addAuxiliaryOutputs(1) 37 | .addProgramPreview() 38 | .generate(), 39 | classicAudio: { 40 | inputs: [ 41 | { 42 | id: 1, 43 | portType: Enums.ExternalPortType.HDMI, 44 | }, 45 | { 46 | id: 2, 47 | portType: Enums.ExternalPortType.HDMI, 48 | }, 49 | { 50 | id: 3, 51 | portType: Enums.ExternalPortType.HDMI, 52 | }, 53 | { 54 | id: 4, 55 | portType: Enums.ExternalPortType.HDMI, 56 | }, 57 | { 58 | id: 5, 59 | portType: Enums.ExternalPortType.SDI, 60 | }, 61 | { 62 | id: 6, 63 | portType: Enums.ExternalPortType.SDI, 64 | }, 65 | { 66 | id: 7, 67 | portType: Enums.ExternalPortType.SDI, 68 | }, 69 | { 70 | id: 8, 71 | portType: Enums.ExternalPortType.SDI, 72 | }, 73 | { 74 | id: 1001, 75 | portType: Enums.ExternalPortType.XLR, 76 | }, 77 | { 78 | id: 1201, 79 | portType: Enums.ExternalPortType.RCA, 80 | }, 81 | ], 82 | }, 83 | } 84 | -------------------------------------------------------------------------------- /src/models/tvshd.ts: -------------------------------------------------------------------------------- 1 | import { generateOutputs, type ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { VideoInputGenerator } from './util/videoInput.js' 4 | 5 | export const ModelSpecTVSHD: ModelSpec = { 6 | id: Enums.Model.TVSHD, 7 | label: 'TV Studio HD', 8 | outputs: generateOutputs('Aux', 1), 9 | MEs: 1, 10 | USKs: 1, 11 | DSKs: 2, 12 | MVs: 1, 13 | multiviewerFullGrid: false, 14 | DVEs: 1, 15 | SSrc: 0, 16 | macros: 100, 17 | displayClock: 0, 18 | media: { 19 | players: 2, 20 | stills: 20, 21 | clips: 0, 22 | captureStills: false, 23 | }, 24 | streaming: false, 25 | recording: false, 26 | recordISO: false, 27 | inputs: VideoInputGenerator.begin({ 28 | meCount: 1, 29 | baseSourceAvailability: Enums.SourceAvailability.Auxiliary | Enums.SourceAvailability.Multiviewer, 30 | }) 31 | .addInternalColorsAndBlack() 32 | .addExternalInputs(8) 33 | .addMediaPlayers(2) 34 | .addUpstreamKeyMasks(1) 35 | .addDownstreamKeyMasksAndClean(2) 36 | .addAuxiliaryOutputs(1) 37 | .addProgramPreview() 38 | .generate(), 39 | classicAudio: { 40 | inputs: [ 41 | { 42 | id: 1, 43 | portType: Enums.ExternalPortType.HDMI, 44 | }, 45 | { 46 | id: 2, 47 | portType: Enums.ExternalPortType.HDMI, 48 | }, 49 | { 50 | id: 3, 51 | portType: Enums.ExternalPortType.HDMI, 52 | }, 53 | { 54 | id: 4, 55 | portType: Enums.ExternalPortType.HDMI, 56 | }, 57 | { 58 | id: 5, 59 | portType: Enums.ExternalPortType.SDI, 60 | }, 61 | { 62 | id: 6, 63 | portType: Enums.ExternalPortType.SDI, 64 | }, 65 | { 66 | id: 7, 67 | portType: Enums.ExternalPortType.SDI, 68 | }, 69 | { 70 | id: 8, 71 | portType: Enums.ExternalPortType.SDI, 72 | }, 73 | { 74 | id: 1001, 75 | portType: Enums.ExternalPortType.XLR, 76 | }, 77 | { 78 | id: 1301, 79 | portType: Enums.ExternalPortType.TSJack, 80 | }, 81 | ], 82 | }, 83 | } 84 | -------------------------------------------------------------------------------- /src/models/tvsprohd.ts: -------------------------------------------------------------------------------- 1 | import { generateOutputs, type ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { VideoInputGenerator } from './util/videoInput.js' 4 | 5 | export const ModelSpecTVSProHD: ModelSpec = { 6 | id: Enums.Model.TVSProHD, 7 | label: 'TV Studio Pro HD', 8 | outputs: generateOutputs('Aux', 1), 9 | MEs: 1, 10 | USKs: 1, 11 | DSKs: 2, 12 | MVs: 1, 13 | multiviewerFullGrid: false, 14 | DVEs: 1, 15 | SSrc: 0, 16 | macros: 100, 17 | displayClock: 0, 18 | media: { 19 | players: 2, 20 | stills: 20, 21 | clips: 0, 22 | captureStills: false, 23 | }, 24 | streaming: false, 25 | recording: false, 26 | recordISO: false, 27 | inputs: VideoInputGenerator.begin({ 28 | meCount: 1, 29 | baseSourceAvailability: Enums.SourceAvailability.Auxiliary | Enums.SourceAvailability.Multiviewer, 30 | }) 31 | .addInternalColorsAndBlack() 32 | .addExternalInputs(8) 33 | .addMediaPlayers(2) 34 | .addUpstreamKeyMasks(1) 35 | .addDownstreamKeyMasksAndClean(2) 36 | .addAuxiliaryOutputs(1) 37 | .addProgramPreview() 38 | .generate(), 39 | classicAudio: { 40 | inputs: [ 41 | { 42 | id: 1, 43 | portType: Enums.ExternalPortType.HDMI, 44 | }, 45 | { 46 | id: 2, 47 | portType: Enums.ExternalPortType.HDMI, 48 | }, 49 | { 50 | id: 3, 51 | portType: Enums.ExternalPortType.HDMI, 52 | }, 53 | { 54 | id: 4, 55 | portType: Enums.ExternalPortType.HDMI, 56 | }, 57 | { 58 | id: 5, 59 | portType: Enums.ExternalPortType.SDI, 60 | }, 61 | { 62 | id: 6, 63 | portType: Enums.ExternalPortType.SDI, 64 | }, 65 | { 66 | id: 7, 67 | portType: Enums.ExternalPortType.SDI, 68 | }, 69 | { 70 | id: 8, 71 | portType: Enums.ExternalPortType.SDI, 72 | }, 73 | { 74 | id: 1001, 75 | portType: Enums.ExternalPortType.XLR, 76 | }, 77 | { 78 | id: 1301, 79 | portType: Enums.ExternalPortType.TSJack, 80 | }, 81 | ], 82 | }, 83 | } 84 | -------------------------------------------------------------------------------- /src/actions/cameraControl/display.ts: -------------------------------------------------------------------------------- 1 | import { type Atem } from 'atem-connection' 2 | import { ActionId } from '../ActionId.js' 3 | import type { MyActionDefinitions } from '../types.js' 4 | import type { StateWrapper } from '../../state.js' 5 | import { AtemCameraControlDirectCommandSender } from '@atem-connection/camera-control' 6 | import { CHOICES_ON_OFF_TOGGLE, CameraControlSourcePicker, type TrueFalseToggle } from '../../choices.js' 7 | import type { AtemConfig } from '../../config.js' 8 | 9 | export interface AtemCameraControlDisplayActions { 10 | [ActionId.CameraControlDisplayColorBars]: { 11 | cameraId: string 12 | state: TrueFalseToggle 13 | } 14 | } 15 | 16 | export function createCameraControlDisplayActions( 17 | config: AtemConfig, 18 | atem: Atem | undefined, 19 | state: StateWrapper, 20 | ): MyActionDefinitions { 21 | if (!config.enableCameraControl) { 22 | return { 23 | [ActionId.CameraControlDisplayColorBars]: undefined, 24 | } 25 | } 26 | 27 | const commandSender = atem && new AtemCameraControlDirectCommandSender(atem) 28 | 29 | return { 30 | [ActionId.CameraControlDisplayColorBars]: { 31 | name: 'Camera Control: Show Color Bars', 32 | options: { 33 | cameraId: CameraControlSourcePicker(), 34 | state: { 35 | id: 'state', 36 | type: 'dropdown', 37 | label: 'State', 38 | default: 'toggle', 39 | choices: CHOICES_ON_OFF_TOGGLE, 40 | }, 41 | }, 42 | callback: async ({ options }) => { 43 | const cameraId = await options.getParsedNumber('cameraId') 44 | 45 | let target: boolean 46 | if (options.getPlainString('state') === 'toggle') { 47 | const cameraState = state.atemCameraState.get(cameraId) 48 | target = !cameraState?.display?.colorBarEnable 49 | console.log('camera', cameraState, target) 50 | } else { 51 | target = options.getPlainString('state') === 'true' 52 | } 53 | 54 | await commandSender?.displayColorBars(cameraId, target) 55 | }, 56 | }, 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/models/1me.ts: -------------------------------------------------------------------------------- 1 | import { generateOutputs, type ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { VideoInputGenerator } from './util/videoInput.js' 4 | 5 | export const ModelSpecOneME: ModelSpec = { 6 | id: Enums.Model.OneME, 7 | label: '1 ME Production', 8 | outputs: generateOutputs('Aux', 3), 9 | MEs: 1, 10 | USKs: 4, 11 | DSKs: 2, 12 | MVs: 1, 13 | multiviewerFullGrid: false, 14 | DVEs: 1, 15 | SSrc: 0, 16 | macros: 100, 17 | displayClock: 0, 18 | media: { 19 | players: 2, 20 | stills: 32, 21 | clips: 2, 22 | captureStills: false, 23 | }, 24 | streaming: false, 25 | recording: false, 26 | recordISO: false, 27 | inputs: VideoInputGenerator.begin({ 28 | meCount: 1, 29 | baseSourceAvailability: Enums.SourceAvailability.Auxiliary | Enums.SourceAvailability.Multiviewer, 30 | }) 31 | .addInternalColorsAndBlack() 32 | .addExternalInputs(8) 33 | .addMediaPlayers(2) 34 | .addCleanFeeds(2) 35 | .addAuxiliaryOutputs(3) 36 | .addProgramPreview() 37 | .generate(), 38 | classicAudio: { 39 | inputs: [ 40 | { 41 | id: 1, 42 | portType: Enums.ExternalPortType.HDMI, 43 | }, 44 | { 45 | id: 2, 46 | portType: Enums.ExternalPortType.HDMI, 47 | }, 48 | { 49 | id: 3, 50 | portType: Enums.ExternalPortType.HDMI, 51 | }, 52 | { 53 | id: 4, 54 | portType: Enums.ExternalPortType.HDMI, 55 | }, 56 | { 57 | id: 5, 58 | portType: Enums.ExternalPortType.SDI, 59 | }, 60 | { 61 | id: 6, 62 | portType: Enums.ExternalPortType.SDI, 63 | }, 64 | { 65 | id: 7, 66 | portType: Enums.ExternalPortType.SDI, 67 | }, 68 | { 69 | id: 8, 70 | portType: Enums.ExternalPortType.SDI, 71 | }, 72 | { 73 | id: 1001, 74 | portType: Enums.ExternalPortType.XLR, 75 | }, 76 | { 77 | id: 2001, 78 | portType: Enums.ExternalPortType.Internal, 79 | }, 80 | { 81 | id: 2002, 82 | portType: Enums.ExternalPortType.Internal, 83 | }, 84 | ], 85 | }, 86 | } 87 | -------------------------------------------------------------------------------- /src/models/types.ts: -------------------------------------------------------------------------------- 1 | import type { Enums } from 'atem-connection' 2 | 3 | export const MODEL_AUTO_DETECT = 0 4 | export type ModelId = 0 | Enums.Model 5 | 6 | export interface ModelSpec { 7 | id: ModelId 8 | label: string 9 | outputs: Array<{ id: number; name: string }> 10 | MEs: number 11 | USKs: number 12 | DSKs: number 13 | MVs: number 14 | DVEs: number 15 | multiviewerFullGrid: boolean 16 | SSrc: number 17 | macros: number 18 | displayClock: number 19 | media: { players: number; stills: number; clips: number; captureStills: boolean } 20 | streaming: boolean 21 | recording: boolean 22 | recordISO: boolean 23 | inputs: VideoInputInfo[] 24 | classicAudio?: { 25 | inputs: Array<{ 26 | id: number 27 | portType: Enums.ExternalPortType 28 | // type: 'video' | 'audio' | 'internal' 29 | }> 30 | } 31 | fairlightAudio?: { 32 | monitor: 'combined' | 'split' | null 33 | audioRouting?: { sources: AudioRoutingSourceInfo[]; outputs: AudioRoutingOutputInfo[] } 34 | inputs: AudioFairlightInputInfo[] 35 | } 36 | } 37 | 38 | export interface VideoInputInfo { 39 | id: number 40 | portType: Enums.InternalPortType 41 | sourceAvailability: Enums.SourceAvailability 42 | meAvailability: Enums.MeAvailability 43 | } 44 | 45 | export interface AudioFairlightInputInfo { 46 | id: number 47 | portType: Enums.ExternalPortType 48 | // supportedConfigurations: Enums.FairlightInputConfiguration[] 49 | // portType: 'video' | 'audio' | 'internal' 50 | maxDelay?: number 51 | } 52 | 53 | export interface AudioRoutingSourceInfo { 54 | sourceName: string 55 | inputId: number 56 | channelPairs: Enums.AudioChannelPair[] 57 | } 58 | export interface AudioRoutingOutputInfo { 59 | outputName: string 60 | outputId: number 61 | channelPairs: Enums.AudioChannelPair[] 62 | } 63 | 64 | export function generateOutputs(prefix: string, count: number): ModelSpec['outputs'] { 65 | const outputs: ModelSpec['outputs'] = [] 66 | for (let i = 0; i < count; i++) { 67 | outputs.push({ id: i, name: `${prefix} ${i + 1}` }) 68 | } 69 | return outputs 70 | } 71 | -------------------------------------------------------------------------------- /src/presets/aux-outputs.ts: -------------------------------------------------------------------------------- 1 | import { combineRgb, type CompanionButtonStyleProps } from '@companion-module/base' 2 | import { ActionId } from '../actions/ActionId.js' 3 | import { FeedbackId } from '../feedback/FeedbackId.js' 4 | import type { MyPresetDefinitionCategory } from './types.js' 5 | import type { ActionTypes } from '../actions/index.js' 6 | import type { FeedbackTypes } from '../feedback/index.js' 7 | import type { ModelSpec } from '../models/types.js' 8 | import { GetSourcesListForType } from '../choices.js' 9 | import type { AtemState } from 'atem-connection' 10 | 11 | export function createAuxOutputPresets( 12 | model: ModelSpec, 13 | state: AtemState, 14 | pstSize: CompanionButtonStyleProps['size'], 15 | pstText: string, 16 | ): MyPresetDefinitionCategory[] { 17 | const result: MyPresetDefinitionCategory[] = [] 18 | 19 | for (const output of model.outputs) { 20 | const category: MyPresetDefinitionCategory = { 21 | name: output.name, 22 | presets: {}, 23 | } 24 | result.push(category) 25 | 26 | for (const src of GetSourcesListForType(model, state, 'aux')) { 27 | category.presets[`aux_${output.id}_${src.id}`] = { 28 | name: `${output.name} button for ${src.shortName}`, 29 | type: 'button', 30 | style: { 31 | text: `$(atem:${pstText}${src.id})`, 32 | size: pstSize, 33 | color: combineRgb(255, 255, 255), 34 | bgcolor: combineRgb(0, 0, 0), 35 | }, 36 | feedbacks: [ 37 | { 38 | feedbackId: FeedbackId.AuxBG, 39 | options: { 40 | input: src.id, 41 | aux: output.id, 42 | }, 43 | style: { 44 | bgcolor: combineRgb(255, 255, 0), 45 | color: combineRgb(0, 0, 0), 46 | }, 47 | }, 48 | ], 49 | steps: [ 50 | { 51 | down: [ 52 | { 53 | actionId: ActionId.Aux, 54 | options: { 55 | aux: output.id, 56 | input: src.id, 57 | }, 58 | }, 59 | ], 60 | up: [], 61 | }, 62 | ], 63 | } 64 | } 65 | } 66 | 67 | return result 68 | } 69 | -------------------------------------------------------------------------------- /src/presets/streaming.ts: -------------------------------------------------------------------------------- 1 | import { combineRgb } from '@companion-module/base' 2 | import { Enums } from 'atem-connection' 3 | import { ActionId } from '../actions/ActionId.js' 4 | import { FeedbackId } from '../feedback/FeedbackId.js' 5 | import type { MyPresetDefinitionCategory } from './types.js' 6 | import type { ActionTypes } from '../actions/index.js' 7 | import type { FeedbackTypes } from '../feedback/index.js' 8 | import type { ModelSpec } from '../models/types.js' 9 | 10 | export function createStreamingPresets(model: ModelSpec): MyPresetDefinitionCategory[] { 11 | if (!model.streaming) return [] 12 | 13 | return [ 14 | { 15 | name: 'Streaming & Recording', 16 | presets: { 17 | [`streaming_toggle`]: { 18 | name: 'Stream', 19 | type: 'button', 20 | style: { 21 | text: 'Stream\\n$(atem:stream_duration_hm)', 22 | size: '18', 23 | color: combineRgb(255, 255, 255), 24 | bgcolor: combineRgb(0, 0, 0), 25 | }, 26 | feedbacks: [ 27 | { 28 | feedbackId: FeedbackId.StreamStatus, 29 | options: { 30 | state: Enums.StreamingStatus.Streaming, 31 | }, 32 | style: { 33 | bgcolor: combineRgb(0, 255, 0), 34 | color: combineRgb(0, 0, 0), 35 | }, 36 | }, 37 | { 38 | feedbackId: FeedbackId.StreamStatus, 39 | options: { 40 | state: Enums.StreamingStatus.Stopping, 41 | }, 42 | style: { 43 | bgcolor: combineRgb(238, 238, 0), 44 | color: combineRgb(0, 0, 0), 45 | }, 46 | }, 47 | { 48 | feedbackId: FeedbackId.StreamStatus, 49 | options: { 50 | state: Enums.StreamingStatus.Connecting, 51 | }, 52 | style: { 53 | bgcolor: combineRgb(238, 238, 0), 54 | color: combineRgb(0, 0, 0), 55 | }, 56 | }, 57 | ], 58 | steps: [ 59 | { 60 | down: [ 61 | { 62 | actionId: ActionId.StreamStartStop, 63 | options: { 64 | stream: 'toggle', 65 | }, 66 | }, 67 | ], 68 | up: [], 69 | }, 70 | ], 71 | }, 72 | }, 73 | }, 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /src/actions/aux-outputs.ts: -------------------------------------------------------------------------------- 1 | import { type Atem } from 'atem-connection' 2 | import type { ModelSpec } from '../models/index.js' 3 | import { ActionId } from './ActionId.js' 4 | import type { MyActionDefinitions } from './types.js' 5 | import { AtemAuxPicker, AtemAuxSourcePicker } from '../input.js' 6 | import type { StateWrapper } from '../state.js' 7 | 8 | export interface AtemAuxOutputActions { 9 | [ActionId.Aux]: { 10 | aux: number 11 | input: number 12 | } 13 | [ActionId.AuxVariables]: { 14 | aux: string 15 | input: string 16 | } 17 | } 18 | 19 | export function createAuxOutputActions( 20 | atem: Atem | undefined, 21 | model: ModelSpec, 22 | state: StateWrapper, 23 | ): MyActionDefinitions { 24 | if (model.outputs.length === 0) { 25 | return { 26 | [ActionId.Aux]: undefined, 27 | [ActionId.AuxVariables]: undefined, 28 | } 29 | } 30 | return { 31 | [ActionId.Aux]: { 32 | name: 'Aux/Output: Set source', 33 | options: { 34 | aux: AtemAuxPicker(model), 35 | input: AtemAuxSourcePicker(model, state.state), 36 | }, 37 | callback: async ({ options }) => { 38 | await atem?.setAuxSource(options.getPlainNumber('input'), options.getPlainNumber('aux')) 39 | }, 40 | learn: ({ options }) => { 41 | const auxSource = state.state.video.auxilliaries[options.getPlainNumber('aux')] 42 | 43 | if (auxSource !== undefined) { 44 | return { 45 | ...options.getJson(), 46 | input: auxSource, 47 | } 48 | } else { 49 | return undefined 50 | } 51 | }, 52 | }, 53 | [ActionId.AuxVariables]: { 54 | name: 'Aux/Output: Set source from variables', 55 | options: { 56 | aux: { 57 | type: 'textinput', 58 | id: 'aux', 59 | label: 'AUX', 60 | default: '1', 61 | useVariables: true, 62 | }, 63 | input: { 64 | type: 'textinput', 65 | id: 'input', 66 | label: 'Input ID', 67 | default: '0', 68 | useVariables: true, 69 | }, 70 | }, 71 | callback: async ({ options }) => { 72 | const output = await options.getParsedNumber('aux') 73 | const input = await options.getParsedNumber('input') 74 | 75 | if (!isNaN(output) && !isNaN(input)) { 76 | await atem?.setAuxSource(input, output - 1) 77 | } 78 | }, 79 | }, 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/models/util/fairlight.ts: -------------------------------------------------------------------------------- 1 | import { Enums } from 'atem-connection' 2 | import type { AudioFairlightInputInfo } from '../types' 3 | 4 | export const AUDIO_FAIRLIGHT_INPUT_XLR: AudioFairlightInputInfo = { 5 | id: 1001, 6 | portType: Enums.ExternalPortType.XLR, 7 | } 8 | export const AUDIO_FAIRLIGHT_INPUT_XLR2: AudioFairlightInputInfo = { 9 | id: 1002, 10 | portType: Enums.ExternalPortType.XLR, 11 | } 12 | export const AUDIO_FAIRLIGHT_INPUT_RCA: AudioFairlightInputInfo = { 13 | id: 1201, 14 | portType: Enums.ExternalPortType.RCA, 15 | } 16 | export const AUDIO_FAIRLIGHT_INPUT_TS_JACK: AudioFairlightInputInfo = { 17 | id: 1301, 18 | portType: Enums.ExternalPortType.TSJack, 19 | } 20 | export const AUDIO_FAIRLIGHT_INPUT_TRS_JACK: AudioFairlightInputInfo = { 21 | id: 1401, 22 | portType: Enums.ExternalPortType.TRSJack, 23 | } 24 | 25 | export const AUDIO_FAIRLIGHT_INPUT_MINI_TS_JACKS: AudioFairlightInputInfo[] = [ 26 | { 27 | id: 1301, 28 | portType: Enums.ExternalPortType.TSJack, 29 | maxDelay: 8, 30 | }, 31 | { 32 | id: 1302, 33 | portType: Enums.ExternalPortType.TSJack, 34 | maxDelay: 8, 35 | }, 36 | ] 37 | 38 | export function generateFairlightInputMadi(inputCount: number, maxDelay?: number): AudioFairlightInputInfo[] { 39 | return generateFairlightInputsOfType(1501, inputCount, Enums.ExternalPortType.MADI, maxDelay) 40 | } 41 | 42 | export function generateFairlightInputMediaPlayer(inputCount: number, maxDelay?: number): AudioFairlightInputInfo[] { 43 | return generateFairlightInputsOfType(2001, inputCount, Enums.ExternalPortType.Internal, maxDelay) 44 | } 45 | export function generateFairlightInputThunderbolt(inputCount: 1, maxDelay?: number): AudioFairlightInputInfo[] { 46 | return generateFairlightInputsOfType(2051, inputCount, Enums.ExternalPortType.Internal, maxDelay) 47 | } 48 | 49 | export function generateFairlightInputsOfType( 50 | firstIndex: number, 51 | count: number, 52 | type: Enums.ExternalPortType, 53 | maxDelay?: number, 54 | ): AudioFairlightInputInfo[] { 55 | const sources: Array = [] 56 | for (let i = firstIndex; i < firstIndex + count; i++) { 57 | const src: AudioFairlightInputInfo = { id: i, portType: type } 58 | if (maxDelay !== undefined) src.maxDelay = maxDelay 59 | 60 | sources.push(src) 61 | } 62 | return sources 63 | } 64 | -------------------------------------------------------------------------------- /src/feedback/recording.ts: -------------------------------------------------------------------------------- 1 | import { Enums } from 'atem-connection' 2 | import type { ModelSpec } from '../models/index.js' 3 | import type { MyFeedbackDefinitions } from './types.js' 4 | import { FeedbackId } from './FeedbackId.js' 5 | import { combineRgb, type CompanionInputFieldDropdown } from '@companion-module/base' 6 | import type { StateWrapper } from '../state.js' 7 | 8 | export interface AtemRecordingFeedbacks { 9 | [FeedbackId.RecordStatus]: { 10 | state: Enums.RecordingStatus 11 | } 12 | [FeedbackId.RecordISO]: Record 13 | } 14 | 15 | export function createRecordingFeedbacks( 16 | model: ModelSpec, 17 | state: StateWrapper, 18 | ): MyFeedbackDefinitions { 19 | if (!model.recording) { 20 | return { 21 | [FeedbackId.RecordStatus]: undefined, 22 | [FeedbackId.RecordISO]: undefined, 23 | } 24 | } 25 | return { 26 | [FeedbackId.RecordStatus]: { 27 | type: 'boolean', 28 | name: 'Recording: Active/Running', 29 | description: 'If the record has the specified status, change style of the bank', 30 | options: { 31 | state: { 32 | id: 'state', 33 | label: 'State', 34 | type: 'dropdown', 35 | choices: Object.entries(Enums.RecordingStatus) 36 | .filter(([_k, v]) => typeof v === 'number') 37 | .map(([k, v]) => ({ 38 | id: v, 39 | label: k, 40 | })), 41 | default: Enums.RecordingStatus.Recording, 42 | } satisfies CompanionInputFieldDropdown, 43 | }, 44 | defaultStyle: { 45 | color: combineRgb(0, 0, 0), 46 | bgcolor: combineRgb(0, 255, 0), 47 | }, 48 | callback: ({ options }): boolean => { 49 | const recording = state.state.recording?.status?.state 50 | return recording === options.getPlainNumber('state') 51 | }, 52 | learn: ({ options }) => { 53 | if (state.state.recording?.status) { 54 | return { 55 | ...options.getJson(), 56 | state: state.state.recording.status.state, 57 | } 58 | } else { 59 | return undefined 60 | } 61 | }, 62 | }, 63 | [FeedbackId.RecordISO]: { 64 | type: 'boolean', 65 | name: 'Recording: ISO enabled', 66 | description: 'If ISO recording is enabled', 67 | options: {}, 68 | defaultStyle: { 69 | color: combineRgb(0, 0, 0), 70 | bgcolor: combineRgb(0, 255, 0), 71 | }, 72 | callback: (): boolean => { 73 | return !!state.state.recording?.recordAllInputs 74 | }, 75 | }, 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/presets/multiviewer.ts: -------------------------------------------------------------------------------- 1 | import { combineRgb, type CompanionButtonStyleProps } from '@companion-module/base' 2 | import { ActionId } from '../actions/ActionId.js' 3 | import { FeedbackId } from '../feedback/FeedbackId.js' 4 | import type { MyPresetDefinitionCategory } from './types.js' 5 | import type { ActionTypes } from '../actions/index.js' 6 | import type { FeedbackTypes } from '../feedback/index.js' 7 | import type { ModelSpec } from '../models/types.js' 8 | import { GetSourcesListForType } from '../choices.js' 9 | import type { AtemState } from 'atem-connection' 10 | 11 | export function createMultiviewerPresets( 12 | model: ModelSpec, 13 | state: AtemState, 14 | pstSize: CompanionButtonStyleProps['size'], 15 | pstText: string, 16 | ): MyPresetDefinitionCategory[] { 17 | const result: MyPresetDefinitionCategory[] = [] 18 | 19 | for (let mv = 0; mv < model.MVs; mv++) { 20 | const firstWindow = model.multiviewerFullGrid ? 0 : 2 21 | const windowCount = model.multiviewerFullGrid ? 16 : 10 22 | for (let window = firstWindow; window < windowCount; window++) { 23 | const category: MyPresetDefinitionCategory = { 24 | name: `MV ${mv + 1} Window ${window + 1}`, 25 | presets: {}, 26 | } 27 | result.push(category) 28 | 29 | for (const src of GetSourcesListForType(model, state, 'mv')) { 30 | category.presets[`mv_win_src_${mv}_${window}_${src.id}`] = { 31 | name: `Set MV ${mv + 1} Window ${window + 1} to source ${src.shortName}`, 32 | type: 'button', 33 | style: { 34 | text: `$(atem:${pstText}${src.id})`, 35 | size: pstSize, 36 | color: combineRgb(255, 255, 255), 37 | bgcolor: combineRgb(0, 0, 0), 38 | }, 39 | feedbacks: [ 40 | { 41 | feedbackId: FeedbackId.MVSource, 42 | options: { 43 | multiViewerId: mv, 44 | source: src.id, 45 | windowIndex: window, 46 | }, 47 | style: { 48 | bgcolor: combineRgb(255, 255, 0), 49 | color: combineRgb(0, 0, 0), 50 | }, 51 | }, 52 | ], 53 | steps: [ 54 | { 55 | down: [ 56 | { 57 | actionId: ActionId.MultiviewerWindowSource, 58 | options: { 59 | multiViewerId: mv, 60 | source: src.id, 61 | windowIndex: window, 62 | }, 63 | }, 64 | ], 65 | up: [], 66 | }, 67 | ], 68 | } 69 | } 70 | } 71 | } 72 | 73 | return result 74 | } 75 | -------------------------------------------------------------------------------- /src/models/1me4k.ts: -------------------------------------------------------------------------------- 1 | import { generateOutputs, type ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { VideoInputGenerator } from './util/videoInput.js' 4 | 5 | export const ModelSpecOneME4K: ModelSpec = { 6 | id: Enums.Model.OneME4K, 7 | label: '1 ME Production 4K', 8 | outputs: generateOutputs('Aux', 3), 9 | MEs: 1, 10 | USKs: 4, 11 | DSKs: 2, 12 | MVs: 1, 13 | multiviewerFullGrid: false, 14 | DVEs: 1, 15 | SSrc: 0, 16 | macros: 100, 17 | displayClock: 0, 18 | media: { 19 | players: 2, 20 | stills: 32, 21 | clips: 2, 22 | captureStills: false, 23 | }, 24 | streaming: false, 25 | recording: false, 26 | recordISO: false, 27 | inputs: VideoInputGenerator.begin({ 28 | meCount: 1, 29 | baseSourceAvailability: 30 | Enums.SourceAvailability.Auxiliary | 31 | Enums.SourceAvailability.Multiviewer | 32 | Enums.SourceAvailability.SuperSourceBox | 33 | Enums.SourceAvailability.SuperSourceArt, 34 | }) 35 | .addInternalColorsAndBlack() 36 | .addExternalInputs(10) 37 | .addMediaPlayers(2) 38 | .addUpstreamKeyMasks(4) 39 | .addDownstreamKeyMasksAndClean(2) 40 | .addAuxiliaryOutputs(3) 41 | .addProgramPreview() 42 | .generate(), 43 | classicAudio: { 44 | inputs: [ 45 | { 46 | id: 1, 47 | portType: Enums.ExternalPortType.SDI, 48 | }, 49 | { 50 | id: 2, 51 | portType: Enums.ExternalPortType.SDI, 52 | }, 53 | { 54 | id: 3, 55 | portType: Enums.ExternalPortType.SDI, 56 | }, 57 | { 58 | id: 4, 59 | portType: Enums.ExternalPortType.SDI, 60 | }, 61 | { 62 | id: 5, 63 | portType: Enums.ExternalPortType.SDI, 64 | }, 65 | { 66 | id: 6, 67 | portType: Enums.ExternalPortType.SDI, 68 | }, 69 | { 70 | id: 7, 71 | portType: Enums.ExternalPortType.SDI, 72 | }, 73 | { 74 | id: 8, 75 | portType: Enums.ExternalPortType.SDI, 76 | }, 77 | { 78 | id: 9, 79 | portType: Enums.ExternalPortType.SDI, 80 | }, 81 | { 82 | id: 10, 83 | portType: Enums.ExternalPortType.SDI, 84 | }, 85 | { 86 | id: 1001, 87 | portType: Enums.ExternalPortType.XLR, 88 | }, 89 | { 90 | id: 1201, 91 | portType: Enums.ExternalPortType.RCA, 92 | }, 93 | { 94 | id: 2001, 95 | portType: Enums.ExternalPortType.Internal, 96 | }, 97 | { 98 | id: 2002, 99 | portType: Enums.ExternalPortType.Internal, 100 | }, 101 | ], 102 | }, 103 | } 104 | -------------------------------------------------------------------------------- /src/__tests__/util.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | 3 | import { AudioChannelPair } from 'atem-connection/dist/enums' 4 | import { combineInputId } from '../models/util/audioRouting' 5 | import { formatAudioRoutingAsString, parseAudioRoutingString, parseAudioRoutingStringSingle } from '../util' 6 | 7 | test('formatAudioRoutingAsString', () => { 8 | const sourceId = combineInputId(1234, AudioChannelPair.Channel5_6) 9 | expect(formatAudioRoutingAsString(sourceId)).toEqual('1234-5_6') 10 | 11 | const sourceId2 = combineInputId(9999, AudioChannelPair.Channel11_12) 12 | expect(formatAudioRoutingAsString(sourceId2)).toEqual('9999-11_12') 13 | 14 | const sourceId3 = combineInputId(0, AudioChannelPair.Channel5_6) 15 | expect(formatAudioRoutingAsString(sourceId3)).toEqual('0-5_6') 16 | }) 17 | 18 | describe('parseAudioRoutingString', () => { 19 | test('single', () => { 20 | const parse1 = parseAudioRoutingString('9991-1_2') 21 | expect(parse1).toEqual([combineInputId(9991, AudioChannelPair.Channel1_2)]) 22 | 23 | const parse2 = parseAudioRoutingString('123') 24 | expect(parse2).toEqual([combineInputId(123, AudioChannelPair.Channel1_2)]) 25 | }) 26 | 27 | test('multiple', () => { 28 | const parse1 = parseAudioRoutingString('9991-1_2,123 456, ,777-5_6') 29 | expect(parse1).toEqual([ 30 | combineInputId(9991, AudioChannelPair.Channel1_2), 31 | combineInputId(123, AudioChannelPair.Channel1_2), 32 | combineInputId(456, AudioChannelPair.Channel1_2), 33 | combineInputId(777, AudioChannelPair.Channel5_6), 34 | ]) 35 | }) 36 | }) 37 | 38 | test('parseAudioRoutingStringSingle', () => { 39 | const parse1 = parseAudioRoutingStringSingle('9991-1_2') 40 | expect(parse1).toEqual(combineInputId(9991, AudioChannelPair.Channel1_2)) 41 | 42 | const parse2 = parseAudioRoutingStringSingle('9291-7_8') 43 | expect(parse2).toEqual(combineInputId(9291, AudioChannelPair.Channel7_8)) 44 | 45 | const parse3 = parseAudioRoutingStringSingle('9291:7_8') 46 | expect(parse3).toEqual(null) 47 | 48 | const parse4 = parseAudioRoutingStringSingle('91') 49 | expect(parse4).toEqual(combineInputId(91, AudioChannelPair.Channel1_2)) 50 | 51 | const parse5 = parseAudioRoutingStringSingle('9291-8_9') 52 | expect(parse5).toEqual(null) 53 | 54 | const parse6 = parseAudioRoutingStringSingle('2001-9_10') 55 | expect(parse6).toEqual(combineInputId(2001, AudioChannelPair.Channel9_10)) 56 | 57 | const parse7 = parseAudioRoutingStringSingle('2001-15_16') 58 | expect(parse7).toEqual(combineInputId(2001, AudioChannelPair.Channel15_16)) 59 | }) 60 | -------------------------------------------------------------------------------- /companion/HELP.md: -------------------------------------------------------------------------------- 1 | ## Blackmagic Design ATEM 2 | 3 | Should work with all models of Blackmagic Design ATEM mixers. 4 | 5 | Firmware versions 7.5.2 and later are known to work, other versions may experience problems. 6 | Firmware versions after 20.2 are not verified to be working at the time of writing, but they likely will work fine. 7 | 8 | Devices must be controlled over a network, USB control is NOT supported. 9 | 10 | **Available commands for Blackmagic Design ATEM** 11 | 12 | - Set input on Program 13 | - Set input on Preview 14 | - Set inputs on Upstream KEY 15 | - Set inputs on Downstream KEY 16 | - Set AUX bus 17 | - Set Upstream KEY OnAir 18 | - Auto DSK Transition 19 | - Tie DSK to transition 20 | - Set Downstream Key On Air 21 | - CUT operation 22 | - AUTO transition operation 23 | - Change transition type 24 | - Change transition selection 25 | - Change transition selection component 26 | - Change transition rate 27 | - Set fade to black rate 28 | - Execute fade to/from black 29 | - Run MACRO 30 | - Continue MACRO 31 | - Stop MACROS 32 | - Change MV window source 33 | - Set SuperSource box On Air 34 | - Change SuperSource box source 35 | - Change SuperSource geometry properties 36 | - Offset SuperSource geometry properties 37 | - Change media player source 38 | - Cycle media player source 39 | - Mini-pro recording control 40 | - Mini-pro streaming control 41 | - Classic audio inputs control 42 | - Fairlight audio inputs control 43 | 44 | ## Common issues 45 | 46 | ### Macros not showing as running 47 | 48 | Companion is not always able to detect that a macro has been run. This happens when the macro has zero length. 49 | You can resolve this by giving the macro a pause/sleep of 1 frame. 50 | 51 | ### Diagnosing connection issues 52 | 53 | The most common cause of Companion not being able to connect to your ATEM is misconfiguration of the networking. Due to how the discovery protocol works, it will see ATEMs that you may not be able to connect to. 54 | A good way to rule out Companion as being at fault, is to disconnect the USB to your ATEM, and use the ATEM software. If that is unable to connect then it is most likely a network configuration issue. 55 | 56 | To be able to connect to your ATEM, both the ATEM and your Companion machine must be connected to the same network (ideally cabled, but wifi should work). They must also be of the same IP address range. For example, your network could be `192.168.0.x`, where each machine has a different number instead of the `x`. In most cases the subnet mask should be 255.255.255.0, unless your network is setup to use something else. 57 | -------------------------------------------------------------------------------- /src/actions/timecode.ts: -------------------------------------------------------------------------------- 1 | import { type Atem, Enums } from 'atem-connection' 2 | import { ActionId } from './ActionId.js' 3 | import type { MyActionDefinitions } from './types.js' 4 | import type { StateWrapper } from '../state.js' 5 | import type { AtemConfig } from '../config.js' 6 | import type { InstanceBaseExt } from '../util.js' 7 | import { formatDurationSeconds } from '../variables/util.js' 8 | 9 | export interface AtemTimecodeActions { 10 | [ActionId.Timecode]: { 11 | time: string 12 | } 13 | [ActionId.TimecodeMode]: { 14 | mode: Enums.TimeMode 15 | } 16 | } 17 | 18 | export function createTimecodeActions( 19 | instance: InstanceBaseExt, 20 | atem: Atem | undefined, 21 | state: StateWrapper, 22 | ): MyActionDefinitions { 23 | if (!instance.config.pollTimecode) { 24 | return { 25 | [ActionId.Timecode]: undefined, 26 | [ActionId.TimecodeMode]: undefined, 27 | } 28 | } 29 | return { 30 | [ActionId.Timecode]: { 31 | name: 'Timecode: Set time', 32 | options: { 33 | time: { 34 | id: 'time', 35 | type: 'textinput', 36 | label: 'Timecode', 37 | default: '00:00:00', 38 | tooltip: 'HH:MM:SS', 39 | useVariables: true, 40 | }, 41 | }, 42 | callback: async ({ options }) => { 43 | const timecodeStr = await options.getParsedString('time') 44 | const [hour, minute, seconds, frames] = timecodeStr.split(/:|;/).map((v) => parseInt(v, 10)) 45 | 46 | if (isNaN(hour) || isNaN(minute) || isNaN(seconds)) throw new Error('Invalid timecode') 47 | 48 | await atem?.setTime(hour, minute, seconds, isNaN(frames) ? 0 : frames) 49 | }, 50 | learn: ({ options }) => { 51 | const timecode = formatDurationSeconds(instance.timecodeSeconds).hms 52 | 53 | return { 54 | ...options.getJson(), 55 | time: timecode, 56 | } 57 | }, 58 | }, 59 | [ActionId.TimecodeMode]: { 60 | name: 'Timecode: Set mode', 61 | options: { 62 | mode: { 63 | id: 'mode', 64 | type: 'dropdown', 65 | label: 'Mode', 66 | choices: [ 67 | { id: Enums.TimeMode.FreeRun, label: 'Free run' }, 68 | { id: Enums.TimeMode.TimeOfDay, label: 'Time of Day' }, 69 | ], 70 | default: Enums.TimeMode.FreeRun, 71 | }, 72 | }, 73 | callback: async ({ options }) => { 74 | const mode = options.getPlainNumber('mode') 75 | 76 | await atem?.setTimeMode(mode) 77 | }, 78 | learn: ({ options }) => { 79 | return { 80 | ...options.getJson(), 81 | mode: state.state.settings.timeMode ?? Enums.TimeMode.FreeRun, 82 | } 83 | }, 84 | }, 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/models/constellationHd2Me.ts: -------------------------------------------------------------------------------- 1 | import { generateOutputs, type ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { 4 | AUDIO_ROUTING_SOURCE_NO_AUDIO, 5 | generateInputRoutingSources, 6 | AUDIO_ROUTING_SOURCE_MICROPHONE, 7 | AUDIO_ROUTING_SOURCE_TRS, 8 | generateMediaPlayerRoutingSources, 9 | generateTalkbackRoutingSources, 10 | AUDIO_ROUTING_SOURCE_PROGRAM, 11 | generateMixMinusRoutingSources, 12 | generateAuxRoutingOutputs, 13 | } from './util/audioRouting.js' 14 | import { 15 | AUDIO_FAIRLIGHT_INPUT_TRS_JACK, 16 | AUDIO_FAIRLIGHT_INPUT_TS_JACK, 17 | generateFairlightInputMediaPlayer, 18 | generateFairlightInputsOfType, 19 | } from './util/fairlight.js' 20 | import { VideoInputGenerator } from './util/videoInput.js' 21 | 22 | export const ModelSpecConstellationHD2ME: ModelSpec = { 23 | id: Enums.Model.ConstellationHD2ME, 24 | label: '2 M/E Constellation HD', 25 | outputs: generateOutputs('Output', 12), 26 | MEs: 2, 27 | USKs: 4, 28 | DSKs: 2, 29 | MVs: 2, 30 | multiviewerFullGrid: true, 31 | DVEs: 1, 32 | SSrc: 1, 33 | macros: 100, 34 | displayClock: 1, 35 | media: { 36 | players: 2, 37 | stills: 40, 38 | clips: 2, 39 | captureStills: true, 40 | }, 41 | streaming: false, 42 | recording: false, 43 | recordISO: false, 44 | inputs: VideoInputGenerator.begin({ 45 | meCount: 2, 46 | baseSourceAvailability: 47 | Enums.SourceAvailability.Auxiliary | 48 | Enums.SourceAvailability.Multiviewer | 49 | Enums.SourceAvailability.SuperSourceBox | 50 | Enums.SourceAvailability.SuperSourceArt, 51 | }) 52 | .addInternalColorsAndBlack() 53 | .addExternalInputs(20) 54 | .addMediaPlayers(2) 55 | .addUpstreamKeyMasks(8) 56 | .addDownstreamKeyMasksAndClean(2) 57 | .addAuxiliaryOutputs(12) 58 | .addSuperSource() 59 | .addProgramPreview() 60 | .generate(), 61 | fairlightAudio: { 62 | monitor: 'split', 63 | inputs: [ 64 | ...generateFairlightInputsOfType(1, 20, Enums.ExternalPortType.SDI), 65 | AUDIO_FAIRLIGHT_INPUT_TS_JACK, 66 | AUDIO_FAIRLIGHT_INPUT_TRS_JACK, 67 | ...generateFairlightInputMediaPlayer(2), 68 | ], 69 | audioRouting: { 70 | sources: [ 71 | AUDIO_ROUTING_SOURCE_NO_AUDIO, 72 | ...generateInputRoutingSources(20, false), 73 | AUDIO_ROUTING_SOURCE_MICROPHONE, 74 | AUDIO_ROUTING_SOURCE_TRS, 75 | ...generateMediaPlayerRoutingSources(2), 76 | ...generateTalkbackRoutingSources(false, false), 77 | AUDIO_ROUTING_SOURCE_PROGRAM, 78 | ...generateMixMinusRoutingSources(12), 79 | ], 80 | outputs: [ 81 | // 82 | ...generateAuxRoutingOutputs(12), 83 | ], 84 | }, 85 | }, 86 | } 87 | -------------------------------------------------------------------------------- /src/models/constellationHd1Me.ts: -------------------------------------------------------------------------------- 1 | import { generateOutputs, type ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { 4 | AUDIO_ROUTING_SOURCE_NO_AUDIO, 5 | generateInputRoutingSources, 6 | AUDIO_ROUTING_SOURCE_MICROPHONE, 7 | AUDIO_ROUTING_SOURCE_TRS, 8 | generateMediaPlayerRoutingSources, 9 | generateTalkbackRoutingSources, 10 | AUDIO_ROUTING_SOURCE_PROGRAM, 11 | generateMixMinusRoutingSources, 12 | generateAuxRoutingOutputs, 13 | } from './util/audioRouting.js' 14 | import { 15 | AUDIO_FAIRLIGHT_INPUT_TRS_JACK, 16 | AUDIO_FAIRLIGHT_INPUT_TS_JACK, 17 | generateFairlightInputMediaPlayer, 18 | generateFairlightInputsOfType, 19 | } from './util/fairlight.js' 20 | import { VideoInputGenerator } from './util/videoInput.js' 21 | 22 | export const ModelSpecConstellationHD1ME: ModelSpec = { 23 | id: Enums.Model.ConstellationHD1ME, 24 | label: '1 M/E Constellation HD', 25 | outputs: generateOutputs('Output', 6), 26 | MEs: 1, 27 | USKs: 4, 28 | DSKs: 1, 29 | MVs: 1, 30 | multiviewerFullGrid: true, 31 | DVEs: 1, 32 | SSrc: 0, 33 | macros: 100, 34 | displayClock: 1, 35 | media: { 36 | players: 2, 37 | stills: 20, 38 | clips: 2, 39 | captureStills: true, 40 | }, 41 | streaming: false, 42 | recording: false, 43 | recordISO: false, 44 | inputs: VideoInputGenerator.begin({ 45 | meCount: 1, 46 | baseSourceAvailability: 47 | Enums.SourceAvailability.Auxiliary | 48 | Enums.SourceAvailability.Multiviewer | 49 | Enums.SourceAvailability.SuperSourceBox | 50 | Enums.SourceAvailability.SuperSourceArt, 51 | }) 52 | .addInternalColorsAndBlack() 53 | .addExternalInputs(10) 54 | .addMediaPlayers(2) 55 | .addUpstreamKeyMasks(4) 56 | .addDownstreamKeyMasks(2) 57 | .addCleanFeeds(1) // TODO - is this correct? 58 | .addAuxiliaryOutputs(6) 59 | .addProgramPreview() 60 | .generate(), 61 | fairlightAudio: { 62 | monitor: 'split', 63 | inputs: [ 64 | ...generateFairlightInputsOfType(1, 10, Enums.ExternalPortType.SDI), 65 | AUDIO_FAIRLIGHT_INPUT_TS_JACK, 66 | AUDIO_FAIRLIGHT_INPUT_TRS_JACK, 67 | ...generateFairlightInputMediaPlayer(2), 68 | ], 69 | audioRouting: { 70 | sources: [ 71 | AUDIO_ROUTING_SOURCE_NO_AUDIO, 72 | ...generateInputRoutingSources(10, false), 73 | AUDIO_ROUTING_SOURCE_MICROPHONE, 74 | AUDIO_ROUTING_SOURCE_TRS, 75 | ...generateMediaPlayerRoutingSources(2), 76 | ...generateTalkbackRoutingSources(false, false), 77 | AUDIO_ROUTING_SOURCE_PROGRAM, 78 | ...generateMixMinusRoutingSources(6), 79 | ], 80 | outputs: [ 81 | // 82 | ...generateAuxRoutingOutputs(6), 83 | ], 84 | }, 85 | }, 86 | } 87 | -------------------------------------------------------------------------------- /src/presets/index.ts: -------------------------------------------------------------------------------- 1 | import { type CompanionPresetDefinitions } from '@companion-module/base' 2 | import { type AtemState } from 'atem-connection' 3 | import { GetSourcesListForType } from '../choices.js' 4 | import { type AtemConfig, PresetStyleName } from '../config.js' 5 | import type { ModelSpec } from '../models/index.js' 6 | import { type InstanceBaseExt } from '../util.js' 7 | import { createStreamingPresets } from './streaming.js' 8 | import { createRecordingPresets } from './recording.js' 9 | import { convertMyPresetDefinitions } from './wrapper.js' 10 | import type { ActionTypes } from '../actions/index.js' 11 | import type { FeedbackTypes } from '../feedback/index.js' 12 | import { createFadeToBlackPresets } from './fadeToBlack.js' 13 | import { createMediaPlayerPresets } from './mediaPlayer.js' 14 | import { createSuperSourcePresets } from './superSource.js' 15 | import { createMultiviewerPresets } from './multiviewer.js' 16 | import { createMacroPresets } from './macro.js' 17 | import { createProgramPreviewPresets } from './mixeffect/programPreview.js' 18 | import { createTransitionPresets } from './mixeffect/transition.js' 19 | import { createAuxOutputPresets } from './aux-outputs.js' 20 | import { createUpstreamKeyerPresets } from './mixeffect/upstreamKeyer.js' 21 | import { createDownstreamKeyerPresets } from './downstreamKeyer.js' 22 | 23 | const rateOptions = [12, 15, 25, 30, 37, 45, 50, 60] 24 | 25 | export function GetPresetsList( 26 | instance: InstanceBaseExt, 27 | model: ModelSpec, 28 | state: AtemState, 29 | ): CompanionPresetDefinitions { 30 | const pstText = Number(instance.config.presets) === PresetStyleName.Long ? 'long_' : 'short_' 31 | const pstSize = Number(instance.config.presets) === PresetStyleName.Long ? 'auto' : '18' 32 | 33 | const meSources = GetSourcesListForType(model, state, 'me') 34 | 35 | return convertMyPresetDefinitions([ 36 | ...createProgramPreviewPresets(model, pstSize, pstText, meSources), 37 | ...createTransitionPresets(model, pstSize, rateOptions), 38 | ...createAuxOutputPresets(model, state, pstSize, pstText), 39 | ...createUpstreamKeyerPresets(model, pstSize, pstText, meSources), 40 | ...createDownstreamKeyerPresets(model, pstSize, pstText, meSources), 41 | ...createMacroPresets(model), 42 | ...createMultiviewerPresets(model, state, pstSize, pstText), 43 | ...createSuperSourcePresets(model, pstSize, pstText, meSources), 44 | ...createMediaPlayerPresets(model, pstSize), 45 | ...createFadeToBlackPresets(model, pstSize, rateOptions), 46 | ...createStreamingPresets(model), 47 | ...createRecordingPresets(model), 48 | ]) 49 | } 50 | -------------------------------------------------------------------------------- /src/actions/types.ts: -------------------------------------------------------------------------------- 1 | import type { CompanionActionContext, SomeCompanionActionInputField } from '@companion-module/base' 2 | import type { MyOptionsHelper, MyOptionsObject } from '../common.js' 3 | 4 | /** 5 | * Basic information about an instance of an action 6 | */ 7 | export interface MyActionInfo2 { 8 | /** The unique id for this action */ 9 | readonly id: string 10 | /** The unique id for the location of this action */ 11 | readonly controlId: string 12 | /** The id of the action definition */ 13 | readonly actionId: string 14 | /** The user selected options for the action */ 15 | readonly options: MyOptionsHelper 16 | } 17 | /** 18 | * Extended information for execution of an action 19 | */ 20 | export interface MyActionEvent2 extends MyActionInfo2 { 21 | /** Identifier of the surface which triggered this action */ 22 | readonly surfaceId: string | undefined 23 | } 24 | 25 | export interface MyActionDefinition { 26 | /** Name to show in the actions list */ 27 | name: string 28 | /** Additional description of the action */ 29 | description?: string 30 | /** The input fields for the action */ 31 | options: MyOptionsObject 32 | /** 33 | * Ignore changes to certain options and don't allow them to trigger the subscribe/unsubscribe callbacks 34 | * This allows for ensuring that the subscribe callback is only called when values the action cares about change 35 | */ 36 | optionsToIgnoreForSubscribe?: string[] 37 | /** 38 | * If true, the unsubscribe callback will not be called when the options change, only when the action is removed or disabled 39 | */ 40 | skipUnsubscribeOnOptionsChange?: boolean 41 | 42 | /** Called to execute the action */ 43 | callback: (action: MyActionEvent2, context: CompanionActionContext) => Promise | void 44 | /** 45 | * Called to report the existence of an action 46 | * Useful to ensure necessary data is loaded 47 | */ 48 | subscribe?: (action: MyActionInfo2, context: CompanionActionContext) => Promise | void 49 | /** 50 | * Called to report an action has been edited/removed 51 | * Useful to cleanup subscriptions setup in subscribe 52 | */ 53 | unsubscribe?: (action: MyActionInfo2, context: CompanionActionContext) => Promise | void 54 | /** 55 | * The user requested to 'learn' the values for this action. 56 | */ 57 | learn?: ( 58 | action: MyActionEvent2, 59 | context: CompanionActionContext, 60 | ) => TOptions | undefined | Promise 61 | } 62 | 63 | export type MyActionDefinitions = { 64 | [Key in keyof TTypes]: MyActionDefinition | undefined 65 | } 66 | -------------------------------------------------------------------------------- /src/presets/macro.ts: -------------------------------------------------------------------------------- 1 | import { combineRgb } from '@companion-module/base' 2 | import { ActionId } from '../actions/ActionId.js' 3 | import { FeedbackId } from '../feedback/FeedbackId.js' 4 | import type { MyPresetDefinitionCategory } from './types.js' 5 | import type { ActionTypes } from '../actions/index.js' 6 | import type { FeedbackTypes } from '../feedback/index.js' 7 | import type { ModelSpec } from '../models/types.js' 8 | import { MacroFeedbackType } from '../feedback/macro.js' 9 | 10 | export function createMacroPresets(model: ModelSpec): MyPresetDefinitionCategory[] { 11 | const result: MyPresetDefinitionCategory[] = [] 12 | 13 | const category: MyPresetDefinitionCategory = { 14 | name: `MACROS`, 15 | presets: {}, 16 | } 17 | result.push(category) 18 | 19 | for (let macro = 0; macro < model.macros; macro++) { 20 | category.presets[`macro_run_${macro}`] = { 21 | name: `Run button for macro ${macro + 1}`, 22 | type: 'button', 23 | style: { 24 | text: `$(atem:macro_${macro + 1})`, 25 | size: 'auto', 26 | color: combineRgb(255, 255, 255), 27 | bgcolor: combineRgb(0, 0, 0), 28 | }, 29 | feedbacks: [ 30 | { 31 | feedbackId: FeedbackId.Macro, 32 | options: { 33 | macroIndex: macro + 1, 34 | state: MacroFeedbackType.IsUsed, 35 | }, 36 | style: { 37 | bgcolor: combineRgb(0, 0, 238), 38 | color: combineRgb(255, 255, 255), 39 | }, 40 | }, 41 | { 42 | feedbackId: FeedbackId.Macro, 43 | options: { 44 | macroIndex: macro + 1, 45 | state: MacroFeedbackType.IsRunning, 46 | }, 47 | style: { 48 | bgcolor: combineRgb(0, 238, 0), 49 | color: combineRgb(255, 255, 255), 50 | }, 51 | }, 52 | { 53 | feedbackId: FeedbackId.Macro, 54 | options: { 55 | macroIndex: macro + 1, 56 | state: MacroFeedbackType.IsWaiting, 57 | }, 58 | style: { 59 | bgcolor: combineRgb(238, 238, 0), 60 | color: combineRgb(255, 255, 255), 61 | }, 62 | }, 63 | { 64 | feedbackId: FeedbackId.Macro, 65 | options: { 66 | macroIndex: macro + 1, 67 | state: MacroFeedbackType.IsRecording, 68 | }, 69 | style: { 70 | bgcolor: combineRgb(238, 0, 0), 71 | color: combineRgb(255, 255, 255), 72 | }, 73 | }, 74 | ], 75 | steps: [ 76 | { 77 | down: [ 78 | { 79 | actionId: ActionId.MacroRun, 80 | options: { 81 | macro: macro + 1, 82 | action: 'runContinue', 83 | }, 84 | }, 85 | ], 86 | up: [], 87 | }, 88 | ], 89 | } 90 | } 91 | 92 | return result 93 | } 94 | -------------------------------------------------------------------------------- /src/presets/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CompanionButtonStyleProps, 3 | CompanionButtonPresetOptions, 4 | CompanionFeedbackButtonStyleResult, 5 | CompanionActionSetOptions, 6 | } from '@companion-module/base' 7 | 8 | export type MyPresetDefinitions = MyPresetDefinitionCategory[] 9 | export type MyPresetDefinitionCategory = { 10 | name: string 11 | presets: Record> 12 | } 13 | 14 | export interface MyButtonPresetDefinition { 15 | /** The type of this preset */ 16 | type: 'button' 17 | /** The category of this preset, for grouping */ 18 | // category: string 19 | /** The name of this preset */ 20 | name: string 21 | /** The base style of this preset, this will be copied to the button */ 22 | style: CompanionButtonStyleProps 23 | /** Preview style for preset, will be used in GUI for preview */ 24 | previewStyle?: CompanionButtonStyleProps 25 | /** Options for this preset */ 26 | options?: CompanionButtonPresetOptions 27 | /** The feedbacks on the button */ 28 | feedbacks: MyPresetFeedback[] 29 | steps: MyButtonStepActions[] 30 | } 31 | 32 | type MyPresetFeedbackInner = Id extends any 33 | ? { 34 | /** The id of the feedback definition */ 35 | feedbackId: Id 36 | /** The option values for the feedback */ 37 | options: TTypes[Id] 38 | /** 39 | * If a boolean feedback, the style effect of the feedback 40 | */ 41 | style?: CompanionFeedbackButtonStyleResult 42 | /** 43 | * If a boolean feedback, invert the value of the feedback 44 | */ 45 | isInverted?: boolean 46 | } 47 | : never 48 | 49 | export type MyPresetFeedback = MyPresetFeedbackInner 50 | 51 | type MyPresetActionInner = Id extends any 52 | ? { 53 | /** The id of the action definition */ 54 | actionId: Id 55 | /** The option values for the action */ 56 | options: TTypes[Id] 57 | /** The execution delay of the action */ 58 | delay?: number 59 | } 60 | : never 61 | 62 | export type MyPresetAction = MyPresetActionInner 63 | 64 | export interface MyButtonStepActions { 65 | /** The button down actions */ 66 | down: MyPresetAction[] 67 | /** The button up actions */ 68 | up: MyPresetAction[] 69 | rotate_left?: MyPresetAction[] 70 | rotate_right?: MyPresetAction[] 71 | [delay: number]: MyPresetActionsWithOptions | MyPresetAction[] 72 | } 73 | 74 | export interface MyPresetActionsWithOptions { 75 | options?: CompanionActionSetOptions 76 | actions: MyPresetAction[] 77 | } 78 | -------------------------------------------------------------------------------- /src/models/constellation8kas8k.ts: -------------------------------------------------------------------------------- 1 | import { generateOutputs, type ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { 4 | AUDIO_ROUTING_SOURCE_NO_AUDIO, 5 | generateInputRoutingSources, 6 | AUDIO_ROUTING_SOURCE_MICROPHONE, 7 | AUDIO_ROUTING_SOURCE_TRS, 8 | generateMadiRoutingSources, 9 | generateMediaPlayerRoutingSources, 10 | generateTalkbackRoutingSources, 11 | AUDIO_ROUTING_SOURCE_PROGRAM, 12 | generateMixMinusRoutingSources, 13 | generateAuxRoutingOutputs, 14 | generateMadiRoutingOutputs, 15 | } from './util/audioRouting.js' 16 | import { 17 | AUDIO_FAIRLIGHT_INPUT_TRS_JACK, 18 | AUDIO_FAIRLIGHT_INPUT_TS_JACK, 19 | generateFairlightInputMadi, 20 | generateFairlightInputMediaPlayer, 21 | generateFairlightInputsOfType, 22 | } from './util/fairlight.js' 23 | import { VideoInputGenerator } from './util/videoInput.js' 24 | 25 | export const ModelSpecConstellation8KAs8K: ModelSpec = { 26 | id: Enums.Model.Constellation8K, 27 | label: 'Constellation 8K (8K Mode)', 28 | outputs: generateOutputs('Output', 6), 29 | MEs: 1, 30 | USKs: 4, 31 | DSKs: 2, 32 | MVs: 1, 33 | multiviewerFullGrid: true, 34 | DVEs: 1, 35 | SSrc: 1, 36 | macros: 100, 37 | displayClock: 0, 38 | media: { players: 1, stills: 24, clips: 2, captureStills: false }, 39 | streaming: false, 40 | recording: false, 41 | recordISO: false, 42 | inputs: VideoInputGenerator.begin({ 43 | meCount: 1, 44 | baseSourceAvailability: 45 | Enums.SourceAvailability.Auxiliary | 46 | Enums.SourceAvailability.Multiviewer | 47 | Enums.SourceAvailability.SuperSourceBox | 48 | Enums.SourceAvailability.SuperSourceArt, 49 | }) 50 | .addInternalColorsAndBlack() 51 | .addExternalInputs(10) 52 | .addMediaPlayers(1) 53 | .addUpstreamKeyMasks(4) 54 | .addDownstreamKeyMasksAndClean(2) 55 | .addAuxiliaryOutputs(6) 56 | .addSuperSource() 57 | .addProgramPreview() 58 | .generate(), 59 | fairlightAudio: { 60 | monitor: 'split', 61 | audioRouting: { 62 | sources: [ 63 | AUDIO_ROUTING_SOURCE_NO_AUDIO, 64 | ...generateInputRoutingSources(10, true), 65 | AUDIO_ROUTING_SOURCE_MICROPHONE, 66 | AUDIO_ROUTING_SOURCE_TRS, 67 | ...generateMadiRoutingSources(32), 68 | ...generateMediaPlayerRoutingSources(1), 69 | ...generateTalkbackRoutingSources(true, false), 70 | AUDIO_ROUTING_SOURCE_PROGRAM, 71 | ...generateMixMinusRoutingSources(6), 72 | ], 73 | outputs: [ 74 | // 75 | ...generateMadiRoutingOutputs(8), 76 | ...generateAuxRoutingOutputs(6), 77 | ], 78 | }, 79 | inputs: [ 80 | ...generateFairlightInputsOfType(1, 10, Enums.ExternalPortType.SDI), 81 | AUDIO_FAIRLIGHT_INPUT_TS_JACK, 82 | AUDIO_FAIRLIGHT_INPUT_TRS_JACK, 83 | ...generateFairlightInputMadi(32), 84 | ...generateFairlightInputMediaPlayer(4), 85 | ], 86 | }, 87 | } 88 | -------------------------------------------------------------------------------- /src/models/constellationHd4Me.ts: -------------------------------------------------------------------------------- 1 | import { generateOutputs, type ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { 4 | AUDIO_ROUTING_SOURCE_MICROPHONE, 5 | AUDIO_ROUTING_SOURCE_NO_AUDIO, 6 | AUDIO_ROUTING_SOURCE_PROGRAM, 7 | AUDIO_ROUTING_SOURCE_TRS, 8 | generateAuxRoutingOutputs, 9 | generateInputRoutingSources, 10 | generateMadiRoutingOutputs, 11 | generateMadiRoutingSources, 12 | generateMediaPlayerRoutingSources, 13 | generateMixMinusRoutingSources, 14 | generateTalkbackRoutingSources, 15 | } from './util/audioRouting.js' 16 | import { 17 | AUDIO_FAIRLIGHT_INPUT_TRS_JACK, 18 | AUDIO_FAIRLIGHT_INPUT_TS_JACK, 19 | generateFairlightInputMadi, 20 | generateFairlightInputMediaPlayer, 21 | generateFairlightInputsOfType, 22 | } from './util/fairlight.js' 23 | import { VideoInputGenerator } from './util/videoInput.js' 24 | 25 | export const ModelSpecConstellationHD4ME: ModelSpec = { 26 | id: Enums.Model.ConstellationHD4ME, 27 | label: '4 M/E Constellation HD', 28 | outputs: generateOutputs('Output', 24), 29 | MEs: 4, 30 | USKs: 4, 31 | DSKs: 4, 32 | MVs: 4, 33 | multiviewerFullGrid: true, 34 | DVEs: 4, 35 | SSrc: 2, 36 | macros: 100, 37 | displayClock: 1, 38 | media: { players: 4, stills: 60, clips: 4, captureStills: true }, 39 | streaming: false, 40 | recording: false, 41 | recordISO: false, 42 | inputs: VideoInputGenerator.begin({ 43 | meCount: 4, 44 | baseSourceAvailability: 45 | Enums.SourceAvailability.Auxiliary | 46 | Enums.SourceAvailability.Multiviewer | 47 | Enums.SourceAvailability.SuperSourceBox | 48 | Enums.SourceAvailability.SuperSourceArt, 49 | }) 50 | .addInternalColorsAndBlack() 51 | .addExternalInputs(40) 52 | .addMediaPlayers(4) 53 | .addUpstreamKeyMasks(16) 54 | .addDownstreamKeyMasksAndClean(4) 55 | .addAuxiliaryOutputs(24) 56 | .addSuperSource(2) 57 | .addProgramPreview() 58 | .generate(), 59 | fairlightAudio: { 60 | monitor: 'split', 61 | inputs: [ 62 | ...generateFairlightInputsOfType(1, 40, Enums.ExternalPortType.SDI), 63 | AUDIO_FAIRLIGHT_INPUT_TS_JACK, 64 | AUDIO_FAIRLIGHT_INPUT_TRS_JACK, 65 | ...generateFairlightInputMadi(32), 66 | ...generateFairlightInputMediaPlayer(4), 67 | ], 68 | audioRouting: { 69 | sources: [ 70 | AUDIO_ROUTING_SOURCE_NO_AUDIO, 71 | ...generateInputRoutingSources(40, false), 72 | AUDIO_ROUTING_SOURCE_MICROPHONE, 73 | AUDIO_ROUTING_SOURCE_TRS, 74 | ...generateMadiRoutingSources(32), 75 | ...generateMediaPlayerRoutingSources(4), 76 | ...generateTalkbackRoutingSources(false, false), 77 | AUDIO_ROUTING_SOURCE_PROGRAM, 78 | ...generateMixMinusRoutingSources(24), 79 | ], 80 | outputs: [ 81 | // 82 | ...generateMadiRoutingOutputs(32), 83 | ...generateAuxRoutingOutputs(24), 84 | ], 85 | }, 86 | }, 87 | } 88 | -------------------------------------------------------------------------------- /src/feedback/mixeffect/fadeToBlack.ts: -------------------------------------------------------------------------------- 1 | import { AtemFadeToBlackStatePicker, AtemMEPicker, AtemRatePicker } from '../../input.js' 2 | import type { ModelSpec } from '../../models/index.js' 3 | import type { MyFeedbackDefinitions } from '../types.js' 4 | import { FeedbackId } from '../FeedbackId.js' 5 | import { combineRgb } from '@companion-module/base' 6 | import { getMixEffect, type StateWrapper } from '../../state.js' 7 | 8 | export interface AtemFadeToBlackFeedbacks { 9 | [FeedbackId.FadeToBlackIsBlack]: { 10 | mixeffect: number 11 | state: 'on' | 'off' | 'fading' 12 | } 13 | [FeedbackId.FadeToBlackRate]: { 14 | mixeffect: number 15 | rate: number 16 | } 17 | } 18 | 19 | export function createFadeToBlackFeedbacks( 20 | model: ModelSpec, 21 | state: StateWrapper, 22 | ): MyFeedbackDefinitions { 23 | return { 24 | [FeedbackId.FadeToBlackIsBlack]: { 25 | type: 'boolean', 26 | name: 'Fade to black: Active', 27 | description: 'If the specified fade to black is active, change style of the bank', 28 | options: { 29 | mixeffect: AtemMEPicker(model, 0), 30 | state: AtemFadeToBlackStatePicker(), 31 | }, 32 | defaultStyle: { 33 | color: combineRgb(0, 0, 0), 34 | bgcolor: combineRgb(255, 255, 0), 35 | }, 36 | callback: ({ options }): boolean => { 37 | const me = getMixEffect(state.state, options.getPlainNumber('mixeffect')) 38 | if (me && me.fadeToBlack) { 39 | switch (options.getPlainString('state')) { 40 | case 'off': 41 | return !me.fadeToBlack.isFullyBlack && !me.fadeToBlack.inTransition 42 | case 'fading': 43 | return me.fadeToBlack.inTransition 44 | default: 45 | // on 46 | return !me.fadeToBlack.inTransition && me.fadeToBlack.isFullyBlack 47 | } 48 | } 49 | return false 50 | }, 51 | }, 52 | [FeedbackId.FadeToBlackRate]: { 53 | type: 'boolean', 54 | name: 'Fade to black: Rate', 55 | description: 'If the specified fade to black rate matches, change style of the bank', 56 | options: { 57 | mixeffect: AtemMEPicker(model, 0), 58 | rate: AtemRatePicker('Rate'), 59 | }, 60 | defaultStyle: { 61 | color: combineRgb(0, 0, 0), 62 | bgcolor: combineRgb(255, 255, 0), 63 | }, 64 | callback: ({ options }): boolean => { 65 | const me = getMixEffect(state.state, options.getPlainNumber('mixeffect')) 66 | const rate = options.getPlainNumber('rate') 67 | return me?.fadeToBlack?.rate === rate 68 | }, 69 | learn: ({ options }) => { 70 | const me = getMixEffect(state.state, options.getPlainNumber('mixeffect')) 71 | 72 | if (me?.fadeToBlack) { 73 | return { 74 | ...options.getJson(), 75 | rate: me.fadeToBlack.rate, 76 | } 77 | } else { 78 | return undefined 79 | } 80 | }, 81 | }, 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/models/constellation4K4MePlus.ts: -------------------------------------------------------------------------------- 1 | import { generateOutputs, type ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { 4 | AUDIO_ROUTING_SOURCE_MICROPHONE, 5 | AUDIO_ROUTING_SOURCE_NO_AUDIO, 6 | AUDIO_ROUTING_SOURCE_PROGRAM, 7 | AUDIO_ROUTING_SOURCE_TRS, 8 | generateAuxRoutingOutputs, 9 | generateInputRoutingSources, 10 | generateMadiRoutingOutputs, 11 | generateMadiRoutingSources, 12 | generateMediaPlayerRoutingSources, 13 | generateMixMinusRoutingSources, 14 | generateTalkbackRoutingSources, 15 | } from './util/audioRouting.js' 16 | import { 17 | AUDIO_FAIRLIGHT_INPUT_TRS_JACK, 18 | AUDIO_FAIRLIGHT_INPUT_TS_JACK, 19 | generateFairlightInputMadi, 20 | generateFairlightInputMediaPlayer, 21 | generateFairlightInputsOfType, 22 | } from './util/fairlight.js' 23 | import { VideoInputGenerator } from './util/videoInput.js' 24 | 25 | export const ModelSpecConstellation4K4MEPlus: ModelSpec = { 26 | id: Enums.Model.Constellation4K4MEPlus, 27 | label: '4 M/E Constellation 4K Plus', 28 | outputs: generateOutputs('Output', 48), 29 | MEs: 4, 30 | USKs: 4, 31 | DSKs: 4, 32 | MVs: 4, 33 | multiviewerFullGrid: true, 34 | DVEs: 4, 35 | SSrc: 2, 36 | macros: 100, 37 | displayClock: 1, 38 | media: { 39 | players: 4, 40 | stills: 64, 41 | clips: 4, 42 | captureStills: true, 43 | }, 44 | streaming: false, 45 | recording: false, 46 | recordISO: false, 47 | inputs: VideoInputGenerator.begin({ 48 | meCount: 4, 49 | baseSourceAvailability: 50 | Enums.SourceAvailability.Auxiliary | 51 | Enums.SourceAvailability.Multiviewer | 52 | Enums.SourceAvailability.SuperSourceBox | 53 | Enums.SourceAvailability.SuperSourceArt, 54 | }) 55 | .addInternalColorsAndBlack() 56 | .addExternalInputs(80) 57 | .addMediaPlayers(4) 58 | .addUpstreamKeyMasks(16) 59 | .addDownstreamKeyMasksAndClean(4) 60 | .addAuxiliaryOutputs(24) 61 | .addSuperSource(2) 62 | .addProgramPreview() 63 | .generate(), 64 | fairlightAudio: { 65 | monitor: 'split', 66 | inputs: [ 67 | ...generateFairlightInputsOfType(1, 80, Enums.ExternalPortType.SDI), 68 | AUDIO_FAIRLIGHT_INPUT_TS_JACK, 69 | AUDIO_FAIRLIGHT_INPUT_TRS_JACK, 70 | ...generateFairlightInputMadi(64), 71 | ...generateFairlightInputMediaPlayer(4), 72 | ], 73 | audioRouting: { 74 | sources: [ 75 | AUDIO_ROUTING_SOURCE_NO_AUDIO, 76 | ...generateInputRoutingSources(80, false), 77 | AUDIO_ROUTING_SOURCE_MICROPHONE, 78 | AUDIO_ROUTING_SOURCE_TRS, 79 | ...generateMadiRoutingSources(64), 80 | ...generateMediaPlayerRoutingSources(4), 81 | ...generateTalkbackRoutingSources(false, false), 82 | AUDIO_ROUTING_SOURCE_PROGRAM, 83 | ...generateMixMinusRoutingSources(48), 84 | ], 85 | outputs: [ 86 | // 87 | ...generateMadiRoutingOutputs(64), 88 | ...generateAuxRoutingOutputs(48), 89 | ], 90 | }, 91 | }, 92 | } 93 | -------------------------------------------------------------------------------- /src/batching.ts: -------------------------------------------------------------------------------- 1 | import { Enums } from 'atem-connection' 2 | 3 | export class AtemCommandBatching { 4 | public readonly meTransitionSelection = new Map>() 5 | } 6 | 7 | export class CommandBatching { 8 | private readonly executeFunction: (value: T) => Promise 9 | private readonly maxBatch: number 10 | private readonly delayStep: number 11 | 12 | private inflight: { 13 | completion: Promise | undefined // For when it is executing 14 | timer: NodeJS.Timeout | undefined // For when it is delayed 15 | batched: number // number of updates batched here 16 | nextValue: T | undefined 17 | } 18 | 19 | constructor(executeFunction: (value: T) => Promise, options: { delayStep: number; maxBatch: number }) { 20 | this.executeFunction = executeFunction 21 | this.maxBatch = options.maxBatch 22 | this.delayStep = options.delayStep 23 | 24 | this.inflight = { 25 | completion: undefined, 26 | timer: undefined, 27 | batched: 0, 28 | nextValue: undefined, 29 | } 30 | } 31 | 32 | queueChange(currentValue: T, modifier: (oldValue: T) => T): void { 33 | // Update the nextValue, and mark the change 34 | this.inflight.batched++ 35 | if (!this.inflight.completion && this.inflight.batched <= this.maxBatch) { 36 | this.updateBatchDelayedTimer() 37 | } 38 | this.inflight.nextValue = modifier(this.inflight.nextValue ?? currentValue) 39 | } 40 | 41 | private updateBatchDelayedTimer(): void { 42 | if (this.inflight.timer) { 43 | clearTimeout(this.inflight.timer) 44 | } 45 | this.inflight.timer = setTimeout(() => { 46 | this.inflight.timer = undefined 47 | 48 | if (!this.inflight.completion && this.inflight.nextValue !== undefined && this.inflight.batched > 0) { 49 | // reset batch counting 50 | this.inflight.timer = undefined 51 | this.inflight.batched = 0 52 | // send it off 53 | this.inflight.completion = this.executeFunction(this.inflight.nextValue).then(() => this.inflightCompleted()) 54 | } 55 | }, this.delayStep) 56 | } 57 | 58 | private inflightCompleted(): void { 59 | this.inflight.completion = undefined 60 | if (this.inflight.timer) { 61 | clearTimeout(this.inflight.timer) 62 | this.inflight.timer = undefined 63 | } 64 | 65 | // The command send has completed, so check if another batch is ready 66 | if (this.inflight.batched === 0 || this.inflight.nextValue === undefined) { 67 | this.inflight.batched = 0 68 | this.inflight.nextValue = undefined 69 | } else if (this.inflight.batched >= this.maxBatch) { 70 | // send it off 71 | this.inflight.batched = 0 72 | this.inflight.completion = this.executeFunction(this.inflight.nextValue).then(() => this.inflightCompleted()) 73 | } else { 74 | // Batch can wait a bit longer, so lets do that 75 | this.updateBatchDelayedTimer() 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/models/constellation8kAsHdOr4k.ts: -------------------------------------------------------------------------------- 1 | import { generateOutputs, type ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { 4 | AUDIO_ROUTING_SOURCE_MICROPHONE, 5 | AUDIO_ROUTING_SOURCE_NO_AUDIO, 6 | AUDIO_ROUTING_SOURCE_PROGRAM, 7 | AUDIO_ROUTING_SOURCE_TRS, 8 | generateMixMinusRoutingSources, 9 | generateAuxRoutingOutputs, 10 | generateInputRoutingSources, 11 | generateMadiRoutingOutputs, 12 | generateMadiRoutingSources, 13 | generateMediaPlayerRoutingSources, 14 | generateTalkbackRoutingSources, 15 | } from './util/audioRouting.js' 16 | import { 17 | AUDIO_FAIRLIGHT_INPUT_TRS_JACK, 18 | AUDIO_FAIRLIGHT_INPUT_TS_JACK, 19 | generateFairlightInputMadi, 20 | generateFairlightInputMediaPlayer, 21 | generateFairlightInputsOfType, 22 | } from './util/fairlight.js' 23 | import { VideoInputGenerator } from './util/videoInput.js' 24 | 25 | export const ModelSpecConstellationAsHDOr4K: ModelSpec = { 26 | id: Enums.Model.Constellation, 27 | label: 'Constellation 8K (HD/4K Mode)', 28 | outputs: generateOutputs('Output', 24), 29 | MEs: 4, 30 | USKs: 4, 31 | DSKs: 4, 32 | MVs: 4, 33 | multiviewerFullGrid: true, 34 | DVEs: 4, 35 | SSrc: 2, 36 | macros: 100, 37 | displayClock: 0, 38 | media: { players: 4, stills: 64, clips: 4, captureStills: false }, 39 | streaming: false, 40 | recording: false, 41 | recordISO: false, 42 | inputs: VideoInputGenerator.begin({ 43 | meCount: 4, 44 | baseSourceAvailability: 45 | Enums.SourceAvailability.Auxiliary | 46 | Enums.SourceAvailability.Multiviewer | 47 | Enums.SourceAvailability.SuperSourceBox | 48 | Enums.SourceAvailability.SuperSourceArt, 49 | }) 50 | .addInternalColorsAndBlack() 51 | .addExternalInputs(40) 52 | .addMediaPlayers(4) 53 | .addUpstreamKeyMasks(16) 54 | .addDownstreamKeyMasksAndClean(4) 55 | .addAuxiliaryOutputs(24) 56 | .addSuperSource(2) 57 | .addProgramPreview() 58 | .generate(), 59 | fairlightAudio: { 60 | monitor: 'split', 61 | audioRouting: { 62 | // TODO: this is a guess based on the hd/4k setup 63 | sources: [ 64 | AUDIO_ROUTING_SOURCE_NO_AUDIO, 65 | ...generateInputRoutingSources(40, true), 66 | AUDIO_ROUTING_SOURCE_MICROPHONE, 67 | AUDIO_ROUTING_SOURCE_TRS, 68 | ...generateMadiRoutingSources(32), 69 | ...generateMediaPlayerRoutingSources(4), 70 | ...generateTalkbackRoutingSources(true, false), 71 | AUDIO_ROUTING_SOURCE_PROGRAM, 72 | ...generateMixMinusRoutingSources(24), 73 | ], 74 | outputs: [ 75 | // 76 | ...generateMadiRoutingOutputs(32), 77 | ...generateAuxRoutingOutputs(24), 78 | ], 79 | }, 80 | inputs: [ 81 | ...generateFairlightInputsOfType(1, 40, Enums.ExternalPortType.SDI), 82 | AUDIO_FAIRLIGHT_INPUT_TS_JACK, 83 | AUDIO_FAIRLIGHT_INPUT_TRS_JACK, 84 | ...generateFairlightInputMadi(32), 85 | ...generateFairlightInputMediaPlayer(4), 86 | ], 87 | }, 88 | } 89 | -------------------------------------------------------------------------------- /src/models/2me.ts: -------------------------------------------------------------------------------- 1 | import { generateOutputs, type ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { VideoInputGenerator } from './util/videoInput.js' 4 | 5 | export const ModelSpecTwoME: ModelSpec = { 6 | id: Enums.Model.TwoME, 7 | label: '2 ME Production', 8 | outputs: generateOutputs('Aux', 6), 9 | MEs: 2, 10 | USKs: 2, 11 | DSKs: 2, 12 | MVs: 2, 13 | multiviewerFullGrid: false, 14 | DVEs: 1, 15 | SSrc: 1, 16 | macros: 100, 17 | displayClock: 0, 18 | media: { 19 | players: 2, 20 | stills: 32, 21 | clips: 2, 22 | captureStills: false, 23 | }, 24 | streaming: false, 25 | recording: false, 26 | recordISO: false, 27 | inputs: VideoInputGenerator.begin({ 28 | meCount: 2, 29 | baseSourceAvailability: 30 | Enums.SourceAvailability.Auxiliary | 31 | Enums.SourceAvailability.Multiviewer | 32 | Enums.SourceAvailability.SuperSourceBox | 33 | Enums.SourceAvailability.SuperSourceArt, 34 | }) 35 | .addInternalColorsAndBlack() 36 | .addExternalInputs(16) 37 | .addMediaPlayers(2) 38 | .addCleanFeeds(2) 39 | .addAuxiliaryOutputs(6) 40 | .addSuperSource() 41 | .addProgramPreview() 42 | .generate(), 43 | classicAudio: { 44 | inputs: [ 45 | { 46 | id: 1, 47 | portType: Enums.ExternalPortType.SDI, 48 | }, 49 | { 50 | id: 2, 51 | portType: Enums.ExternalPortType.SDI, 52 | }, 53 | { 54 | id: 3, 55 | portType: Enums.ExternalPortType.SDI, 56 | }, 57 | { 58 | id: 4, 59 | portType: Enums.ExternalPortType.SDI, 60 | }, 61 | { 62 | id: 5, 63 | portType: Enums.ExternalPortType.SDI, 64 | }, 65 | { 66 | id: 6, 67 | portType: Enums.ExternalPortType.SDI, 68 | }, 69 | { 70 | id: 7, 71 | portType: Enums.ExternalPortType.SDI, 72 | }, 73 | { 74 | id: 8, 75 | portType: Enums.ExternalPortType.SDI, 76 | }, 77 | { 78 | id: 9, 79 | portType: Enums.ExternalPortType.SDI, 80 | }, 81 | { 82 | id: 10, 83 | portType: Enums.ExternalPortType.SDI, 84 | }, 85 | { 86 | id: 11, 87 | portType: Enums.ExternalPortType.SDI, 88 | }, 89 | { 90 | id: 12, 91 | portType: Enums.ExternalPortType.SDI, 92 | }, 93 | { 94 | id: 13, 95 | portType: Enums.ExternalPortType.SDI, 96 | }, 97 | { 98 | id: 14, 99 | portType: Enums.ExternalPortType.SDI, 100 | }, 101 | { 102 | id: 15, 103 | portType: Enums.ExternalPortType.SDI, 104 | }, 105 | { 106 | id: 16, 107 | portType: Enums.ExternalPortType.SDI, 108 | }, 109 | { 110 | id: 1001, 111 | portType: Enums.ExternalPortType.XLR, 112 | }, 113 | { 114 | id: 2001, 115 | portType: Enums.ExternalPortType.Internal, 116 | }, 117 | { 118 | id: 2002, 119 | portType: Enums.ExternalPortType.Internal, 120 | }, 121 | ], 122 | }, 123 | } 124 | -------------------------------------------------------------------------------- /src/presets/wrapper.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CompanionButtonPresetDefinition, 3 | CompanionButtonStepActions, 4 | CompanionPresetAction, 5 | CompanionPresetActionsWithOptions, 6 | CompanionPresetDefinitions, 7 | CompanionPresetFeedback, 8 | } from '@companion-module/base' 9 | import type { 10 | MyButtonPresetDefinition, 11 | MyButtonStepActions, 12 | MyPresetAction, 13 | MyPresetDefinitionCategory, 14 | } from './types.js' 15 | import type { Complete } from '@companion-module/base/dist/util.js' 16 | 17 | function wrapAction(action: MyPresetAction): CompanionPresetAction { 18 | return { 19 | actionId: String(action.actionId), 20 | headline: undefined, 21 | options: action.options, 22 | delay: action.delay, 23 | } satisfies Complete 24 | } 25 | 26 | function wrapStep(step: MyButtonStepActions): CompanionButtonStepActions { 27 | const res: CompanionButtonStepActions = { 28 | name: undefined, 29 | up: step.up.map(wrapAction), 30 | down: step.down.map(wrapAction), 31 | rotate_left: step.rotate_left?.map(wrapAction), 32 | rotate_right: step.rotate_right?.map(wrapAction), 33 | } satisfies Complete 34 | 35 | const keys = Object.keys(step) 36 | .map((k) => Number(k)) 37 | .filter((k) => !isNaN(k)) 38 | for (const delay of keys) { 39 | const src = step[delay] 40 | if (!src) continue 41 | res[delay] = Array.isArray(src) 42 | ? src.map(wrapAction) 43 | : ({ 44 | options: src.options, 45 | actions: src.actions.map(wrapAction), 46 | } satisfies Complete) 47 | } 48 | 49 | return res 50 | } 51 | 52 | function convertMyPresetToCompanionPreset( 53 | rawPreset: MyButtonPresetDefinition, 54 | category: MyPresetDefinitionCategory, 55 | ): CompanionButtonPresetDefinition { 56 | return { 57 | type: rawPreset.type, 58 | name: rawPreset.name, 59 | category: category.name, 60 | style: rawPreset.style, 61 | previewStyle: rawPreset.previewStyle, 62 | options: rawPreset.options, 63 | feedbacks: rawPreset.feedbacks.map( 64 | (feedback) => 65 | ({ 66 | feedbackId: String(feedback.feedbackId), 67 | headline: undefined, 68 | options: feedback.options, 69 | style: feedback.style, 70 | isInverted: feedback.isInverted, 71 | }) satisfies Complete, 72 | ), 73 | steps: rawPreset.steps.map(wrapStep), 74 | } satisfies Complete 75 | } 76 | 77 | export function convertMyPresetDefinitions( 78 | presets: (MyPresetDefinitionCategory | undefined)[], 79 | ): CompanionPresetDefinitions { 80 | const res: CompanionPresetDefinitions = {} 81 | 82 | for (const category of presets) { 83 | if (!category) continue 84 | 85 | for (const [id, preset] of Object.entries(category.presets)) { 86 | if (!preset) continue 87 | 88 | res[id] = convertMyPresetToCompanionPreset(preset, category) 89 | } 90 | } 91 | 92 | return res 93 | } 94 | -------------------------------------------------------------------------------- /src/feedback/FeedbackId.ts: -------------------------------------------------------------------------------- 1 | export enum FeedbackId { 2 | PreviewBG = 'preview_bg', 3 | PreviewVariables = 'previewVariables', 4 | PreviewBG2 = 'preview_bg_2', 5 | PreviewBG3 = 'preview_bg_3', 6 | PreviewBG4 = 'preview_bg_4', 7 | ProgramBG = 'program_bg', 8 | ProgramVariables = 'programVariables', 9 | ProgramBG2 = 'program_bg_2', 10 | ProgramBG3 = 'program_bg_3', 11 | ProgramBG4 = 'program_bg_4', 12 | AuxBG = 'aux_bg', 13 | AuxVariables = 'auxVariables', 14 | USKOnAir = 'usk_bg', 15 | USKType = 'usk_type', 16 | USKSource = 'usk_source', 17 | USKSourceVariables = 'usk_source_variables', 18 | USKKeyFrame = 'usk_keyframe', 19 | DSKOnAir = 'dsk_bg', 20 | DSKTie = 'dskTie', 21 | DSKSource = 'dsk_source', 22 | DSKSourceVariables = 'dsk_source_variables', 23 | Macro = 'macro', 24 | MacroLoop = 'macroloop', 25 | MVSource = 'mv_source', 26 | MVSourceVariables = 'mv_source_variables', 27 | MultiviewerLayout = 'multiviewerLayout', 28 | SSrcArtProperties = 'ssrc_art_properties', 29 | SSrcArtPropertiesVariables = 'ssrcArtPropertiesVariables', 30 | SSrcArtSource = 'ssrc_art_source', 31 | SSrcArtOption = 'ssrc_art_option', 32 | SSrcBoxOnAir = 'ssrc_box_enable', 33 | SSrcBoxSource = 'ssrc_box_source', 34 | SSrcBoxSourceVariables = 'ssrc_box_source_variables', 35 | SSrcBoxProperties = 'ssrc_box_properties', 36 | PreviewTransition = 'previewTransition', 37 | TransitionStyle = 'transitionStyle', 38 | TransitionSelection = 'transitionSelection', 39 | TransitionRate = 'transitionRate', 40 | InTransition = 'inTransition', 41 | MediaPlayerSource = 'mediaPlayerSource', 42 | MediaPlayerSourceVariables = 'mediaPlayerSourceVariables', 43 | MediaPoolPreview = 'mediaPoolPreview', 44 | MediaPoolPreviewVariables = 'mediaPoolPreviewVariables', 45 | FadeToBlackIsBlack = 'fadeToBlackIsBlack', 46 | FadeToBlackRate = 'fadeToBlackRate', 47 | ProgramTally = 'program_tally', 48 | PreviewTally = 'preview_tally', 49 | AdvancedTally = 'advanced_tally', 50 | StreamStatus = 'streamStatus', 51 | RecordStatus = 'recordStatus', 52 | RecordISO = 'recordISO', 53 | ClassicAudioGain = 'classicAudioGain', 54 | ClassicAudioMixOption = 'classicAudioMixOption', 55 | ClassicAudioMasterGain = 'classicAudioMasterGain', 56 | FairlightAudioFaderGain = 'fairlightAudioFaderGain', 57 | FairlightAudioInputGain = 'fairlightAudioInputGain', 58 | FairlightAudioMixOption = 'fairlightAudioMixOption', 59 | FairlightAudioMasterGain = 'fairlightAudioMasterGain', 60 | FairlightAudioMonitorSolo = 'fairlightAudioMonitorSolo', 61 | FairlightAudioMonitorOutputFaderGain = 'fairlightAudioMonitorFaderGain', 62 | FairlightAudioMonitorMasterMuted = 'fairlightAudioMonitorMasterMuted', 63 | FairlightAudioMonitorMasterGain = 'fairlightAudioMonitorMasterGain', 64 | FairlightAudioMonitorTalkbackMuted = 'fairlightAudioMonitorTalkbackMuted', 65 | FairlightAudioMonitorTalkbackGain = 'fairlightAudioMonitorTalkbackGain', 66 | FairlightAudioMonitorSidetoneGain = 'fairlightAudioMonitorSidetoneGain', 67 | FairlightAudioRouting = 'fairlightAudioRouting', 68 | FairlightAudioRoutingVariables = 'fairlightAudioRoutingVariables', 69 | TimecodeMode = 'timecodeMode', 70 | } 71 | -------------------------------------------------------------------------------- /src/actions/macro.ts: -------------------------------------------------------------------------------- 1 | import { type Atem } from 'atem-connection' 2 | import type { ModelSpec } from '../models/index.js' 3 | import { ActionId } from './ActionId.js' 4 | import type { MyActionDefinitions } from './types.js' 5 | import { GetMacroChoices, CHOICES_ON_OFF_TOGGLE, type TrueFalseToggle } from '../choices.js' 6 | import type { StateWrapper } from '../state.js' 7 | 8 | export interface AtemMacroActions { 9 | [ActionId.MacroRun]: { 10 | macro: number 11 | action: 'run' | 'runContinue' 12 | } 13 | [ActionId.MacroContinue]: Record 14 | [ActionId.MacroStop]: Record 15 | [ActionId.MacroLoop]: { 16 | loop: TrueFalseToggle 17 | } 18 | } 19 | 20 | export function createMacroActions( 21 | atem: Atem | undefined, 22 | model: ModelSpec, 23 | state: StateWrapper, 24 | ): MyActionDefinitions { 25 | if (!model.macros) { 26 | return { 27 | [ActionId.MacroRun]: undefined, 28 | [ActionId.MacroContinue]: undefined, 29 | [ActionId.MacroStop]: undefined, 30 | [ActionId.MacroLoop]: undefined, 31 | } 32 | } 33 | return { 34 | [ActionId.MacroRun]: { 35 | name: 'Macro: Run', 36 | options: { 37 | macro: { 38 | type: 'dropdown', 39 | id: 'macro', 40 | label: 'Macro', 41 | default: 1, 42 | choices: GetMacroChoices(model, state.state), 43 | }, 44 | action: { 45 | type: 'dropdown', 46 | id: 'action', 47 | label: 'Action', 48 | default: 'run', 49 | choices: [ 50 | { id: 'run', label: 'Run' }, 51 | { id: 'runContinue', label: 'Run/Continue' }, 52 | ], 53 | }, 54 | }, 55 | callback: async ({ options }) => { 56 | const macroIndex = options.getPlainNumber('macro') - 1 57 | const { macroPlayer, macroRecorder } = state.state.macro 58 | if ( 59 | options.getPlainString('action') === 'runContinue' && 60 | macroPlayer.isWaiting && 61 | macroPlayer.macroIndex === macroIndex 62 | ) { 63 | await atem?.macroContinue() 64 | } else if (macroRecorder.isRecording && macroRecorder.macroIndex === macroIndex) { 65 | await atem?.macroStopRecord() 66 | } else { 67 | await atem?.macroRun(macroIndex) 68 | } 69 | }, 70 | }, 71 | [ActionId.MacroContinue]: { 72 | name: 'Macro: Continue', 73 | options: {}, 74 | callback: async () => { 75 | await atem?.macroContinue() 76 | }, 77 | }, 78 | [ActionId.MacroStop]: { 79 | name: 'Macro: Stop', 80 | options: {}, 81 | callback: async () => { 82 | await atem?.macroStop() 83 | }, 84 | }, 85 | [ActionId.MacroLoop]: { 86 | name: 'Macro: Loop', 87 | options: { 88 | loop: { 89 | id: 'loop', 90 | type: 'dropdown', 91 | label: 'Loop', 92 | default: 'toggle', 93 | choices: CHOICES_ON_OFF_TOGGLE, 94 | }, 95 | }, 96 | callback: async ({ options }) => { 97 | let newState = options.getPlainString('loop') === 'true' 98 | if (options.getPlainString('loop') === 'toggle') { 99 | newState = !state.state.macro.macroPlayer.loop 100 | } 101 | 102 | await atem?.macroSetLoop(newState) 103 | }, 104 | }, 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/actions/wrapper.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CompanionActionContext, 3 | CompanionActionDefinition, 4 | CompanionActionInfo, 5 | CompanionOptionValues, 6 | SomeCompanionActionInputField, 7 | } from '@companion-module/base' 8 | import type { MyActionDefinition, MyActionEvent2, MyActionInfo2 } from './types.js' 9 | import type { Complete } from '@companion-module/base/dist/util.js' 10 | import { MyOptionsHelperImpl, type MyOptionsObject } from '../common.js' 11 | 12 | function rewrapActionInfo( 13 | action: CompanionActionInfo, 14 | context: CompanionActionContext, 15 | fields: MyOptionsObject, 16 | ): MyActionInfo2 { 17 | return { 18 | id: action.id, 19 | controlId: action.controlId, 20 | actionId: action.actionId, 21 | 22 | options: new MyOptionsHelperImpl(action.options, context, fields), 23 | } satisfies Complete> 24 | } 25 | 26 | function convertMyActionToCompanionAction( 27 | actionDef: MyActionDefinition, 28 | ): CompanionActionDefinition { 29 | const { subscribe, unsubscribe, learn } = actionDef 30 | 31 | return { 32 | name: actionDef.name, 33 | description: actionDef.description, 34 | options: Object.entries(actionDef.options) 35 | .filter((o) => !!o[1]) 36 | .map(([id, option]) => ({ 37 | ...(option as SomeCompanionActionInputField), 38 | id, 39 | })), 40 | optionsToIgnoreForSubscribe: actionDef.optionsToIgnoreForSubscribe, 41 | skipUnsubscribeOnOptionsChange: actionDef.skipUnsubscribeOnOptionsChange, 42 | callback: async (action, context) => { 43 | return actionDef.callback( 44 | { 45 | ...rewrapActionInfo(action, context, actionDef.options), 46 | 47 | surfaceId: action.surfaceId, 48 | } satisfies Complete>, 49 | context, 50 | ) 51 | }, 52 | subscribe: subscribe 53 | ? async (action, context) => { 54 | return subscribe(rewrapActionInfo(action, context, actionDef.options), context) 55 | } 56 | : undefined, 57 | unsubscribe: unsubscribe 58 | ? async (action, context) => { 59 | return unsubscribe(rewrapActionInfo(action, context, actionDef.options), context) 60 | } 61 | : undefined, 62 | learn: learn 63 | ? async (action, context) => { 64 | return learn( 65 | { 66 | ...rewrapActionInfo(action, context, actionDef.options), 67 | 68 | surfaceId: action.surfaceId, 69 | } satisfies Complete>, 70 | context, 71 | ) as CompanionOptionValues | undefined | Promise 72 | } 73 | : undefined, 74 | learnTimeout: undefined, 75 | } satisfies Complete 76 | } 77 | 78 | type Test = { 79 | [Key in keyof TTypes]: TTypes[Key] extends MyActionDefinition ? CompanionActionDefinition : never 80 | } 81 | 82 | export function convertMyActionDefinitions | undefined>>( 83 | actionDefs: TTypes, 84 | ): Test { 85 | const res: Test = {} as any 86 | 87 | for (const [id, def] of Object.entries(actionDefs)) { 88 | ;(res as any)[id] = def ? convertMyActionToCompanionAction(def) : undefined 89 | } 90 | 91 | return res 92 | } 93 | -------------------------------------------------------------------------------- /src/actions/streaming.ts: -------------------------------------------------------------------------------- 1 | import { Enums, type Atem } from 'atem-connection' 2 | import type { ModelSpec } from '../models/index.js' 3 | import { ActionId } from './ActionId.js' 4 | import type { MyActionDefinitions } from './types.js' 5 | import { CHOICES_ON_OFF_TOGGLE, type TrueFalseToggle } from '../choices.js' 6 | import type { StateWrapper } from '../state.js' 7 | 8 | export interface AtemStreamingActions { 9 | [ActionId.StreamStartStop]: { 10 | stream: TrueFalseToggle 11 | } 12 | [ActionId.StreamService]: { 13 | service: string 14 | url: string 15 | key: string 16 | } 17 | } 18 | 19 | export function createStreamingActions( 20 | atem: Atem | undefined, 21 | model: ModelSpec, 22 | state: StateWrapper, 23 | ): MyActionDefinitions { 24 | if (!model.streaming) { 25 | return { 26 | [ActionId.StreamStartStop]: undefined, 27 | [ActionId.StreamService]: undefined, 28 | } 29 | } 30 | return { 31 | [ActionId.StreamStartStop]: { 32 | name: 'Stream: Start or Stop', 33 | options: { 34 | stream: { 35 | id: 'stream', 36 | type: 'dropdown', 37 | label: 'Stream', 38 | default: 'toggle', 39 | choices: CHOICES_ON_OFF_TOGGLE, 40 | }, 41 | }, 42 | callback: async ({ options }) => { 43 | let newState = options.getPlainString('stream') === 'true' 44 | if (options.getPlainString('stream') === 'toggle') { 45 | newState = state.state.streaming?.status?.state === Enums.StreamingStatus.Idle 46 | } 47 | 48 | if (newState) { 49 | await atem?.startStreaming() 50 | } else { 51 | await atem?.stopStreaming() 52 | } 53 | }, 54 | learn: ({ options }) => { 55 | if (state.state.streaming?.status) { 56 | return { 57 | ...options.getJson(), 58 | state: state.state.streaming.status.state, 59 | } 60 | } else { 61 | return undefined 62 | } 63 | }, 64 | }, 65 | [ActionId.StreamService]: { 66 | name: 'Stream: Set service', 67 | options: { 68 | service: { 69 | id: 'service', 70 | label: 'Service', 71 | type: 'textinput', 72 | default: '', 73 | useVariables: true, 74 | }, 75 | url: { 76 | id: 'url', 77 | label: 'URL', 78 | type: 'textinput', 79 | default: '', 80 | useVariables: true, 81 | }, 82 | key: { 83 | id: 'key', 84 | label: 'Key', 85 | type: 'textinput', 86 | default: '', 87 | useVariables: true, 88 | }, 89 | }, 90 | callback: async ({ options }) => { 91 | const [serviceName, url, key] = await Promise.all([ 92 | options.getParsedString('service'), 93 | options.getParsedString('url'), 94 | options.getParsedString('key'), 95 | ]) 96 | 97 | await atem?.setStreamingService({ serviceName, url, key }) 98 | }, 99 | learn: ({ options }) => { 100 | if (state.state.streaming?.service) { 101 | return { 102 | ...options.getJson(), 103 | service: state.state.streaming.service.serviceName, 104 | url: state.state.streaming.service.url, 105 | key: state.state.streaming.service.key, 106 | } 107 | } else { 108 | return undefined 109 | } 110 | }, 111 | }, 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/presets/mixeffect/programPreview.ts: -------------------------------------------------------------------------------- 1 | import { combineRgb, type CompanionButtonStyleProps } from '@companion-module/base' 2 | import { ActionId } from '../../actions/ActionId.js' 3 | import { FeedbackId } from '../../feedback/FeedbackId.js' 4 | import type { MyPresetDefinitionCategory } from '../types.js' 5 | import type { ActionTypes } from '../../actions/index.js' 6 | import type { FeedbackTypes } from '../../feedback/index.js' 7 | import type { ModelSpec } from '../../models/types.js' 8 | import type { SourceInfo } from '../../choices.js' 9 | 10 | export function createProgramPreviewPresets( 11 | model: ModelSpec, 12 | pstSize: CompanionButtonStyleProps['size'], 13 | pstText: string, 14 | meSources: SourceInfo[], 15 | ): MyPresetDefinitionCategory[] { 16 | const result: MyPresetDefinitionCategory[] = [] 17 | 18 | for (let me = 0; me < model.MEs; ++me) { 19 | const previewCategory: MyPresetDefinitionCategory = { 20 | name: `Preview (M/E ${me + 1})`, 21 | presets: {}, 22 | } 23 | const programCategory: MyPresetDefinitionCategory = { 24 | name: `Program (M/E ${me + 1})`, 25 | presets: {}, 26 | } 27 | result.push(previewCategory, programCategory) 28 | 29 | for (const src of meSources) { 30 | previewCategory.presets[`preview_me_${me}_${src.id}`] = { 31 | name: `Preview button for ${src.shortName}`, 32 | type: 'button', 33 | style: { 34 | text: `$(atem:${pstText}${src.id})`, 35 | size: pstSize, 36 | color: combineRgb(255, 255, 255), 37 | bgcolor: combineRgb(0, 0, 0), 38 | }, 39 | feedbacks: [ 40 | { 41 | feedbackId: FeedbackId.PreviewBG, 42 | options: { 43 | input: src.id, 44 | mixeffect: me, 45 | }, 46 | style: { 47 | bgcolor: combineRgb(0, 255, 0), 48 | color: combineRgb(255, 255, 255), 49 | }, 50 | }, 51 | ], 52 | steps: [ 53 | { 54 | down: [ 55 | { 56 | actionId: ActionId.Preview, 57 | options: { 58 | mixeffect: me, 59 | input: src.id, 60 | }, 61 | }, 62 | ], 63 | up: [], 64 | }, 65 | ], 66 | } 67 | 68 | programCategory.presets[`program_me_${me}_${src.id}`] = { 69 | name: `Program button for ${src.shortName}`, 70 | type: 'button', 71 | style: { 72 | text: `$(atem:${pstText}${src.id})`, 73 | size: pstSize, 74 | color: combineRgb(255, 255, 255), 75 | bgcolor: combineRgb(0, 0, 0), 76 | }, 77 | feedbacks: [ 78 | { 79 | feedbackId: FeedbackId.ProgramBG, 80 | style: { 81 | bgcolor: combineRgb(255, 0, 0), 82 | color: combineRgb(255, 255, 255), 83 | }, 84 | options: { 85 | input: src.id, 86 | mixeffect: me, 87 | }, 88 | }, 89 | ], 90 | steps: [ 91 | { 92 | down: [ 93 | { 94 | actionId: ActionId.Program, 95 | options: { 96 | mixeffect: me, 97 | input: src.id, 98 | }, 99 | }, 100 | ], 101 | up: [], 102 | }, 103 | ], 104 | } 105 | } 106 | } 107 | 108 | return result 109 | } 110 | -------------------------------------------------------------------------------- /src/feedback/aux-outputs.ts: -------------------------------------------------------------------------------- 1 | import type { ModelSpec } from '../models/index.js' 2 | import type { MyFeedbackDefinitions } from './types.js' 3 | import { FeedbackId } from './FeedbackId.js' 4 | import { combineRgb } from '@companion-module/base' 5 | import { AtemAuxPicker, AtemAuxSourcePicker } from '../input.js' 6 | import type { StateWrapper } from '../state.js' 7 | 8 | export interface AtemAuxOutputFeedbacks { 9 | [FeedbackId.AuxBG]: { 10 | aux: number 11 | input: number 12 | } 13 | [FeedbackId.AuxVariables]: { 14 | aux: string 15 | input: string 16 | } 17 | } 18 | 19 | export function createAuxOutputFeedbacks( 20 | model: ModelSpec, 21 | state: StateWrapper, 22 | ): MyFeedbackDefinitions { 23 | if (model.outputs.length === 0) { 24 | return { 25 | [FeedbackId.AuxBG]: undefined, 26 | [FeedbackId.AuxVariables]: undefined, 27 | } 28 | } 29 | return { 30 | [FeedbackId.AuxBG]: { 31 | type: 'boolean', 32 | name: 'Aux/Output: Source', 33 | description: 'If the input specified is selected in the aux bus specified, change style of the bank', 34 | options: { 35 | aux: AtemAuxPicker(model), 36 | input: AtemAuxSourcePicker(model, state.state), 37 | }, 38 | defaultStyle: { 39 | color: combineRgb(0, 0, 0), 40 | bgcolor: combineRgb(255, 255, 0), 41 | }, 42 | callback: ({ options }): boolean => { 43 | const auxSource = state.state.video.auxilliaries[options.getPlainNumber('aux')] 44 | return auxSource === options.getPlainNumber('input') 45 | }, 46 | learn: ({ options }) => { 47 | const auxSource = state.state.video.auxilliaries[options.getPlainNumber('aux')] 48 | 49 | if (auxSource !== undefined) { 50 | return { 51 | ...options.getJson(), 52 | input: auxSource, 53 | } 54 | } else { 55 | return undefined 56 | } 57 | }, 58 | }, 59 | [FeedbackId.AuxVariables]: { 60 | type: 'boolean', 61 | name: 'Aux/Output: Source from variables', 62 | description: 'If the input specified is selected in the aux bus specified, change style of the bank', 63 | options: { 64 | aux: { 65 | type: 'textinput', 66 | id: 'aux', 67 | label: 'AUX', 68 | default: '1', 69 | useVariables: true, 70 | }, 71 | input: { 72 | type: 'textinput', 73 | id: 'input', 74 | label: 'Input ID', 75 | default: '0', 76 | useVariables: true, 77 | }, 78 | }, 79 | defaultStyle: { 80 | color: combineRgb(0, 0, 0), 81 | bgcolor: combineRgb(255, 255, 0), 82 | }, 83 | callback: async ({ options }) => { 84 | const output = (await options.getParsedNumber('aux')) - 1 85 | const input = await options.getParsedNumber('input') 86 | 87 | const auxSource = state.state.video.auxilliaries[output] 88 | return auxSource === input 89 | }, 90 | learn: async ({ options }) => { 91 | const output = (await options.getParsedNumber('aux')) - 1 92 | 93 | const auxSource = state.state.video.auxilliaries[output] 94 | 95 | if (auxSource !== undefined) { 96 | return { 97 | ...options.getJson(), 98 | input: auxSource + '', 99 | } 100 | } else { 101 | return undefined 102 | } 103 | }, 104 | }, 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/presets/mediaPlayer.ts: -------------------------------------------------------------------------------- 1 | import { combineRgb, type CompanionButtonStyleProps } from '@companion-module/base' 2 | import { ActionId } from '../actions/ActionId.js' 3 | import { FeedbackId } from '../feedback/FeedbackId.js' 4 | import type { MyPresetDefinitionCategory } from './types.js' 5 | import type { ActionTypes } from '../actions/index.js' 6 | import type { FeedbackTypes } from '../feedback/index.js' 7 | import type { ModelSpec } from '../models/types.js' 8 | import { MEDIA_PLAYER_SOURCE_CLIP_OFFSET } from '../util.js' 9 | 10 | export function createMediaPlayerPresets( 11 | model: ModelSpec, 12 | pstSize: CompanionButtonStyleProps['size'], 13 | ): MyPresetDefinitionCategory[] { 14 | const result: MyPresetDefinitionCategory[] = [] 15 | 16 | for (let player = 0; player < model.media.players; player++) { 17 | const category: MyPresetDefinitionCategory = { 18 | name: `Mediaplayer ${player + 1}`, 19 | presets: {}, 20 | } 21 | 22 | for (let clip = 0; clip < model.media.clips; clip++) { 23 | category.presets[`mediaplayer_clip_${player}_${clip}`] = { 24 | name: `Set Mediaplayer ${player + 1} source to clip ${clip + 1}`, 25 | type: 'button', 26 | style: { 27 | text: `MP ${player + 1} Clip ${clip + 1}`, 28 | size: pstSize, 29 | color: combineRgb(255, 255, 255), 30 | bgcolor: combineRgb(0, 0, 0), 31 | }, 32 | feedbacks: [ 33 | { 34 | feedbackId: FeedbackId.MediaPlayerSource, 35 | options: { 36 | mediaplayer: player, 37 | source: clip + MEDIA_PLAYER_SOURCE_CLIP_OFFSET, 38 | }, 39 | style: { 40 | bgcolor: combineRgb(255, 255, 0), 41 | color: combineRgb(0, 0, 0), 42 | }, 43 | }, 44 | ], 45 | steps: [ 46 | { 47 | down: [ 48 | { 49 | actionId: ActionId.MediaPlayerSource, 50 | options: { 51 | mediaplayer: player, 52 | source: clip + MEDIA_PLAYER_SOURCE_CLIP_OFFSET, 53 | }, 54 | }, 55 | ], 56 | up: [], 57 | }, 58 | ], 59 | } 60 | } 61 | 62 | for (let still = 0; still < model.media.stills; still++) { 63 | category.presets[`mediaplayer_still_${player}_${still}`] = { 64 | name: `Set Mediaplayer ${player + 1} source to still ${still + 1}`, 65 | type: 'button', 66 | style: { 67 | text: `MP ${player + 1} Still ${still + 1}`, 68 | size: pstSize, 69 | color: combineRgb(255, 255, 255), 70 | bgcolor: combineRgb(0, 0, 0), 71 | }, 72 | feedbacks: [ 73 | { 74 | feedbackId: FeedbackId.MediaPlayerSource, 75 | options: { 76 | mediaplayer: player, 77 | source: still, 78 | }, 79 | style: { 80 | bgcolor: combineRgb(255, 255, 0), 81 | color: combineRgb(0, 0, 0), 82 | }, 83 | }, 84 | ], 85 | steps: [ 86 | { 87 | down: [ 88 | { 89 | actionId: ActionId.MediaPlayerSource, 90 | options: { 91 | mediaplayer: player, 92 | source: still, 93 | }, 94 | }, 95 | ], 96 | up: [], 97 | }, 98 | ], 99 | } 100 | } 101 | 102 | result.push(category) 103 | } 104 | 105 | return result 106 | } 107 | -------------------------------------------------------------------------------- /src/models/2me4k.ts: -------------------------------------------------------------------------------- 1 | import { generateOutputs, type ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { VideoInputGenerator } from './util/videoInput.js' 4 | 5 | export const ModelSpecTwoME4K: ModelSpec = { 6 | id: Enums.Model.TwoME4K, 7 | label: '2 ME Production 4K', 8 | outputs: generateOutputs('Aux', 6), 9 | MEs: 2, 10 | USKs: 2, 11 | DSKs: 2, 12 | MVs: 2, 13 | multiviewerFullGrid: false, 14 | DVEs: 1, 15 | SSrc: 1, 16 | macros: 100, 17 | displayClock: 0, 18 | media: { 19 | players: 2, 20 | stills: 32, 21 | clips: 2, 22 | captureStills: false, 23 | }, 24 | streaming: false, 25 | recording: false, 26 | recordISO: false, 27 | inputs: VideoInputGenerator.begin({ 28 | meCount: 2, 29 | baseSourceAvailability: 30 | Enums.SourceAvailability.Auxiliary | 31 | Enums.SourceAvailability.Multiviewer | 32 | Enums.SourceAvailability.SuperSourceBox | 33 | Enums.SourceAvailability.SuperSourceArt, 34 | }) 35 | .addInternalColorsAndBlack() 36 | .addExternalInputs(20) 37 | .addMediaPlayers(2) 38 | .addUpstreamKeyMasks(4) 39 | .addDownstreamKeyMasksAndClean(2) 40 | .addAuxiliaryOutputs(6) 41 | .addSuperSource() 42 | .addProgramPreview() 43 | .generate(), 44 | classicAudio: { 45 | inputs: [ 46 | { 47 | id: 1, 48 | portType: Enums.ExternalPortType.SDI, 49 | }, 50 | { 51 | id: 2, 52 | portType: Enums.ExternalPortType.SDI, 53 | }, 54 | { 55 | id: 3, 56 | portType: Enums.ExternalPortType.SDI, 57 | }, 58 | { 59 | id: 4, 60 | portType: Enums.ExternalPortType.SDI, 61 | }, 62 | { 63 | id: 5, 64 | portType: Enums.ExternalPortType.SDI, 65 | }, 66 | { 67 | id: 6, 68 | portType: Enums.ExternalPortType.SDI, 69 | }, 70 | { 71 | id: 7, 72 | portType: Enums.ExternalPortType.SDI, 73 | }, 74 | { 75 | id: 8, 76 | portType: Enums.ExternalPortType.SDI, 77 | }, 78 | { 79 | id: 9, 80 | portType: Enums.ExternalPortType.SDI, 81 | }, 82 | { 83 | id: 10, 84 | portType: Enums.ExternalPortType.SDI, 85 | }, 86 | { 87 | id: 11, 88 | portType: Enums.ExternalPortType.SDI, 89 | }, 90 | { 91 | id: 12, 92 | portType: Enums.ExternalPortType.SDI, 93 | }, 94 | { 95 | id: 13, 96 | portType: Enums.ExternalPortType.SDI, 97 | }, 98 | { 99 | id: 14, 100 | portType: Enums.ExternalPortType.SDI, 101 | }, 102 | { 103 | id: 15, 104 | portType: Enums.ExternalPortType.SDI, 105 | }, 106 | { 107 | id: 16, 108 | portType: Enums.ExternalPortType.SDI, 109 | }, 110 | { 111 | id: 17, 112 | portType: Enums.ExternalPortType.SDI, 113 | }, 114 | { 115 | id: 18, 116 | portType: Enums.ExternalPortType.SDI, 117 | }, 118 | { 119 | id: 19, 120 | portType: Enums.ExternalPortType.SDI, 121 | }, 122 | { 123 | id: 20, 124 | portType: Enums.ExternalPortType.SDI, 125 | }, 126 | { 127 | id: 1001, 128 | portType: Enums.ExternalPortType.XLR, 129 | }, 130 | { 131 | id: 1201, 132 | portType: Enums.ExternalPortType.RCA, 133 | }, 134 | { 135 | id: 2001, 136 | portType: Enums.ExternalPortType.Internal, 137 | }, 138 | { 139 | id: 2002, 140 | portType: Enums.ExternalPortType.Internal, 141 | }, 142 | ], 143 | }, 144 | } 145 | -------------------------------------------------------------------------------- /src/models/tvs4k8.ts: -------------------------------------------------------------------------------- 1 | import { generateOutputs, type ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { 4 | AUDIO_ROUTING_SOURCE_NO_AUDIO, 5 | generateInputRoutingSources, 6 | AUDIO_ROUTING_SOURCE_XLR, 7 | AUDIO_ROUTING_SOURCE_RCA, 8 | AUDIO_ROUTING_SOURCE_MICROPHONE, 9 | generateMadiRoutingSources, 10 | generateMediaPlayerRoutingSources, 11 | generateTalkbackRoutingSources, 12 | AUDIO_ROUTING_SOURCE_MONITOR, 13 | AUDIO_ROUTING_SOURCE_PROGRAM, 14 | AUDIO_ROUTING_SOURCE_CONTROL, 15 | AUDIO_ROUTING_SOURCE_STUDIO, 16 | AUDIO_ROUTING_SOURCE_HEADPHONES, 17 | AUDIO_ROUTING_OUTPUT_PROGRAM, 18 | generateAuxRoutingOutputs, 19 | generateMadiRoutingOutputs, 20 | AUDIO_ROUTING_OUTPUT_MULTIVIEWER, 21 | generateMixMinusRoutingSources, 22 | } from './util/audioRouting.js' 23 | import { 24 | AUDIO_FAIRLIGHT_INPUT_RCA, 25 | AUDIO_FAIRLIGHT_INPUT_TS_JACK, 26 | AUDIO_FAIRLIGHT_INPUT_XLR, 27 | generateFairlightInputMadi, 28 | generateFairlightInputMediaPlayer, 29 | generateFairlightInputsOfType, 30 | } from './util/fairlight.js' 31 | import { VideoInputGenerator } from './util/videoInput.js' 32 | 33 | export const ModelSpecTVS4K8: ModelSpec = { 34 | id: Enums.Model.TelevisionStudio4K8, 35 | label: 'Television Studio 4K8', 36 | inputs: VideoInputGenerator.begin({ 37 | meCount: 1, 38 | baseSourceAvailability: 39 | Enums.SourceAvailability.Auxiliary | 40 | Enums.SourceAvailability.Multiviewer | 41 | Enums.SourceAvailability.SuperSourceBox | 42 | Enums.SourceAvailability.SuperSourceArt, 43 | }) 44 | .addInternalColorsAndBlack() 45 | .addExternalInputs(8) 46 | .addMediaPlayers(2) 47 | .addCleanFeeds(2) 48 | .addAuxiliaryOutputs(10) 49 | .addProgramPreview() 50 | .addSuperSource() 51 | .addMultiviewers(1) 52 | .addMultiviewerStatusSources() 53 | .generate(), 54 | outputs: generateOutputs('Output', 10), 55 | MEs: 1, 56 | USKs: 4, 57 | DSKs: 2, 58 | MVs: 1, 59 | multiviewerFullGrid: true, 60 | DVEs: 1, 61 | SSrc: 1, 62 | macros: 100, 63 | displayClock: 1, 64 | media: { 65 | players: 2, 66 | stills: 20, 67 | clips: 2, 68 | captureStills: true, 69 | }, 70 | streaming: true, 71 | recording: true, 72 | recordISO: false, 73 | fairlightAudio: { 74 | monitor: 'split', 75 | audioRouting: { 76 | sources: [ 77 | AUDIO_ROUTING_SOURCE_NO_AUDIO, 78 | ...generateInputRoutingSources(8, true), 79 | AUDIO_ROUTING_SOURCE_XLR, 80 | AUDIO_ROUTING_SOURCE_RCA, 81 | AUDIO_ROUTING_SOURCE_MICROPHONE, 82 | ...generateMadiRoutingSources(16), 83 | ...generateMediaPlayerRoutingSources(2), 84 | ...generateTalkbackRoutingSources(true, true), 85 | AUDIO_ROUTING_SOURCE_MONITOR, 86 | AUDIO_ROUTING_SOURCE_PROGRAM, 87 | AUDIO_ROUTING_SOURCE_CONTROL, 88 | AUDIO_ROUTING_SOURCE_STUDIO, 89 | AUDIO_ROUTING_SOURCE_HEADPHONES, 90 | ...generateMixMinusRoutingSources(8), 91 | ], 92 | outputs: [ 93 | ...generateMadiRoutingOutputs(32), 94 | ...generateAuxRoutingOutputs(10), 95 | AUDIO_ROUTING_OUTPUT_MULTIVIEWER, 96 | AUDIO_ROUTING_OUTPUT_PROGRAM, 97 | ], 98 | }, 99 | inputs: [ 100 | ...generateFairlightInputsOfType(1, 8, Enums.ExternalPortType.SDI), 101 | AUDIO_FAIRLIGHT_INPUT_XLR, 102 | AUDIO_FAIRLIGHT_INPUT_RCA, 103 | AUDIO_FAIRLIGHT_INPUT_TS_JACK, 104 | ...generateFairlightInputMadi(16), 105 | ...generateFairlightInputMediaPlayer(2), 106 | ], 107 | }, 108 | } 109 | -------------------------------------------------------------------------------- /src/models/miniextremeisog2.ts: -------------------------------------------------------------------------------- 1 | import { generateOutputs, type ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { 4 | AUDIO_FAIRLIGHT_INPUT_XLR, 5 | AUDIO_FAIRLIGHT_INPUT_XLR2, 6 | generateFairlightInputMadi, 7 | generateFairlightInputMediaPlayer, 8 | generateFairlightInputsOfType, 9 | generateFairlightInputThunderbolt, 10 | } from './util/fairlight.js' 11 | import { VideoInputGenerator } from './util/videoInput.js' 12 | import { 13 | AUDIO_ROUTING_SOURCE_MONITOR, 14 | AUDIO_ROUTING_SOURCE_NO_AUDIO, 15 | AUDIO_ROUTING_SOURCE_PROGRAM, 16 | AUDIO_ROUTING_SOURCE_XLR, 17 | generateAuxRoutingOutputs, 18 | generateInputRoutingSources, 19 | generateMadiRoutingSources, 20 | generateMediaPlayerRoutingSources, 21 | generateThunderboltRoutingSources, 22 | } from './util/audioRouting.js' 23 | 24 | export const ModelSpecMiniExtremeISOG2: ModelSpec = { 25 | id: Enums.Model.MiniExtremeISOG2, 26 | label: 'Mini Extreme ISO G2', 27 | inputs: VideoInputGenerator.begin({ 28 | meCount: 1, 29 | baseSourceAvailability: 30 | Enums.SourceAvailability.Auxiliary | 31 | Enums.SourceAvailability.Multiviewer | 32 | Enums.SourceAvailability.SuperSourceBox | 33 | Enums.SourceAvailability.SuperSourceArt | 34 | Enums.SourceAvailability.WebcamOut, 35 | }) 36 | .addInternalColorsAndBlack() 37 | .addExternalInputs(8) 38 | .addMediaPlayers(2) 39 | .addThunderbolt() 40 | .addSuperSource() 41 | .addCleanFeeds(2) 42 | .addAuxiliaryOutputs(3) 43 | .addInputs( 44 | // Webcam Aux 45 | 8200, 46 | 1, 47 | Enums.InternalPortType.Auxiliary, 48 | Enums.SourceAvailability.Multiviewer, 49 | Enums.MeAvailability.None, 50 | ) 51 | .addInputs( 52 | // Thunderbolt Aux 53 | 8300, 54 | 1, 55 | Enums.InternalPortType.Auxiliary, 56 | Enums.SourceAvailability.Multiviewer, 57 | Enums.MeAvailability.None, 58 | ) 59 | .addProgramPreview() 60 | .addMultiviewers(1) 61 | .addMultiviewerStatusSources(true) 62 | .generate(), 63 | outputs: [ 64 | ...generateOutputs('Aux/Output', 3), 65 | { 66 | id: 3, 67 | name: 'Webcam (4)', 68 | }, 69 | { 70 | id: 4, 71 | name: 'Thunderbolt (5)', 72 | }, 73 | ], 74 | MEs: 1, 75 | USKs: 4, 76 | DSKs: 2, 77 | MVs: 1, 78 | multiviewerFullGrid: true, 79 | DVEs: 1, 80 | SSrc: 1, 81 | macros: 100, 82 | displayClock: 1, 83 | media: { 84 | players: 2, 85 | stills: 20, 86 | clips: 2, 87 | captureStills: true, 88 | }, 89 | streaming: true, 90 | recording: true, 91 | recordISO: true, 92 | fairlightAudio: { 93 | monitor: 'split', 94 | inputs: [ 95 | ...generateFairlightInputsOfType(1, 8, Enums.ExternalPortType.HDMI, 0), 96 | { ...AUDIO_FAIRLIGHT_INPUT_XLR, maxDelay: 8 }, 97 | { ...AUDIO_FAIRLIGHT_INPUT_XLR2, maxDelay: 8 }, 98 | ...generateFairlightInputMadi(16, 0), 99 | ...generateFairlightInputMediaPlayer(2, 0), 100 | ...generateFairlightInputThunderbolt(1, 0), 101 | ], 102 | audioRouting: { 103 | sources: [ 104 | AUDIO_ROUTING_SOURCE_NO_AUDIO, 105 | ...generateInputRoutingSources(8, false), 106 | AUDIO_ROUTING_SOURCE_XLR, 107 | ...generateMadiRoutingSources(32), 108 | ...generateMediaPlayerRoutingSources(2), 109 | ...generateThunderboltRoutingSources(1), 110 | AUDIO_ROUTING_SOURCE_MONITOR, 111 | AUDIO_ROUTING_SOURCE_PROGRAM, 112 | ], 113 | outputs: [...generateAuxRoutingOutputs(5, true)], 114 | }, 115 | }, 116 | } 117 | -------------------------------------------------------------------------------- /src/models/tvshd8.ts: -------------------------------------------------------------------------------- 1 | import { generateOutputs, type ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { 4 | AUDIO_ROUTING_OUTPUT_PROGRAM, 5 | AUDIO_ROUTING_OUTPUT_RETURN, 6 | AUDIO_ROUTING_SOURCE_CONTROL, 7 | AUDIO_ROUTING_SOURCE_HEADPHONES, 8 | AUDIO_ROUTING_SOURCE_MICROPHONE, 9 | AUDIO_ROUTING_SOURCE_MONITOR, 10 | AUDIO_ROUTING_SOURCE_NO_AUDIO, 11 | AUDIO_ROUTING_SOURCE_PROGRAM, 12 | AUDIO_ROUTING_SOURCE_RCA, 13 | AUDIO_ROUTING_SOURCE_STUDIO, 14 | AUDIO_ROUTING_SOURCE_XLR, 15 | generateAuxRoutingOutputs, 16 | generateAuxRoutingSources, 17 | generateInputRoutingSources, 18 | generateMadiRoutingOutputs, 19 | generateMadiRoutingSources, 20 | generateMediaPlayerRoutingSources, 21 | generateTalkbackRoutingSources, 22 | } from './util/audioRouting.js' 23 | import { 24 | AUDIO_FAIRLIGHT_INPUT_RCA, 25 | AUDIO_FAIRLIGHT_INPUT_TS_JACK, 26 | AUDIO_FAIRLIGHT_INPUT_XLR, 27 | generateFairlightInputMadi, 28 | generateFairlightInputMediaPlayer, 29 | generateFairlightInputsOfType, 30 | } from './util/fairlight.js' 31 | import { VideoInputGenerator } from './util/videoInput.js' 32 | 33 | export const ModelSpecTVSHD8: ModelSpec = { 34 | id: Enums.Model.TelevisionStudioHD8, 35 | label: 'Television Studio HD8', 36 | inputs: VideoInputGenerator.begin({ 37 | meCount: 1, 38 | baseSourceAvailability: 39 | Enums.SourceAvailability.Auxiliary | 40 | Enums.SourceAvailability.Multiviewer | 41 | Enums.SourceAvailability.SuperSourceBox | 42 | Enums.SourceAvailability.SuperSourceArt, 43 | }) 44 | .addInternalColorsAndBlack() 45 | .addExternalInputs(8) 46 | .addMediaPlayers(2) 47 | .addCleanFeeds(1) // TODO - should this be 2? 48 | .addAuxiliaryOutputs(2) 49 | .addProgramPreview() 50 | .addSuperSource() 51 | .addMultiviewers(1) 52 | .addMultiviewerStatusSources() 53 | .generate(), 54 | outputs: generateOutputs('Aux', 2), 55 | MEs: 1, 56 | USKs: 4, 57 | DSKs: 2, 58 | MVs: 1, 59 | multiviewerFullGrid: true, 60 | DVEs: 1, 61 | SSrc: 1, 62 | macros: 100, 63 | displayClock: 1, 64 | media: { 65 | players: 2, 66 | stills: 20, 67 | clips: 2, 68 | captureStills: true, 69 | }, 70 | streaming: true, 71 | recording: true, 72 | recordISO: false, 73 | fairlightAudio: { 74 | monitor: 'split', 75 | audioRouting: { 76 | sources: [ 77 | AUDIO_ROUTING_SOURCE_NO_AUDIO, 78 | ...generateInputRoutingSources(8, true), 79 | AUDIO_ROUTING_SOURCE_XLR, 80 | AUDIO_ROUTING_SOURCE_RCA, 81 | AUDIO_ROUTING_SOURCE_MICROPHONE, 82 | ...generateMadiRoutingSources(16), 83 | ...generateMediaPlayerRoutingSources(2), 84 | ...generateTalkbackRoutingSources(true, true), 85 | AUDIO_ROUTING_SOURCE_MONITOR, 86 | AUDIO_ROUTING_SOURCE_PROGRAM, 87 | AUDIO_ROUTING_SOURCE_CONTROL, 88 | AUDIO_ROUTING_SOURCE_STUDIO, 89 | AUDIO_ROUTING_SOURCE_HEADPHONES, 90 | ...generateAuxRoutingSources(2), 91 | ], 92 | outputs: [ 93 | ...generateMadiRoutingOutputs(32), 94 | ...generateAuxRoutingOutputs(2), 95 | AUDIO_ROUTING_OUTPUT_PROGRAM, 96 | AUDIO_ROUTING_OUTPUT_RETURN, 97 | ], 98 | }, 99 | inputs: [ 100 | ...generateFairlightInputsOfType(1, 8, Enums.ExternalPortType.SDI), 101 | AUDIO_FAIRLIGHT_INPUT_XLR, 102 | AUDIO_FAIRLIGHT_INPUT_RCA, 103 | AUDIO_FAIRLIGHT_INPUT_TS_JACK, 104 | ...generateFairlightInputMadi(16), 105 | ...generateFairlightInputMediaPlayer(2), 106 | ], 107 | }, 108 | } 109 | -------------------------------------------------------------------------------- /src/presets/fadeToBlack.ts: -------------------------------------------------------------------------------- 1 | import { combineRgb, type CompanionButtonStyleProps } from '@companion-module/base' 2 | import { ActionId } from '../actions/ActionId.js' 3 | import { FeedbackId } from '../feedback/FeedbackId.js' 4 | import type { MyPresetDefinitionCategory } from './types.js' 5 | import type { ActionTypes } from '../actions/index.js' 6 | import type { FeedbackTypes } from '../feedback/index.js' 7 | import type { ModelSpec } from '../models/types.js' 8 | 9 | export function createFadeToBlackPresets( 10 | model: ModelSpec, 11 | pstSize: CompanionButtonStyleProps['size'], 12 | rateOptions: number[], 13 | ): MyPresetDefinitionCategory[] { 14 | const result: MyPresetDefinitionCategory[] = [] 15 | 16 | for (let me = 0; me < model.MEs; ++me) { 17 | const category: MyPresetDefinitionCategory = { 18 | name: `Fade to black (M/E ${me + 1})`, 19 | presets: { 20 | [`ftb_auto_${me}`]: { 21 | name: `Auto fade`, 22 | type: 'button', 23 | style: { 24 | text: `FTB Auto`, 25 | size: pstSize, 26 | color: combineRgb(255, 255, 255), 27 | bgcolor: combineRgb(0, 0, 0), 28 | }, 29 | feedbacks: [ 30 | { 31 | feedbackId: FeedbackId.FadeToBlackIsBlack, 32 | options: { 33 | mixeffect: me, 34 | state: 'off', 35 | }, 36 | style: { 37 | bgcolor: combineRgb(0, 255, 0), 38 | color: combineRgb(255, 255, 255), 39 | }, 40 | }, 41 | { 42 | feedbackId: FeedbackId.FadeToBlackIsBlack, 43 | options: { 44 | mixeffect: me, 45 | state: 'on', 46 | }, 47 | style: { 48 | bgcolor: combineRgb(255, 0, 0), 49 | color: combineRgb(255, 255, 255), 50 | }, 51 | }, 52 | { 53 | feedbackId: FeedbackId.FadeToBlackIsBlack, 54 | options: { 55 | mixeffect: me, 56 | state: 'fading', 57 | }, 58 | style: { 59 | bgcolor: combineRgb(255, 255, 0), 60 | color: combineRgb(0, 0, 0), 61 | }, 62 | }, 63 | ], 64 | steps: [ 65 | { 66 | down: [ 67 | { 68 | actionId: ActionId.FadeToBlackAuto, 69 | options: { 70 | mixeffect: me, 71 | }, 72 | }, 73 | ], 74 | up: [], 75 | }, 76 | ], 77 | }, 78 | }, 79 | } 80 | 81 | for (const rate of rateOptions) { 82 | category.presets[`ftb_rate_${me}_${rate}`] = { 83 | name: `Rate ${rate}`, 84 | type: 'button', 85 | style: { 86 | text: `Rate ${rate}`, 87 | size: pstSize, 88 | color: combineRgb(255, 255, 255), 89 | bgcolor: combineRgb(0, 0, 0), 90 | }, 91 | feedbacks: [ 92 | { 93 | feedbackId: FeedbackId.FadeToBlackRate, 94 | options: { 95 | mixeffect: me, 96 | rate, 97 | }, 98 | style: { 99 | bgcolor: combineRgb(255, 255, 0), 100 | color: combineRgb(0, 0, 0), 101 | }, 102 | }, 103 | ], 104 | steps: [ 105 | { 106 | down: [ 107 | { 108 | actionId: ActionId.FadeToBlackRate, 109 | options: { 110 | mixeffect: me, 111 | rate, 112 | }, 113 | }, 114 | ], 115 | up: [], 116 | }, 117 | ], 118 | } 119 | } 120 | 121 | result.push(category) 122 | } 123 | 124 | return result 125 | } 126 | -------------------------------------------------------------------------------- /src/presets/superSource.ts: -------------------------------------------------------------------------------- 1 | import { combineRgb, type CompanionButtonStyleProps } from '@companion-module/base' 2 | import { ActionId } from '../actions/ActionId.js' 3 | import { FeedbackId } from '../feedback/FeedbackId.js' 4 | import type { MyPresetDefinitionCategory } from './types.js' 5 | import type { ActionTypes } from '../actions/index.js' 6 | import type { FeedbackTypes } from '../feedback/index.js' 7 | import type { ModelSpec } from '../models/types.js' 8 | import type { SourceInfo } from '../choices.js' 9 | 10 | export function createSuperSourcePresets( 11 | model: ModelSpec, 12 | pstSize: CompanionButtonStyleProps['size'], 13 | pstText: string, 14 | meSources: SourceInfo[], 15 | ): MyPresetDefinitionCategory[] { 16 | const result: MyPresetDefinitionCategory[] = [] 17 | 18 | for (let ssrc = 0; ssrc < model.SSrc; ssrc++) { 19 | const category: MyPresetDefinitionCategory = { 20 | name: `SSrc ${ssrc + 1} Boxes`, 21 | presets: {}, 22 | } 23 | result.push(category) 24 | 25 | for (let box = 0; box < 4; box++) { 26 | category.presets[`ssrc_box_onair_${ssrc}_${box}`] = { 27 | name: `Toggle SuperSource ${ssrc + 1} Box ${box + 1} visibility`, 28 | type: 'button', 29 | style: { 30 | text: `Box ${box + 1}`, 31 | size: pstSize, 32 | color: combineRgb(255, 255, 255), 33 | bgcolor: combineRgb(0, 0, 0), 34 | }, 35 | feedbacks: [ 36 | { 37 | feedbackId: FeedbackId.SSrcBoxOnAir, 38 | options: { 39 | ssrcId: ssrc, 40 | boxIndex: box, 41 | }, 42 | style: { 43 | bgcolor: combineRgb(255, 255, 0), 44 | color: combineRgb(0, 0, 0), 45 | }, 46 | }, 47 | ], 48 | steps: [ 49 | { 50 | down: [ 51 | { 52 | actionId: ActionId.SuperSourceBoxOnAir, 53 | options: { 54 | ssrcId: ssrc, 55 | onair: 'toggle', 56 | boxIndex: box, 57 | }, 58 | }, 59 | ], 60 | up: [], 61 | }, 62 | ], 63 | } 64 | 65 | const boxCategory: MyPresetDefinitionCategory = { 66 | name: `SSrc ${ssrc + 1} Box ${box + 1}`, 67 | presets: {}, 68 | } 69 | result.push(boxCategory) 70 | 71 | for (const src of meSources) { 72 | boxCategory.presets[`ssrc_box_src_${ssrc}_${box}_${src.id}`] = { 73 | name: `Set SuperSource ${ssrc + 1} Box ${box + 1} to source ${src.shortName}`, 74 | type: 'button', 75 | style: { 76 | text: `$(atem:${pstText}${src.id})`, 77 | size: pstSize, 78 | color: combineRgb(255, 255, 255), 79 | bgcolor: combineRgb(0, 0, 0), 80 | }, 81 | feedbacks: [ 82 | { 83 | feedbackId: FeedbackId.SSrcBoxSource, 84 | options: { 85 | ssrcId: ssrc, 86 | source: src.id, 87 | boxIndex: box, 88 | }, 89 | style: { 90 | bgcolor: combineRgb(255, 255, 0), 91 | color: combineRgb(0, 0, 0), 92 | }, 93 | }, 94 | ], 95 | steps: [ 96 | { 97 | down: [ 98 | { 99 | actionId: ActionId.SuperSourceBoxSource, 100 | options: { 101 | ssrcId: ssrc, 102 | source: src.id, 103 | boxIndex: box, 104 | }, 105 | }, 106 | ], 107 | up: [], 108 | }, 109 | ], 110 | } 111 | } 112 | } 113 | } 114 | 115 | return result 116 | } 117 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { Regex, type SomeCompanionConfigField } from '@companion-module/base' 2 | import { ALL_MODEL_CHOICES } from './models/index.js' 3 | import type { InstanceBaseExt } from './util.js' 4 | 5 | export const fadeFpsDefault = 10 6 | 7 | export enum PresetStyleName { 8 | Short = 0, 9 | Long = 1, 10 | } 11 | 12 | export interface AtemConfig { 13 | bonjourHost?: string 14 | host?: string 15 | modelID?: string 16 | autoModelID?: number 17 | autoModelName?: string 18 | presets?: string 19 | fadeFps?: number 20 | 21 | enableCameraControl?: boolean 22 | pollTimecode?: boolean 23 | } 24 | 25 | export function GetConfigFields(_self: InstanceBaseExt): SomeCompanionConfigField[] { 26 | return [ 27 | { 28 | type: 'static-text', 29 | id: 'info', 30 | width: 12, 31 | label: 'Information', 32 | value: 33 | 'This works with all models of Blackmagic Design ATEM mixers.
' + 34 | 'Firmware versions 7.5.2 and later are known to work, other versions may experience problems.
' + 35 | 'Firmware versions after 10.2 are not verified to be working at the time of writing, but they likely will work fine.
' + 36 | "In general the model can be left in 'Auto Detect', however a specific model can be selected below for offline programming.
" + 37 | 'Devices must be controlled over a network, USB control is NOT supported.', 38 | }, 39 | { 40 | type: 'bonjour-device', 41 | id: 'bonjourHost', 42 | label: 'Device', 43 | width: 6, 44 | }, 45 | { 46 | type: 'textinput', 47 | id: 'host', 48 | label: 'Target IP2', 49 | width: 6, 50 | isVisibleExpression: `!$(options:bonjourHost)`, 51 | default: '', 52 | regex: Regex.IP, 53 | }, 54 | { 55 | type: 'static-text', 56 | id: 'host-filler', 57 | width: 6, 58 | label: '', 59 | isVisibleExpression: `!!$(options:bonjourHost)`, 60 | value: '', 61 | }, 62 | { 63 | type: 'dropdown', 64 | id: 'modelID', 65 | label: 'Model', 66 | width: 6, 67 | choices: ALL_MODEL_CHOICES, 68 | default: 0, 69 | }, 70 | { 71 | type: 'static-text', 72 | id: 'autoModelName', 73 | label: 'Detected Model', 74 | width: 6, 75 | isVisibleExpression: `$(options:modelID) == 0`, // Loose comparison 76 | value: _self.config.autoModelName ?? 'Pending', 77 | }, 78 | { 79 | type: 'static-text', 80 | id: 'autoModelName-filler', 81 | width: 6, 82 | label: '', 83 | isVisibleExpression: `$(options:modelID) != 0`, // Loose comparison 84 | value: '', 85 | }, 86 | { 87 | type: 'dropdown', 88 | id: 'presets', 89 | label: 'Preset Style', 90 | width: 6, 91 | choices: [ 92 | { id: PresetStyleName.Short, label: 'Short Names' }, 93 | { id: PresetStyleName.Long, label: 'Long Names' }, 94 | ], 95 | default: PresetStyleName.Short, 96 | }, 97 | { 98 | type: 'number', 99 | id: 'fadeFps', 100 | label: 'Framerate for fades', 101 | tooltip: 'Higher is smoother, but has higher impact on system performance', 102 | width: 6, 103 | min: 5, 104 | max: 60, 105 | step: 1, 106 | default: fadeFpsDefault, 107 | }, 108 | { 109 | type: 'checkbox', 110 | id: 'enableCameraControl', 111 | label: 'Enable Camera Control', 112 | width: 6, 113 | default: false, 114 | }, 115 | { 116 | type: 'checkbox', 117 | id: 'pollTimecode', 118 | label: 'Enable Timecode variable', 119 | width: 6, 120 | default: false, 121 | }, 122 | ] 123 | } 124 | -------------------------------------------------------------------------------- /src/state.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AtemState, 3 | Commands, 4 | SettingsState, 5 | VideoState, 6 | MediaState, 7 | Fairlight, 8 | ClassicAudio, 9 | } from 'atem-connection' 10 | import type { SuperSource, TransitionProperties } from 'atem-connection/dist/state/video/index.js' 11 | import { type InputValue } from '@companion-module/base' 12 | import type { AtemCameraControlStateBuilder } from '@atem-connection/camera-control' 13 | import { MediaPoolPreviewCache } from './mediaPoolPreviews.js' 14 | 15 | export type TallyBySource = Commands.TallyBySourceCommand['properties'] 16 | 17 | export interface StateWrapper { 18 | state: AtemState 19 | tally: TallyBySource 20 | tallyCache: TallyCache 21 | 22 | readonly atemCameraState: AtemCameraControlStateBuilder 23 | 24 | readonly mediaPoolCache: MediaPoolPreviewCache 25 | } 26 | 27 | export type TallyCache = Map< 28 | number, // InputId of the mix/output 29 | { 30 | lastVisibleInputs: number[] 31 | referencedFeedbackIds: Set 32 | } 33 | > 34 | 35 | export function getMixEffect(state: AtemState, meIndex: InputValue | undefined): VideoState.MixEffect | undefined { 36 | return state.video.mixEffects[Number(meIndex)] 37 | } 38 | export function getTransitionProperties( 39 | state: AtemState, 40 | meIndex: InputValue | undefined, 41 | ): TransitionProperties | undefined { 42 | const me = getMixEffect(state, meIndex) 43 | return me ? me.transitionProperties : undefined 44 | } 45 | export function getUSK( 46 | state: AtemState, 47 | meIndex: InputValue | undefined, 48 | keyIndex: InputValue | undefined, 49 | ): VideoState.USK.UpstreamKeyer | undefined { 50 | const me = getMixEffect(state, meIndex) 51 | return me ? me.upstreamKeyers[Number(keyIndex)] : undefined 52 | } 53 | export function getDSK(state: AtemState, keyIndex: InputValue | undefined): VideoState.DSK.DownstreamKeyer | undefined { 54 | return state.video.downstreamKeyers[Number(keyIndex)] 55 | } 56 | export function getSuperSourceBox( 57 | state: AtemState, 58 | boxIndex: InputValue | undefined, 59 | ssrcId?: InputValue, 60 | ): SuperSource.SuperSourceBox | undefined { 61 | const ssrc = state.video.superSources[Number(ssrcId ?? 0)] 62 | return ssrc ? ssrc.boxes[Number(boxIndex)] : undefined 63 | } 64 | export function getMultiviewer(state: AtemState, index: InputValue | undefined): SettingsState.MultiViewer | undefined { 65 | return state.settings.multiViewers[Number(index)] 66 | } 67 | export function getMultiviewerWindow( 68 | state: AtemState, 69 | mvIndex: InputValue | undefined, 70 | windowIndex: InputValue | undefined, 71 | ): SettingsState.MultiViewerWindowState | undefined { 72 | const mv = getMultiviewer(state, mvIndex) 73 | return mv ? mv.windows[Number(windowIndex)] : undefined 74 | } 75 | export function getMediaPlayer(state: AtemState, index: number): MediaState.MediaPlayerState | undefined { 76 | return state.media.players[index] 77 | } 78 | 79 | export function getFairlightAudioInput(state: AtemState, index: number): Fairlight.FairlightAudioInput | undefined { 80 | return state.fairlight?.inputs[index] 81 | } 82 | 83 | export function getClassicAudioInput(state: AtemState, index: number): ClassicAudio.ClassicAudioChannel | undefined { 84 | return state.audio?.channels[index] 85 | } 86 | 87 | export function getFairlightAudioMasterChannel(state: AtemState): Fairlight.FairlightAudioMasterChannel | undefined { 88 | return state.fairlight?.master 89 | } 90 | 91 | export function getFairlightAudioMonitorChannel(state: AtemState): Fairlight.FairlightAudioMonitorChannel | undefined { 92 | return state.fairlight?.monitor 93 | } 94 | -------------------------------------------------------------------------------- /src/models/4me4k.ts: -------------------------------------------------------------------------------- 1 | import { generateOutputs, type ModelSpec } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | import { VideoInputGenerator } from './util/videoInput.js' 4 | 5 | export const ModelSpecFourME4K: ModelSpec = { 6 | id: Enums.Model.TwoMEBS4K, 7 | label: '4 ME Broadcast 4K', 8 | outputs: generateOutputs('Aux', 6), 9 | MEs: 4, 10 | USKs: 4, 11 | DSKs: 2, 12 | MVs: 2, 13 | multiviewerFullGrid: false, 14 | DVEs: 1, 15 | SSrc: 1, 16 | macros: 100, 17 | displayClock: 0, 18 | media: { 19 | players: 4, 20 | stills: 64, 21 | clips: 2, 22 | captureStills: false, 23 | }, 24 | streaming: false, 25 | recording: false, 26 | recordISO: false, 27 | inputs: VideoInputGenerator.begin({ 28 | meCount: 4, 29 | baseSourceAvailability: 30 | Enums.SourceAvailability.Auxiliary | 31 | Enums.SourceAvailability.Multiviewer | 32 | Enums.SourceAvailability.SuperSourceBox | 33 | Enums.SourceAvailability.SuperSourceArt, 34 | }) 35 | .addInternalColorsAndBlack() 36 | .addExternalInputs(20) 37 | .addMediaPlayers(4) 38 | .addUpstreamKeyMasks(16) 39 | .addDownstreamKeyMasksAndClean(2) 40 | .addAuxiliaryOutputs(6) 41 | .addSuperSource() 42 | .addProgramPreview() 43 | .generate(), 44 | classicAudio: { 45 | inputs: [ 46 | { 47 | id: 1, 48 | portType: Enums.ExternalPortType.SDI, 49 | }, 50 | { 51 | id: 2, 52 | portType: Enums.ExternalPortType.SDI, 53 | }, 54 | { 55 | id: 3, 56 | portType: Enums.ExternalPortType.SDI, 57 | }, 58 | { 59 | id: 4, 60 | portType: Enums.ExternalPortType.SDI, 61 | }, 62 | { 63 | id: 5, 64 | portType: Enums.ExternalPortType.SDI, 65 | }, 66 | { 67 | id: 6, 68 | portType: Enums.ExternalPortType.SDI, 69 | }, 70 | { 71 | id: 7, 72 | portType: Enums.ExternalPortType.SDI, 73 | }, 74 | { 75 | id: 8, 76 | portType: Enums.ExternalPortType.SDI, 77 | }, 78 | { 79 | id: 9, 80 | portType: Enums.ExternalPortType.SDI, 81 | }, 82 | { 83 | id: 10, 84 | portType: Enums.ExternalPortType.SDI, 85 | }, 86 | { 87 | id: 11, 88 | portType: Enums.ExternalPortType.SDI, 89 | }, 90 | { 91 | id: 12, 92 | portType: Enums.ExternalPortType.SDI, 93 | }, 94 | { 95 | id: 13, 96 | portType: Enums.ExternalPortType.SDI, 97 | }, 98 | { 99 | id: 14, 100 | portType: Enums.ExternalPortType.SDI, 101 | }, 102 | { 103 | id: 15, 104 | portType: Enums.ExternalPortType.SDI, 105 | }, 106 | { 107 | id: 16, 108 | portType: Enums.ExternalPortType.SDI, 109 | }, 110 | { 111 | id: 17, 112 | portType: Enums.ExternalPortType.SDI, 113 | }, 114 | { 115 | id: 18, 116 | portType: Enums.ExternalPortType.SDI, 117 | }, 118 | { 119 | id: 19, 120 | portType: Enums.ExternalPortType.SDI, 121 | }, 122 | { 123 | id: 20, 124 | portType: Enums.ExternalPortType.SDI, 125 | }, 126 | { 127 | id: 1001, 128 | portType: Enums.ExternalPortType.XLR, 129 | }, 130 | { 131 | id: 1201, 132 | portType: Enums.ExternalPortType.RCA, 133 | }, 134 | { 135 | id: 2001, 136 | portType: Enums.ExternalPortType.Internal, 137 | }, 138 | { 139 | id: 2002, 140 | portType: Enums.ExternalPortType.Internal, 141 | }, 142 | { 143 | id: 2003, 144 | portType: Enums.ExternalPortType.Internal, 145 | }, 146 | { 147 | id: 2004, 148 | portType: Enums.ExternalPortType.Internal, 149 | }, 150 | ], 151 | }, 152 | } 153 | -------------------------------------------------------------------------------- /src/actions/settings.ts: -------------------------------------------------------------------------------- 1 | import { type Atem, type InputState } from 'atem-connection' 2 | import type { ModelSpec } from '../models/index.js' 3 | import { ActionId } from './ActionId.js' 4 | import type { MyActionDefinitions } from './types.js' 5 | import { AtemAllSourcePicker } from '../input.js' 6 | import type { StateWrapper } from '../state.js' 7 | 8 | export interface AtemSettingsActions { 9 | [ActionId.SaveStartupState]: Record 10 | [ActionId.ClearStartupState]: Record 11 | [ActionId.InputName]: { 12 | source: number 13 | 14 | short_enable: boolean 15 | short_value: string 16 | 17 | long_enable: boolean 18 | long_value: string 19 | } 20 | } 21 | 22 | export function createSettingsActions( 23 | atem: Atem | undefined, 24 | model: ModelSpec, 25 | state: StateWrapper, 26 | ): MyActionDefinitions { 27 | if (!model.media.players) { 28 | return { 29 | [ActionId.SaveStartupState]: undefined, 30 | [ActionId.ClearStartupState]: undefined, 31 | [ActionId.InputName]: undefined, 32 | } 33 | } 34 | return { 35 | [ActionId.SaveStartupState]: { 36 | name: 'Startup State: Save', 37 | options: {}, 38 | callback: async () => { 39 | await atem?.saveStartupState() 40 | }, 41 | }, 42 | [ActionId.ClearStartupState]: { 43 | name: 'Startup State: Clear', 44 | options: {}, 45 | callback: async () => { 46 | await atem?.clearStartupState() 47 | }, 48 | }, 49 | [ActionId.InputName]: { 50 | name: 'Input: Set name', 51 | options: { 52 | source: AtemAllSourcePicker(model, state.state), 53 | short_enable: { 54 | id: 'short_enable', 55 | label: 'Set short name', 56 | type: 'checkbox', 57 | default: true, 58 | }, 59 | short_value: { 60 | id: 'short_value', 61 | label: 'Short name', 62 | type: 'textinput', 63 | default: '', 64 | tooltip: 'Max 4 characters. Supports variables', 65 | useVariables: true, 66 | }, 67 | long_enable: { 68 | id: 'long_enable', 69 | label: 'Set long name', 70 | type: 'checkbox', 71 | default: true, 72 | }, 73 | long_value: { 74 | id: 'long_value', 75 | label: 'Long name', 76 | type: 'textinput', 77 | default: '', 78 | tooltip: 'Max 24 characters. Supports variables', 79 | useVariables: true, 80 | }, 81 | }, 82 | callback: async ({ options }) => { 83 | const source = options.getPlainNumber('source') 84 | const setShort = options.getPlainBoolean('short_enable') 85 | const setLong = options.getPlainBoolean('long_enable') 86 | 87 | const newProps: Partial> = {} 88 | if (setShort) newProps.shortName = await options.getParsedString('short_value') 89 | if (setLong) newProps.longName = await options.getParsedString('long_value') 90 | 91 | await Promise.all([ 92 | typeof newProps.longName === 'string' && !atem?.hasInternalMultiviewerLabelGeneration() 93 | ? atem?.drawMultiviewerLabel(source, newProps.longName) 94 | : undefined, 95 | Object.keys(newProps).length ? atem?.setInputSettings(newProps, source) : undefined, 96 | ]) 97 | }, 98 | learn: ({ options }) => { 99 | const source = options.getPlainNumber('source') 100 | const props = state.state.inputs[source] 101 | 102 | if (props) { 103 | return { 104 | ...options.getJson(), 105 | long_value: props.longName, 106 | short_value: props.shortName, 107 | } 108 | } else { 109 | return undefined 110 | } 111 | }, 112 | }, 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/feedback/macro.ts: -------------------------------------------------------------------------------- 1 | import type { ModelSpec } from '../models/index.js' 2 | import type { MyFeedbackDefinitions } from './types.js' 3 | import { FeedbackId } from './FeedbackId.js' 4 | import { combineRgb, type CompanionInputFieldDropdown } from '@companion-module/base' 5 | import { GetMacroChoices } from '../choices.js' 6 | import { assertUnreachable } from '../util.js' 7 | import type { StateWrapper } from '../state.js' 8 | 9 | export enum MacroFeedbackType { 10 | IsRunning = 'isRunning', 11 | IsWaiting = 'isWaiting', 12 | IsRecording = 'isRecording', 13 | IsUsed = 'isUsed', 14 | } 15 | 16 | export interface AtemMacroFeedbacks { 17 | [FeedbackId.Macro]: { 18 | macroIndex: number 19 | state: MacroFeedbackType 20 | } 21 | [FeedbackId.MacroLoop]: { 22 | loop: boolean 23 | } 24 | } 25 | 26 | export function createMacroFeedbacks(model: ModelSpec, state: StateWrapper): MyFeedbackDefinitions { 27 | if (!model.macros) { 28 | return { 29 | [FeedbackId.Macro]: undefined, 30 | [FeedbackId.MacroLoop]: undefined, 31 | } 32 | } 33 | return { 34 | [FeedbackId.Macro]: { 35 | type: 'boolean', 36 | name: 'Macro: State', 37 | description: 'If the specified macro is running or waiting, change style of the bank', 38 | options: { 39 | macroIndex: { 40 | type: 'dropdown', 41 | label: 'Macro Number (1-100)', 42 | id: 'macroIndex', 43 | default: 1, 44 | choices: GetMacroChoices(model, state.state), 45 | } satisfies CompanionInputFieldDropdown, 46 | state: { 47 | type: 'dropdown', 48 | label: 'State', 49 | id: 'state', 50 | default: MacroFeedbackType.IsWaiting, 51 | choices: [ 52 | { id: MacroFeedbackType.IsRunning, label: 'Is Running' }, 53 | { id: MacroFeedbackType.IsWaiting, label: 'Is Waiting' }, 54 | { id: MacroFeedbackType.IsRecording, label: 'Is Recording' }, 55 | { id: MacroFeedbackType.IsUsed, label: 'Is Used' }, 56 | ], 57 | } satisfies CompanionInputFieldDropdown, 58 | }, 59 | defaultStyle: { 60 | color: combineRgb(255, 255, 255), 61 | bgcolor: combineRgb(238, 238, 0), 62 | }, 63 | callback: ({ options }): boolean => { 64 | let macroIndex = options.getPlainNumber('macroIndex') 65 | if (!isNaN(macroIndex)) { 66 | macroIndex -= 1 67 | const { macroPlayer, macroRecorder } = state.state.macro 68 | const type = options.getPlainString('state') 69 | 70 | switch (type) { 71 | case MacroFeedbackType.IsUsed: { 72 | const macro = state.state.macro.macroProperties[macroIndex] 73 | return !!macro?.isUsed 74 | } 75 | case MacroFeedbackType.IsRecording: 76 | return macroRecorder.isRecording && macroRecorder.macroIndex === macroIndex 77 | case MacroFeedbackType.IsRunning: 78 | return macroPlayer.isRunning && macroPlayer.macroIndex === macroIndex 79 | case MacroFeedbackType.IsWaiting: 80 | return macroPlayer.isWaiting && macroPlayer.macroIndex === macroIndex 81 | default: 82 | assertUnreachable(type) 83 | } 84 | } 85 | return false 86 | }, 87 | }, 88 | [FeedbackId.MacroLoop]: { 89 | type: 'boolean', 90 | name: 'Macro: Looping', 91 | description: 'If the specified macro is looping, change style of the bank', 92 | options: { 93 | loop: { 94 | type: 'checkbox', 95 | label: 'Looping', 96 | id: 'loop', 97 | default: true, 98 | }, 99 | }, 100 | defaultStyle: { 101 | color: combineRgb(255, 255, 255), 102 | bgcolor: combineRgb(238, 238, 0), 103 | }, 104 | callback: ({ options }): boolean => { 105 | return options.getPlainBoolean('loop') === !!state.state.macro.macroPlayer.loop 106 | }, 107 | }, 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/feedback/index.ts: -------------------------------------------------------------------------------- 1 | import { type CompanionFeedbackDefinitions } from '@companion-module/base' 2 | import type { ModelSpec } from '../models/index.js' 3 | import { type StateWrapper } from '../state.js' 4 | import { createTallyFeedbacks, type AtemTallyFeedbacks } from './mixeffect/tally.js' 5 | import { convertMyFeedbackDefinitions } from './wrapper.js' 6 | import { createPreviewFeedbacks, type AtemPreviewFeedbacks } from './mixeffect/preview.js' 7 | import { createProgramFeedbacks, type AtemProgramFeedbacks } from './mixeffect/program.js' 8 | import { createFadeToBlackFeedbacks, type AtemFadeToBlackFeedbacks } from './mixeffect/fadeToBlack.js' 9 | import { createMediaPlayerFeedbacks, type AtemMediaPlayerFeedbacks } from './mediaPlayer.js' 10 | import type { MyFeedbackDefinition } from './types.js' 11 | import { createMultiviewerFeedbacks, type AtemMultiviewerFeedbacks } from './multiviewer.js' 12 | import { createMacroFeedbacks, type AtemMacroFeedbacks } from './macro.js' 13 | import { createAuxOutputFeedbacks, type AtemAuxOutputFeedbacks } from './aux-outputs.js' 14 | import { createRecordingFeedbacks, type AtemRecordingFeedbacks } from './recording.js' 15 | import { createStreamingFeedbacks, type AtemStreamingFeedbacks } from './streaming.js' 16 | import { createDownstreamKeyerFeedbacks, type AtemDownstreamKeyerFeedbacks } from './dsk.js' 17 | import { createUpstreamKeyerFeedbacks, type AtemUpstreamKeyerFeedbacks } from './mixeffect/usk.js' 18 | import { createTransitionFeedbacks, type AtemTransitionFeedbacks } from './mixeffect/transition.js' 19 | import { createSuperSourceFeedbacks, type AtemSuperSourceFeedbacks } from './superSource.js' 20 | import { createClassicAudioFeedbacks, type AtemClassicAudioFeedbacks } from './classicAudio.js' 21 | import { createFairlightAudioFeedbacks, type AtemFairlightAudioFeedbacks } from './fairlightAudio.js' 22 | import { FeedbackId } from './FeedbackId.js' 23 | import { createTimecodeFeedbacks, type AtemTimecodeFeedbacks } from './timecode.js' 24 | import type { AtemConfig } from '../config.js' 25 | import { createMediaPoolFeedbacks, type AtemMediaPoolFeedbacks } from './mediaPool.js' 26 | 27 | export type FeedbackTypes = AtemTallyFeedbacks & 28 | AtemPreviewFeedbacks & 29 | AtemProgramFeedbacks & 30 | AtemUpstreamKeyerFeedbacks & 31 | AtemDownstreamKeyerFeedbacks & 32 | AtemSuperSourceFeedbacks & 33 | AtemFadeToBlackFeedbacks & 34 | AtemTransitionFeedbacks & 35 | AtemStreamingFeedbacks & 36 | AtemRecordingFeedbacks & 37 | AtemClassicAudioFeedbacks & 38 | AtemFairlightAudioFeedbacks & 39 | AtemAuxOutputFeedbacks & 40 | AtemMacroFeedbacks & 41 | AtemMultiviewerFeedbacks & 42 | AtemMediaPlayerFeedbacks & 43 | AtemMediaPoolFeedbacks & 44 | AtemTimecodeFeedbacks 45 | 46 | export function GetFeedbacksList( 47 | config: AtemConfig, 48 | model: ModelSpec, 49 | state: StateWrapper, 50 | ): CompanionFeedbackDefinitions { 51 | const feedbacks: { [id in FeedbackId]: MyFeedbackDefinition | undefined } = { 52 | ...createTallyFeedbacks(model, state), 53 | ...createPreviewFeedbacks(model, state), 54 | ...createProgramFeedbacks(model, state), 55 | ...createUpstreamKeyerFeedbacks(model, state), 56 | ...createDownstreamKeyerFeedbacks(model, state), 57 | ...createSuperSourceFeedbacks(model, state), 58 | ...createFadeToBlackFeedbacks(model, state), 59 | ...createTransitionFeedbacks(model, state), 60 | ...createStreamingFeedbacks(model, state), 61 | ...createRecordingFeedbacks(model, state), 62 | ...createClassicAudioFeedbacks(model, state), 63 | ...createFairlightAudioFeedbacks(model, state), 64 | ...createAuxOutputFeedbacks(model, state), 65 | ...createMacroFeedbacks(model, state), 66 | ...createMultiviewerFeedbacks(model, state), 67 | ...createMediaPlayerFeedbacks(model, state), 68 | ...createMediaPoolFeedbacks(model, state), 69 | ...createTimecodeFeedbacks(config, model, state), 70 | } 71 | 72 | return convertMyFeedbackDefinitions(feedbacks) 73 | } 74 | -------------------------------------------------------------------------------- /src/feedback/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CompanionAdvancedFeedbackResult, 3 | CompanionFeedbackButtonStyleResult, 4 | CompanionFeedbackContext, 5 | SomeCompanionFeedbackInputField, 6 | } from '@companion-module/base' 7 | import type { MyOptionsHelper, MyOptionsObject } from '../common.js' 8 | 9 | /** 10 | * Basic information about an instance of an Feedback 11 | */ 12 | export interface MyFeedbackInfo { 13 | /** The type of the feedback */ 14 | readonly type: 'boolean' | 'advanced' | 'value' 15 | /** The unique id for this feedback */ 16 | readonly id: string 17 | /** The unique id for the location of this feedback */ 18 | readonly controlId: string 19 | /** The id of the feedback definition */ 20 | readonly feedbackId: string 21 | /** The user selected options for the feedback */ 22 | readonly options: MyOptionsHelper 23 | } 24 | /** 25 | * Extended information for execution of an Feedback 26 | */ 27 | export type MyBooleanFeedbackEvent = MyFeedbackInfo 28 | 29 | /** 30 | * Extended information for execution of an advanced feedback 31 | */ 32 | export interface MyAdvancedFeedbackEvent extends MyFeedbackInfo { 33 | /** If control supports an imageBuffer, the dimensions the buffer should be */ 34 | readonly image?: { 35 | readonly width: number 36 | readonly height: number 37 | } 38 | } 39 | 40 | export interface MyFeedbackDefinitionBase { 41 | /** Name to show in the Feedbacks list */ 42 | name: string 43 | /** Additional description of the Feedback */ 44 | description?: string 45 | /** The input fields for the Feedback */ 46 | options: MyOptionsObject 47 | 48 | /** 49 | * Called to report the existence of an Feedback 50 | * Useful to ensure necessary data is loaded 51 | */ 52 | subscribe?: (Feedback: MyFeedbackInfo, context: CompanionFeedbackContext) => Promise | void 53 | /** 54 | * Called to report an Feedback has been edited/removed 55 | * Useful to cleanup subscriptions setup in subscribe 56 | */ 57 | unsubscribe?: (Feedback: MyFeedbackInfo, context: CompanionFeedbackContext) => Promise | void 58 | /** 59 | * The user requested to 'learn' the values for this Feedback. 60 | */ 61 | learn?: ( 62 | Feedback: MyFeedbackInfo, 63 | context: CompanionFeedbackContext, 64 | ) => TOptions | undefined | Promise 65 | } 66 | 67 | export interface MyBooleanFeedbackDefinition extends MyFeedbackDefinitionBase { 68 | type: 'boolean' 69 | 70 | /** The default style properties for this feedback */ 71 | defaultStyle: Partial 72 | 73 | /** 74 | * If `undefined` or true, Companion will add an 'Inverted' checkbox for your feedback, and handle the logic for you. 75 | * By setting this to false, you can disable this for your feedback. You should do this if it does not make sense for your feedback. 76 | */ 77 | showInvert?: boolean 78 | 79 | /** Called to execute the Feedback */ 80 | callback: ( 81 | Feedback: MyBooleanFeedbackEvent, 82 | context: CompanionFeedbackContext, 83 | ) => Promise | boolean 84 | } 85 | export interface MyAdvancedFeedbackDefinition extends MyFeedbackDefinitionBase { 86 | type: 'advanced' 87 | 88 | /** Called to execute the Feedback */ 89 | callback: ( 90 | Feedback: MyAdvancedFeedbackEvent, 91 | context: CompanionFeedbackContext, 92 | ) => Promise | CompanionAdvancedFeedbackResult 93 | } 94 | 95 | export declare type MyFeedbackDefinition = 96 | | MyBooleanFeedbackDefinition 97 | | MyAdvancedFeedbackDefinition 98 | 99 | export type MyFeedbackDefinitions = { 100 | [Key in keyof TTypes]: MyFeedbackDefinition | undefined 101 | } 102 | -------------------------------------------------------------------------------- /src/actions/recording.ts: -------------------------------------------------------------------------------- 1 | import { Enums, type Atem } from 'atem-connection' 2 | import type { ModelSpec } from '../models/index.js' 3 | import { ActionId } from './ActionId.js' 4 | import type { MyActionDefinitions } from './types.js' 5 | import { CHOICES_ON_OFF_TOGGLE, type TrueFalseToggle } from '../choices.js' 6 | import type { StateWrapper } from '../state.js' 7 | 8 | export interface AtemRecordingActions { 9 | [ActionId.RecordStartStop]: { 10 | record: TrueFalseToggle 11 | } 12 | [ActionId.RecordSwitchDisk]: Record 13 | [ActionId.RecordFilename]: { 14 | filename: string 15 | } 16 | [ActionId.RecordISO]: { 17 | recordISO: TrueFalseToggle 18 | } 19 | } 20 | 21 | export function createRecordingActions( 22 | atem: Atem | undefined, 23 | model: ModelSpec, 24 | state: StateWrapper, 25 | ): MyActionDefinitions { 26 | if (!model.recording) { 27 | return { 28 | [ActionId.RecordStartStop]: undefined, 29 | [ActionId.RecordSwitchDisk]: undefined, 30 | [ActionId.RecordFilename]: undefined, 31 | [ActionId.RecordISO]: undefined, 32 | } 33 | } 34 | return { 35 | [ActionId.RecordStartStop]: { 36 | name: 'Recording: Start or Stop', 37 | options: { 38 | record: { 39 | id: 'record', 40 | type: 'dropdown', 41 | label: 'Record', 42 | default: 'toggle', 43 | choices: CHOICES_ON_OFF_TOGGLE, 44 | }, 45 | }, 46 | callback: async ({ options }) => { 47 | let newState = options.getPlainString('record') === 'true' 48 | if (options.getPlainString('record') === 'toggle') { 49 | newState = state.state.recording?.status?.state === Enums.RecordingStatus.Idle 50 | } 51 | 52 | if (newState) { 53 | await atem?.startRecording() 54 | } else { 55 | await atem?.stopRecording() 56 | } 57 | }, 58 | learn: ({ options }) => { 59 | if (state.state.recording?.status) { 60 | return { 61 | ...options.getJson(), 62 | state: state.state.recording.status.state, 63 | } 64 | } else { 65 | return undefined 66 | } 67 | }, 68 | }, 69 | [ActionId.RecordSwitchDisk]: { 70 | name: 'Recording: Switch disk', 71 | options: {}, 72 | callback: async () => { 73 | await atem?.switchRecordingDisk() 74 | }, 75 | }, 76 | [ActionId.RecordFilename]: { 77 | name: 'Recording: Set filename', 78 | options: { 79 | filename: { 80 | id: 'filename', 81 | label: 'Filename', 82 | type: 'textinput', 83 | default: '', 84 | useVariables: true, 85 | }, 86 | }, 87 | callback: async ({ options }) => { 88 | const filename = await options.getParsedString('filename') 89 | await atem?.setRecordingSettings({ filename }) 90 | }, 91 | learn: ({ options }) => { 92 | if (state.state.recording?.properties) { 93 | return { 94 | ...options.getJson(), 95 | filename: state.state.recording?.properties.filename, 96 | } 97 | } else { 98 | return undefined 99 | } 100 | }, 101 | }, 102 | [ActionId.RecordISO]: { 103 | name: 'Recording: Enable/Disable ISO', 104 | options: { 105 | recordISO: { 106 | id: 'recordISO', 107 | type: 'dropdown', 108 | label: 'Record ISO', 109 | default: 'toggle', 110 | choices: CHOICES_ON_OFF_TOGGLE, 111 | }, 112 | }, 113 | callback: async ({ options }) => { 114 | let newState = options.getPlainString('recordISO') === 'true' 115 | if (options.getPlainString('recordISO') === 'toggle') { 116 | newState = !state.state.recording?.recordAllInputs 117 | } 118 | 119 | await atem?.setEnableISORecording(newState) 120 | }, 121 | learn: ({ options }) => { 122 | if (state.state.recording?.recordAllInputs != undefined) { 123 | return { 124 | ...options.getJson(), 125 | state: state.state.recording.recordAllInputs, 126 | } 127 | } else { 128 | return undefined 129 | } 130 | }, 131 | }, 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/feedback/mixeffect/tally.ts: -------------------------------------------------------------------------------- 1 | import { AtemMESourcePicker } from '../../input.js' 2 | import type { ModelSpec } from '../../models/index.js' 3 | import type { MyFeedbackDefinitions } from '../types.js' 4 | import { FeedbackId } from '../FeedbackId.js' 5 | import { combineRgb } from '@companion-module/base' 6 | import type { StateWrapper } from '../../state.js' 7 | import { calculateTallyForInputId } from '../../util.js' 8 | import { GetSourcesListForType, SourcesToChoices } from '../../choices.js' 9 | 10 | export interface AtemTallyFeedbacks { 11 | [FeedbackId.ProgramTally]: { 12 | input: number 13 | } 14 | [FeedbackId.PreviewTally]: { 15 | input: number 16 | } 17 | [FeedbackId.AdvancedTally]: { 18 | inputIds: number[] 19 | input: number 20 | } 21 | } 22 | 23 | export function createTallyFeedbacks(model: ModelSpec, state: StateWrapper): MyFeedbackDefinitions { 24 | return { 25 | [FeedbackId.ProgramTally]: { 26 | type: 'boolean', 27 | name: 'Tally: Program', 28 | description: 'If the input specified has an active progam tally light, change style of the bank', 29 | options: { 30 | input: AtemMESourcePicker(model, state.state, 0), 31 | }, 32 | defaultStyle: { 33 | color: combineRgb(255, 255, 255), 34 | bgcolor: combineRgb(255, 0, 0), 35 | }, 36 | callback: ({ options }): boolean => { 37 | const source = state.tally[options.getPlainNumber('input')] 38 | return !!source?.program 39 | }, 40 | }, 41 | [FeedbackId.PreviewTally]: { 42 | type: 'boolean', 43 | name: 'Tally: Preview', 44 | description: 'If the input specified has an active preview tally light, change style of the bank', 45 | options: { 46 | input: AtemMESourcePicker(model, state.state, 0), 47 | }, 48 | defaultStyle: { 49 | color: combineRgb(0, 0, 0), 50 | bgcolor: combineRgb(0, 255, 0), 51 | }, 52 | callback: ({ options }): boolean => { 53 | const source = state.tally[options.getPlainNumber('input')] 54 | return !!source?.preview 55 | }, 56 | }, 57 | [FeedbackId.AdvancedTally]: { 58 | type: 'boolean', 59 | name: 'Tally: Advanced', 60 | description: 'Check if the input specified is active in one of the selected outputs/mixes', 61 | options: { 62 | inputIds: { 63 | id: `inputIds`, 64 | label: `Mixes`, 65 | type: 'multidropdown', 66 | default: [10010], 67 | choices: SourcesToChoices(GetSourcesListForType(model, state.state, 'tally')), 68 | }, 69 | input: AtemMESourcePicker(model, state.state, 0), 70 | }, 71 | defaultStyle: { 72 | color: combineRgb(255, 255, 255), 73 | bgcolor: combineRgb(255, 0, 0), 74 | }, 75 | callback: ({ options }): boolean => { 76 | const selectedInputIds = options.getRaw('inputIds') ?? [] 77 | if (!Array.isArray(selectedInputIds)) return false 78 | 79 | const matchInputId = options.getPlainNumber('input') 80 | 81 | for (const inputId of selectedInputIds) { 82 | const cacheEntry = state.tallyCache.get(Number(inputId)) 83 | if (cacheEntry && cacheEntry.lastVisibleInputs.includes(matchInputId)) { 84 | return true 85 | } 86 | } 87 | 88 | return false 89 | }, 90 | subscribe: ({ id, options }): void => { 91 | const selectedInputIds = options.getRaw('inputIds') ?? [] 92 | if (!Array.isArray(selectedInputIds)) return 93 | 94 | for (const inputId of selectedInputIds) { 95 | if (typeof inputId !== 'number') continue 96 | 97 | const cacheEntry = state.tallyCache.get(inputId) 98 | if (cacheEntry) { 99 | cacheEntry.referencedFeedbackIds.add(id) 100 | } else { 101 | state.tallyCache.set(inputId, { 102 | referencedFeedbackIds: new Set([id]), 103 | lastVisibleInputs: calculateTallyForInputId(state.state, inputId), 104 | }) 105 | } 106 | } 107 | }, 108 | unsubscribe: ({ id }): void => { 109 | for (const tally of state.tallyCache.values()) { 110 | tally.referencedFeedbackIds.delete(id) 111 | } 112 | }, 113 | }, 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/presets/downstreamKeyer.ts: -------------------------------------------------------------------------------- 1 | import { combineRgb, type CompanionButtonStyleProps } from '@companion-module/base' 2 | import { ActionId } from '../actions/ActionId.js' 3 | import { FeedbackId } from '../feedback/FeedbackId.js' 4 | import type { MyPresetDefinitionCategory } from './types.js' 5 | import type { ActionTypes } from '../actions/index.js' 6 | import type { FeedbackTypes } from '../feedback/index.js' 7 | import type { ModelSpec } from '../models/types.js' 8 | import type { SourceInfo } from '../choices.js' 9 | 10 | export function createDownstreamKeyerPresets( 11 | model: ModelSpec, 12 | pstSize: CompanionButtonStyleProps['size'], 13 | pstText: string, 14 | meSources: SourceInfo[], 15 | ): MyPresetDefinitionCategory[] { 16 | const result: MyPresetDefinitionCategory[] = [] 17 | 18 | const onAirCategory: MyPresetDefinitionCategory = { 19 | name: 'DSK KEYs OnAir', 20 | presets: {}, 21 | } 22 | const nextCategory: MyPresetDefinitionCategory = { 23 | name: 'DSK KEYs Next', 24 | presets: {}, 25 | } 26 | result.push(onAirCategory, nextCategory) 27 | 28 | for (let dsk = 0; dsk < model.DSKs; ++dsk) { 29 | onAirCategory.presets[`dsk_onair_${dsk}`] = { 30 | name: `Toggle downstream KEY ${dsk + 1} OnAir`, 31 | type: 'button', 32 | style: { 33 | text: `DSK ${dsk + 1}`, 34 | size: '24', 35 | color: combineRgb(255, 255, 255), 36 | bgcolor: combineRgb(0, 0, 0), 37 | }, 38 | feedbacks: [ 39 | { 40 | feedbackId: FeedbackId.DSKOnAir, 41 | options: { 42 | key: dsk, 43 | }, 44 | style: { 45 | bgcolor: combineRgb(255, 0, 0), 46 | color: combineRgb(255, 255, 255), 47 | }, 48 | }, 49 | ], 50 | steps: [ 51 | { 52 | down: [ 53 | { 54 | actionId: ActionId.DSKOnAir, 55 | options: { 56 | onair: 'toggle', 57 | key: dsk, 58 | }, 59 | }, 60 | ], 61 | up: [], 62 | }, 63 | ], 64 | } 65 | 66 | nextCategory.presets[`dsk_next_${dsk}`] = { 67 | name: `Toggle downstream KEY ${dsk + 1} Next`, 68 | type: 'button', 69 | style: { 70 | text: `DSK ${dsk + 1}`, 71 | size: '24', 72 | color: combineRgb(255, 255, 255), 73 | bgcolor: combineRgb(0, 0, 0), 74 | }, 75 | feedbacks: [ 76 | { 77 | feedbackId: FeedbackId.DSKTie, 78 | options: { 79 | key: dsk, 80 | }, 81 | style: { 82 | bgcolor: combineRgb(255, 255, 0), 83 | color: combineRgb(0, 0, 0), 84 | }, 85 | }, 86 | ], 87 | steps: [ 88 | { 89 | down: [ 90 | { 91 | actionId: ActionId.DSKTie, 92 | options: { 93 | state: 'toggle', 94 | key: dsk, 95 | }, 96 | }, 97 | ], 98 | up: [], 99 | }, 100 | ], 101 | } 102 | 103 | const dskCategory: MyPresetDefinitionCategory = { 104 | name: `DSK ${dsk + 1}`, 105 | presets: {}, 106 | } 107 | result.push(dskCategory) 108 | 109 | for (const src of meSources) { 110 | dskCategory.presets[`dsk_src_${dsk}_${src.id}`] = { 111 | name: `DSK ${dsk + 1} source ${src.shortName}`, 112 | type: 'button', 113 | style: { 114 | text: `$(atem:${pstText}${src.id})`, 115 | size: pstSize, 116 | color: combineRgb(255, 255, 255), 117 | bgcolor: combineRgb(0, 0, 0), 118 | }, 119 | feedbacks: [ 120 | { 121 | feedbackId: FeedbackId.DSKSource, 122 | options: { 123 | fill: src.id, 124 | key: dsk, 125 | }, 126 | style: { 127 | bgcolor: combineRgb(238, 238, 0), 128 | color: combineRgb(0, 0, 0), 129 | }, 130 | }, 131 | ], 132 | steps: [ 133 | { 134 | down: [ 135 | { 136 | actionId: ActionId.DSKSource, 137 | options: { 138 | fill: src.id, 139 | cut: src.id + 1, 140 | key: dsk, 141 | }, 142 | }, 143 | ], 144 | up: [], 145 | }, 146 | ], 147 | } 148 | } 149 | } 150 | 151 | return result 152 | } 153 | -------------------------------------------------------------------------------- /src/actions/cameraControl/media.ts: -------------------------------------------------------------------------------- 1 | import { type Atem } from 'atem-connection' 2 | import { ActionId } from '../ActionId.js' 3 | import type { MyActionDefinitions } from '../types.js' 4 | import type { StateWrapper } from '../../state.js' 5 | import { 6 | AtemCameraControlBatchCommandSender, 7 | AtemCameraControlDirectCommandSender, 8 | } from '@atem-connection/camera-control' 9 | import { CameraControlSourcePicker, type TrueFalseToggle } from '../../choices.js' 10 | import type { AtemConfig } from '../../config.js' 11 | import type { ModelSpec } from '../../models/types.js' 12 | import { InternalPortType } from 'atem-connection/dist/enums/index.js' 13 | 14 | export interface AtemCameraControlDisplayActions { 15 | [ActionId.CameraControlMediaRecordSingle]: { 16 | cameraId: string 17 | state: TrueFalseToggle 18 | } 19 | [ActionId.CameraControlMediaRecordMultiple]: { 20 | cameraIds: number[] 21 | state: TrueFalseToggle 22 | } 23 | } 24 | 25 | export function createCameraControlMediaActions( 26 | config: AtemConfig, 27 | model: ModelSpec, 28 | atem: Atem | undefined, 29 | state: StateWrapper, 30 | ): MyActionDefinitions { 31 | if (!config.enableCameraControl) { 32 | return { 33 | [ActionId.CameraControlMediaRecordSingle]: undefined, 34 | [ActionId.CameraControlMediaRecordMultiple]: undefined, 35 | } 36 | } 37 | 38 | const commandSender = atem && new AtemCameraControlDirectCommandSender(atem) 39 | 40 | return { 41 | [ActionId.CameraControlMediaRecordSingle]: { 42 | name: 'Camera Control: Set Camera Recording', 43 | options: { 44 | cameraId: CameraControlSourcePicker(), 45 | state: { 46 | id: 'state', 47 | type: 'dropdown', 48 | label: 'State', 49 | default: 'toggle', 50 | choices: [ 51 | { id: 'true', label: 'Recording' }, 52 | { id: 'false', label: 'Stopped' }, 53 | { id: 'toggle', label: 'Toggle' }, 54 | ], 55 | }, 56 | }, 57 | callback: async ({ options }) => { 58 | const cameraId = await options.getParsedNumber('cameraId') 59 | 60 | let target: boolean 61 | if (options.getPlainString('state') === 'toggle') { 62 | const cameraState = state.atemCameraState.get(cameraId) 63 | target = !cameraState?.display?.colorBarEnable 64 | } else { 65 | target = options.getPlainString('state') === 'true' 66 | } 67 | 68 | if (target) { 69 | await commandSender?.mediaTriggerSetRecording(cameraId) 70 | } else { 71 | await commandSender?.mediaTriggerSetStopped(cameraId) 72 | } 73 | }, 74 | }, 75 | [ActionId.CameraControlMediaRecordMultiple]: { 76 | name: 'Camera Control: Set Multiple Camera Recording', 77 | options: { 78 | cameraIds: { 79 | id: 'cameraIds', 80 | type: 'multidropdown', 81 | label: 'Camera Id', 82 | choices: model.inputs 83 | .filter((input) => input.portType === InternalPortType.External) 84 | .map((input) => { 85 | const inputState = state.state.inputs[input.id] 86 | const label = inputState?.longName ? `${inputState.longName} (${input.id})` : `Camera ${input.id}` 87 | 88 | return { 89 | label, 90 | id: input.id, 91 | } 92 | }), 93 | default: [1], 94 | }, 95 | state: { 96 | id: 'state', 97 | type: 'dropdown', 98 | label: 'State', 99 | default: 'true', 100 | choices: [ 101 | { id: 'true', label: 'Recording' }, 102 | { id: 'false', label: 'Stopped' }, 103 | ], 104 | }, 105 | }, 106 | callback: async ({ options }) => { 107 | const cameraIds = options.getRaw('cameraIds') 108 | 109 | if (!cameraIds || !Array.isArray(cameraIds) || cameraIds.length === 0) return 110 | 111 | const target = options.getPlainString('state') === 'true' 112 | 113 | if (!atem) return 114 | 115 | const commandBatch = new AtemCameraControlBatchCommandSender(atem) 116 | 117 | for (const cameraId of cameraIds) { 118 | if (target) { 119 | commandBatch.mediaTriggerSetRecording(cameraId) 120 | } else { 121 | commandBatch.mediaTriggerSetStopped(cameraId) 122 | } 123 | } 124 | 125 | await commandBatch.sendBatch() 126 | }, 127 | }, 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/feedback/dsk.ts: -------------------------------------------------------------------------------- 1 | import type { ModelSpec } from '../models/index.js' 2 | import type { MyFeedbackDefinitions } from './types.js' 3 | import { FeedbackId } from './FeedbackId.js' 4 | import { combineRgb } from '@companion-module/base' 5 | import { AtemDSKPicker, AtemKeyFillSourcePicker } from '../input.js' 6 | import { getDSK, type StateWrapper } from '../state.js' 7 | 8 | export interface AtemDownstreamKeyerFeedbacks { 9 | [FeedbackId.DSKOnAir]: { 10 | key: number 11 | } 12 | [FeedbackId.DSKTie]: { 13 | key: number 14 | } 15 | [FeedbackId.DSKSource]: { 16 | key: number 17 | fill: number 18 | } 19 | [FeedbackId.DSKSourceVariables]: { 20 | key: string 21 | fill: string 22 | } 23 | } 24 | 25 | export function createDownstreamKeyerFeedbacks( 26 | model: ModelSpec, 27 | state: StateWrapper, 28 | ): MyFeedbackDefinitions { 29 | if (!model.DSKs) { 30 | return { 31 | [FeedbackId.DSKOnAir]: undefined, 32 | [FeedbackId.DSKTie]: undefined, 33 | [FeedbackId.DSKSource]: undefined, 34 | [FeedbackId.DSKSourceVariables]: undefined, 35 | } 36 | } 37 | return { 38 | [FeedbackId.DSKOnAir]: { 39 | type: 'boolean', 40 | name: 'Downstream key: OnAir', 41 | description: 'If the specified downstream keyer is onair, change style of the bank', 42 | options: { 43 | key: AtemDSKPicker(model), 44 | }, 45 | defaultStyle: { 46 | color: combineRgb(255, 255, 255), 47 | bgcolor: combineRgb(255, 0, 0), 48 | }, 49 | callback: ({ options }): boolean => { 50 | const dsk = getDSK(state.state, options.getPlainNumber('key')) 51 | return !!dsk?.onAir 52 | }, 53 | }, 54 | [FeedbackId.DSKTie]: { 55 | type: 'boolean', 56 | name: 'Downstream key: Tied', 57 | description: 'If the specified downstream keyer is tied, change style of the bank', 58 | options: { 59 | key: AtemDSKPicker(model), 60 | }, 61 | defaultStyle: { 62 | color: combineRgb(255, 255, 255), 63 | bgcolor: combineRgb(255, 0, 0), 64 | }, 65 | callback: ({ options }): boolean => { 66 | const dsk = getDSK(state.state, options.getPlainNumber('key')) 67 | return !!dsk?.properties?.tie 68 | }, 69 | }, 70 | [FeedbackId.DSKSource]: { 71 | type: 'boolean', 72 | name: 'Downstream key: Fill source', 73 | description: 'If the input specified is selected in the DSK specified, change style of the bank', 74 | options: { 75 | key: AtemDSKPicker(model), 76 | fill: AtemKeyFillSourcePicker(model, state.state), 77 | }, 78 | defaultStyle: { 79 | color: combineRgb(0, 0, 0), 80 | bgcolor: combineRgb(238, 238, 0), 81 | }, 82 | callback: ({ options }): boolean => { 83 | const dsk = getDSK(state.state, options.getPlainNumber('key')) 84 | return dsk?.sources?.fillSource === options.getPlainNumber('fill') 85 | }, 86 | learn: ({ options }) => { 87 | const dsk = getDSK(state.state, options.getPlainNumber('key')) 88 | 89 | if (dsk?.sources) { 90 | return { 91 | ...options.getJson(), 92 | fill: dsk.sources.fillSource, 93 | } 94 | } else { 95 | return undefined 96 | } 97 | }, 98 | }, 99 | [FeedbackId.DSKSourceVariables]: { 100 | type: 'boolean', 101 | name: 'Downstream key: Fill source from variables', 102 | description: 'If the input specified is selected in the DSK specified, change style of the bank', 103 | options: { 104 | key: { 105 | type: 'textinput', 106 | label: 'Key', 107 | id: 'key', 108 | default: '1', 109 | useVariables: true, 110 | }, 111 | fill: { 112 | type: 'textinput', 113 | id: 'fill', 114 | label: 'Fill Source', 115 | default: '0', 116 | useVariables: true, 117 | }, 118 | }, 119 | defaultStyle: { 120 | color: combineRgb(0, 0, 0), 121 | bgcolor: combineRgb(238, 238, 0), 122 | }, 123 | callback: async ({ options }) => { 124 | const key = (await options.getParsedNumber('key')) - 1 125 | const fill = await options.getParsedNumber('fill') 126 | 127 | const dsk = getDSK(state.state, key) 128 | return dsk?.sources?.fillSource === fill 129 | }, 130 | learn: async ({ options }) => { 131 | const key = (await options.getParsedNumber('key')) - 1 132 | 133 | const dsk = getDSK(state.state, key) 134 | 135 | if (dsk?.sources) { 136 | return { 137 | ...options.getJson(), 138 | fill: dsk.sources.fillSource + '', 139 | } 140 | } else { 141 | return undefined 142 | } 143 | }, 144 | }, 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/feedback/wrapper.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CompanionAdvancedFeedbackDefinition, 3 | CompanionBooleanFeedbackDefinition, 4 | CompanionFeedbackContext, 5 | CompanionFeedbackDefinition, 6 | CompanionFeedbackDefinitionBase, 7 | CompanionFeedbackInfo, 8 | CompanionOptionValues, 9 | SomeCompanionFeedbackInputField, 10 | } from '@companion-module/base' 11 | import { assertNever, type Complete } from '@companion-module/base/dist/util.js' 12 | import { MyOptionsHelperImpl, type MyOptionsObject } from '../common.js' 13 | import type { 14 | MyAdvancedFeedbackEvent, 15 | MyBooleanFeedbackEvent, 16 | MyFeedbackDefinition, 17 | MyFeedbackDefinitionBase, 18 | MyFeedbackInfo, 19 | } from './types.js' 20 | 21 | function rewrapFeedbackInfo( 22 | feedback: CompanionFeedbackInfo, 23 | context: CompanionFeedbackContext, 24 | fields: MyOptionsObject, 25 | ): MyFeedbackInfo { 26 | return { 27 | type: feedback.type, 28 | id: feedback.id, 29 | controlId: feedback.controlId, 30 | feedbackId: feedback.feedbackId, 31 | 32 | options: new MyOptionsHelperImpl(feedback.options, context, fields), 33 | } satisfies Complete> 34 | } 35 | 36 | function convertMyFeedbackBaseToCompanionFeedback( 37 | feedbackDef: MyFeedbackDefinitionBase, 38 | ): Complete> { 39 | const { subscribe, unsubscribe, learn } = feedbackDef 40 | 41 | return { 42 | name: feedbackDef.name, 43 | description: feedbackDef.description, 44 | options: Object.entries(feedbackDef.options) 45 | .filter((o) => !!o[1]) 46 | .map(([id, option]) => ({ 47 | ...(option as SomeCompanionFeedbackInputField), 48 | id, 49 | })), 50 | // callback: async (action, context) => { 51 | // return feedbackDef.callback( 52 | // { 53 | // ...rewrapActionInfo(action, context, feedbackDef.options), 54 | 55 | // surfaceId: action.surfaceId, 56 | // } satisfies Complete>, 57 | // context 58 | // ) 59 | // }, 60 | subscribe: subscribe 61 | ? async (action, context) => { 62 | return subscribe(rewrapFeedbackInfo(action, context, feedbackDef.options), context) 63 | } 64 | : undefined, 65 | unsubscribe: unsubscribe 66 | ? async (action, context) => { 67 | return unsubscribe(rewrapFeedbackInfo(action, context, feedbackDef.options), context) 68 | } 69 | : undefined, 70 | learn: learn 71 | ? async (action, context) => { 72 | return learn(rewrapFeedbackInfo(action, context, feedbackDef.options), context) as 73 | | CompanionOptionValues 74 | | undefined 75 | | Promise 76 | } 77 | : undefined, 78 | learnTimeout: undefined, 79 | } 80 | } 81 | 82 | type Test = { 83 | [Key in keyof TTypes]: TTypes[Key] extends MyFeedbackDefinition ? CompanionFeedbackDefinition : never 84 | } 85 | 86 | export function convertMyFeedbackDefinitions | undefined>>( 87 | feedbackDefs: TTypes, 88 | ): Test { 89 | const res: Test = {} as any 90 | 91 | for (const [id, def] of Object.entries(feedbackDefs)) { 92 | let newDef: CompanionFeedbackDefinition | undefined 93 | switch (def?.type) { 94 | case undefined: 95 | newDef = undefined 96 | break 97 | case 'boolean': 98 | newDef = { 99 | ...convertMyFeedbackBaseToCompanionFeedback(def), 100 | type: 'boolean', 101 | defaultStyle: def.defaultStyle, 102 | showInvert: def.showInvert, 103 | callback: async (feedback, context) => { 104 | return def.callback( 105 | { 106 | ...rewrapFeedbackInfo(feedback, context, def.options), 107 | } satisfies Complete>, 108 | context, 109 | ) 110 | }, 111 | } satisfies Complete 112 | break 113 | case 'advanced': 114 | newDef = { 115 | ...convertMyFeedbackBaseToCompanionFeedback(def), 116 | type: 'advanced', 117 | callback: async (feedback, context) => { 118 | return def.callback( 119 | { 120 | ...rewrapFeedbackInfo(feedback, context, def.options), 121 | image: feedback.image, 122 | } satisfies Complete>, 123 | context, 124 | ) 125 | }, 126 | } satisfies Complete 127 | break 128 | default: 129 | assertNever(def) 130 | break 131 | } 132 | 133 | ;(res as any)[id] = newDef //def ? convertMyFeedbackToCompanionFeedback(def) : undefined 134 | } 135 | 136 | return res 137 | } 138 | -------------------------------------------------------------------------------- /src/feedback/mediaPlayer.ts: -------------------------------------------------------------------------------- 1 | import { Enums } from 'atem-connection' 2 | import { AtemMediaPlayerPicker, AtemMediaPlayerSourcePicker } from '../input.js' 3 | import type { ModelSpec } from '../models/index.js' 4 | import type { MyFeedbackDefinitions } from './types.js' 5 | import { FeedbackId } from './FeedbackId.js' 6 | import { combineRgb } from '@companion-module/base' 7 | import { MEDIA_PLAYER_SOURCE_CLIP_OFFSET } from '../util.js' 8 | import type { StateWrapper } from '../state.js' 9 | 10 | export interface AtemMediaPlayerFeedbacks { 11 | [FeedbackId.MediaPlayerSource]: { 12 | mediaplayer: number 13 | source: number 14 | } 15 | [FeedbackId.MediaPlayerSourceVariables]: { 16 | mediaplayer: string 17 | isClip?: boolean 18 | slot: string 19 | } 20 | } 21 | 22 | export function createMediaPlayerFeedbacks( 23 | model: ModelSpec, 24 | state: StateWrapper, 25 | ): MyFeedbackDefinitions { 26 | if (!model.media.players) { 27 | return { 28 | [FeedbackId.MediaPlayerSource]: undefined, 29 | [FeedbackId.MediaPlayerSourceVariables]: undefined, 30 | } 31 | } 32 | return { 33 | [FeedbackId.MediaPlayerSource]: { 34 | type: 'boolean', 35 | name: 'Media player: Source', 36 | description: 'If the specified media player has the specified source, change style of the bank', 37 | options: { 38 | mediaplayer: AtemMediaPlayerPicker(model), 39 | source: AtemMediaPlayerSourcePicker(model, state.state), 40 | }, 41 | defaultStyle: { 42 | color: combineRgb(0, 0, 0), 43 | bgcolor: combineRgb(255, 255, 0), 44 | }, 45 | callback: ({ options }): boolean => { 46 | const player = state.state.media.players[options.getPlainNumber('mediaplayer')] 47 | if ( 48 | player?.sourceType === Enums.MediaSourceType.Still && 49 | player?.stillIndex === options.getPlainNumber('source') 50 | ) { 51 | return true 52 | } else if ( 53 | player?.sourceType === Enums.MediaSourceType.Clip && 54 | player?.clipIndex === options.getPlainNumber('source') - MEDIA_PLAYER_SOURCE_CLIP_OFFSET 55 | ) { 56 | return true 57 | } else { 58 | return false 59 | } 60 | }, 61 | learn: ({ options }) => { 62 | const player = state.state.media.players[options.getPlainNumber('mediaplayer')] 63 | 64 | if (player) { 65 | return { 66 | ...options.getJson(), 67 | source: player.sourceType ? player.stillIndex : player.clipIndex + MEDIA_PLAYER_SOURCE_CLIP_OFFSET, 68 | } 69 | } else { 70 | return undefined 71 | } 72 | }, 73 | }, 74 | [FeedbackId.MediaPlayerSourceVariables]: { 75 | type: 'boolean', 76 | name: 'Media player: Source from variables', 77 | description: 'If the specified media player has the specified source, change style of the bank', 78 | options: { 79 | mediaplayer: { 80 | id: 'mediaplayer', 81 | type: 'textinput', 82 | label: 'Media Player', 83 | default: '1', 84 | useVariables: true, 85 | }, 86 | isClip: 87 | model.media.clips > 0 88 | ? { 89 | type: 'checkbox', 90 | id: 'isClip', 91 | label: 'Is clip', 92 | default: false, 93 | } 94 | : undefined, 95 | slot: { 96 | id: 'slot', 97 | type: 'textinput', 98 | label: 'Slot', 99 | default: '1', 100 | useVariables: true, 101 | }, 102 | }, 103 | defaultStyle: { 104 | color: combineRgb(0, 0, 0), 105 | bgcolor: combineRgb(255, 255, 0), 106 | }, 107 | callback: async ({ options }): Promise => { 108 | const [mediaplayer, slot] = await Promise.all([ 109 | options.getParsedNumber('mediaplayer'), 110 | options.getParsedNumber('slot'), 111 | ]) 112 | 113 | const optionIsClip = options.getPlainBoolean('isClip') 114 | 115 | const player = state.state.media.players[mediaplayer - 1] 116 | if (!optionIsClip && player?.sourceType === Enums.MediaSourceType.Still && player?.stillIndex === slot - 1) { 117 | return true 118 | } else if ( 119 | optionIsClip && 120 | player?.sourceType === Enums.MediaSourceType.Clip && 121 | player?.clipIndex === slot - 1 122 | ) { 123 | return true 124 | } else { 125 | return false 126 | } 127 | }, 128 | learn: async ({ options }) => { 129 | const mediaplayer = await options.getParsedNumber('mediaplayer') 130 | const player = state.state.media.players[mediaplayer - 1] 131 | 132 | if (player) { 133 | return { 134 | ...options.getJson(), 135 | source: player.sourceType ? player.stillIndex : player.clipIndex + MEDIA_PLAYER_SOURCE_CLIP_OFFSET, 136 | } 137 | } else { 138 | return undefined 139 | } 140 | }, 141 | }, 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/models/auto.ts: -------------------------------------------------------------------------------- 1 | import { type ModelSpec, MODEL_AUTO_DETECT, generateOutputs } from './types.js' 2 | import { Enums } from 'atem-connection' 3 | 4 | export const ModelSpecAuto: ModelSpec = { 5 | id: MODEL_AUTO_DETECT, 6 | label: 'Auto Detect', 7 | outputs: generateOutputs('Aux/Output', 3), 8 | MEs: 1, 9 | USKs: 1, 10 | DSKs: 2, 11 | MVs: 1, 12 | multiviewerFullGrid: false, 13 | DVEs: 0, 14 | SSrc: 0, 15 | macros: 100, 16 | displayClock: 0, 17 | media: { 18 | players: 2, 19 | stills: 20, 20 | clips: 0, 21 | captureStills: false, 22 | }, 23 | streaming: false, 24 | recording: false, 25 | recordISO: false, 26 | inputs: [ 27 | { 28 | id: 0, 29 | portType: Enums.InternalPortType.Black, 30 | sourceAvailability: Enums.SourceAvailability.KeySource | Enums.SourceAvailability.Multiviewer, 31 | meAvailability: Enums.MeAvailability.Me1, 32 | }, 33 | { 34 | id: 1, 35 | portType: Enums.InternalPortType.External, 36 | sourceAvailability: Enums.SourceAvailability.KeySource | Enums.SourceAvailability.Multiviewer, 37 | meAvailability: Enums.MeAvailability.Me1, 38 | }, 39 | { 40 | id: 2, 41 | portType: Enums.InternalPortType.External, 42 | sourceAvailability: Enums.SourceAvailability.KeySource | Enums.SourceAvailability.Multiviewer, 43 | meAvailability: Enums.MeAvailability.Me1, 44 | }, 45 | { 46 | id: 3, 47 | portType: Enums.InternalPortType.External, 48 | sourceAvailability: Enums.SourceAvailability.KeySource | Enums.SourceAvailability.Multiviewer, 49 | meAvailability: Enums.MeAvailability.Me1, 50 | }, 51 | { 52 | id: 4, 53 | portType: Enums.InternalPortType.External, 54 | sourceAvailability: Enums.SourceAvailability.KeySource | Enums.SourceAvailability.Multiviewer, 55 | meAvailability: Enums.MeAvailability.Me1, 56 | }, 57 | { 58 | id: 5, 59 | portType: Enums.InternalPortType.External, 60 | sourceAvailability: Enums.SourceAvailability.KeySource | Enums.SourceAvailability.Multiviewer, 61 | meAvailability: Enums.MeAvailability.Me1, 62 | }, 63 | { 64 | id: 6, 65 | portType: Enums.InternalPortType.External, 66 | sourceAvailability: Enums.SourceAvailability.KeySource | Enums.SourceAvailability.Multiviewer, 67 | meAvailability: Enums.MeAvailability.Me1, 68 | }, 69 | { 70 | id: 1000, 71 | portType: Enums.InternalPortType.ColorBars, 72 | sourceAvailability: Enums.SourceAvailability.KeySource | Enums.SourceAvailability.Multiviewer, 73 | meAvailability: Enums.MeAvailability.Me1, 74 | }, 75 | { 76 | id: 2001, 77 | portType: Enums.InternalPortType.ColorGenerator, 78 | sourceAvailability: Enums.SourceAvailability.Multiviewer, 79 | meAvailability: Enums.MeAvailability.Me1, 80 | }, 81 | { 82 | id: 2002, 83 | portType: Enums.InternalPortType.ColorGenerator, 84 | sourceAvailability: Enums.SourceAvailability.Multiviewer, 85 | meAvailability: Enums.MeAvailability.Me1, 86 | }, 87 | { 88 | id: 3010, 89 | portType: Enums.InternalPortType.MediaPlayerFill, 90 | sourceAvailability: Enums.SourceAvailability.KeySource | Enums.SourceAvailability.Multiviewer, 91 | meAvailability: Enums.MeAvailability.Me1, 92 | }, 93 | { 94 | id: 3011, 95 | portType: Enums.InternalPortType.MediaPlayerKey, 96 | sourceAvailability: Enums.SourceAvailability.KeySource | Enums.SourceAvailability.Multiviewer, 97 | meAvailability: Enums.MeAvailability.Me1, 98 | }, 99 | { 100 | id: 3020, 101 | portType: Enums.InternalPortType.MediaPlayerFill, 102 | sourceAvailability: Enums.SourceAvailability.KeySource | Enums.SourceAvailability.Multiviewer, 103 | meAvailability: Enums.MeAvailability.Me1, 104 | }, 105 | { 106 | id: 3021, 107 | portType: Enums.InternalPortType.MediaPlayerKey, 108 | sourceAvailability: Enums.SourceAvailability.KeySource | Enums.SourceAvailability.Multiviewer, 109 | meAvailability: Enums.MeAvailability.Me1, 110 | }, 111 | { 112 | id: 7001, 113 | portType: Enums.InternalPortType.MEOutput, 114 | sourceAvailability: Enums.SourceAvailability.Multiviewer, 115 | meAvailability: Enums.MeAvailability.None, 116 | }, 117 | { 118 | id: 7002, 119 | portType: Enums.InternalPortType.MEOutput, 120 | sourceAvailability: Enums.SourceAvailability.Multiviewer, 121 | meAvailability: Enums.MeAvailability.None, 122 | }, 123 | { 124 | id: 10010, 125 | portType: Enums.InternalPortType.MEOutput, 126 | sourceAvailability: Enums.SourceAvailability.Multiviewer, 127 | meAvailability: Enums.MeAvailability.None, 128 | }, 129 | { 130 | id: 10011, 131 | portType: Enums.InternalPortType.MEOutput, 132 | sourceAvailability: Enums.SourceAvailability.Multiviewer, 133 | meAvailability: Enums.MeAvailability.None, 134 | }, 135 | ], 136 | } 137 | -------------------------------------------------------------------------------- /src/common.ts: -------------------------------------------------------------------------------- 1 | import type { CompanionInputFieldBase, CompanionOptionValues, DropdownChoiceId } from '@companion-module/base' 2 | import type { CompanionCommonCallbackContext } from '@companion-module/base/dist/module-api/common.js' 3 | 4 | import type { ConditionalKeys } from 'type-fest' 5 | 6 | // type InputFieldNumber = CompanionInputFieldColor | CompanionInputFieldNumber 7 | // type InputFieldString = CompanionInputFieldTextInput 8 | 9 | // type PrimitiveToActionInputField = T extends boolean 10 | // ? CompanionInputFieldCheckbox 11 | // : T extends number | string 12 | // ? CompanionInputFieldDropdown | (T extends string ? InputFieldString : T extends number ? InputFieldNumber : never) 13 | // : never 14 | 15 | export type MyOptionsObject = { 16 | [K in keyof TOptions]: undefined extends TOptions[K] ? TFields | undefined : TFields 17 | } 18 | 19 | export interface MyDropdownChoice { 20 | /** Value of the option */ 21 | id: T 22 | /** Label to show to users */ 23 | label: string 24 | } 25 | 26 | export interface MyOptionsHelper { 27 | getJson(): TOptions 28 | getRaw(fieldName: Key): TOptions[Key] | undefined 29 | getPlainString>(fieldName: Key): TOptions[Key] 30 | getPlainNumber>(fieldName: Key): TOptions[Key] 31 | getPlainBoolean>(fieldName: Key): boolean 32 | 33 | getParsedString>(fieldName: Key): Promise 34 | getParsedNumber>(fieldName: Key): Promise 35 | getParsedBoolean>(fieldName: Key): Promise 36 | } 37 | 38 | export class MyOptionsHelperImpl implements MyOptionsHelper { 39 | readonly #options: any 40 | readonly #context: CompanionCommonCallbackContext 41 | readonly #fields: MyOptionsObject 42 | 43 | constructor( 44 | options: CompanionOptionValues, 45 | context: CompanionCommonCallbackContext, 46 | fields: MyOptionsObject, 47 | ) { 48 | this.#options = options 49 | this.#context = context 50 | this.#fields = fields 51 | } 52 | 53 | getJson(): TOptions { 54 | return { ...this.#options } 55 | } 56 | getRaw(fieldName: Key): any { 57 | // TODO - should this populate defaults? 58 | return this.#options[fieldName] 59 | } 60 | 61 | getPlainString>(fieldName: Key): TOptions[Key] { 62 | const fieldSpec = this.#fields[fieldName] 63 | const defaultValue = fieldSpec && 'default' in fieldSpec ? fieldSpec.default : undefined 64 | 65 | const rawValue = this.#options[fieldName] 66 | if (defaultValue !== undefined && rawValue === undefined) return String(defaultValue) as any 67 | 68 | return String(rawValue) as any 69 | } 70 | 71 | getPlainNumber>(fieldName: Key): TOptions[Key] { 72 | const fieldSpec = this.#fields[fieldName] 73 | const defaultValue = fieldSpec && 'default' in fieldSpec ? fieldSpec.default : undefined 74 | 75 | const rawValue = this.#options[fieldName] 76 | if (defaultValue !== undefined && rawValue === undefined) return Number(defaultValue) as any 77 | 78 | const value = Number(rawValue) 79 | if (isNaN(value)) { 80 | throw new Error(`Invalid option '${String(fieldName)}'`) 81 | } 82 | return value as any 83 | } 84 | 85 | getPlainBoolean>(fieldName: Key): boolean { 86 | const fieldSpec = this.#fields[fieldName] 87 | const defaultValue = fieldSpec && 'default' in fieldSpec ? fieldSpec.default : undefined 88 | 89 | const rawValue = this.#options[fieldName] 90 | if (defaultValue !== undefined && rawValue === undefined) return Boolean(defaultValue) 91 | 92 | return Boolean(rawValue) 93 | } 94 | 95 | async getParsedString>(fieldName: Key): Promise { 96 | const rawValue = this.#options[fieldName] 97 | 98 | return this.#context.parseVariablesInString(rawValue) 99 | } 100 | async getParsedNumber>(fieldName: Key): Promise { 101 | const str = await this.getParsedString(fieldName) 102 | 103 | return Number(str) 104 | } 105 | async getParsedBoolean>(fieldName: Key): Promise { 106 | const str = await this.getParsedString(fieldName) 107 | 108 | if (str.toLowerCase() == 'false' || Number(str) == 0) return false 109 | return true 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { Enums, type AtemState, listVisibleInputs } from 'atem-connection' 2 | import { InstanceBase } from '@companion-module/base' 3 | import { AudioRoutingChannelsNames } from './choices.js' 4 | import { combineInputId } from './models/util/audioRouting.js' 5 | 6 | export const CLASSIC_AUDIO_MIN_GAIN = -60 // The minimum value to consider as valid for classic audio gain 7 | 8 | export const MEDIA_PLAYER_SOURCE_CLIP_OFFSET = 1000 9 | 10 | export function assertUnreachable(_never: never): void { 11 | // throw new Error('Unreachable') 12 | } 13 | 14 | export function pad(str: string, prefix: string, len: number): string { 15 | while (str.length < len) { 16 | str = prefix + str 17 | } 18 | return str 19 | } 20 | 21 | export function compact(arr: Array): T[] { 22 | return arr.filter((v) => v !== undefined) as T[] 23 | } 24 | 25 | export function iterateTimes(count: number, cb: (i: number) => T): T[] { 26 | const res: T[] = [] 27 | for (let i = 0; i < count; i++) { 28 | res.push(cb(i)) 29 | } 30 | return res 31 | } 32 | 33 | export function clamp(min: number, max: number, val: number): number { 34 | return Math.min(Math.max(val, min), max) 35 | } 36 | 37 | export function calculateTransitionSelection( 38 | keyCount: number, 39 | rawSelection: ('background' | string)[] | undefined, 40 | ): Enums.TransitionSelection[] { 41 | if (!rawSelection || !Array.isArray(rawSelection)) return [] 42 | 43 | const selection: Enums.TransitionSelection[] = [] 44 | if (rawSelection.includes('background')) { 45 | selection.push(Enums.TransitionSelection.Background) 46 | } 47 | 48 | for (let i = 0; i < keyCount; i++) { 49 | if (rawSelection.includes(`key${i}`)) { 50 | selection.push(1 << (i + 1)) 51 | } 52 | } 53 | 54 | return selection 55 | } 56 | 57 | export enum NumberComparitor { 58 | Equal = 'eq', 59 | NotEqual = 'ne', 60 | LessThan = 'lt', 61 | LessThanEqual = 'lte', 62 | GreaterThan = 'gt', 63 | GreaterThanEqual = 'gte', 64 | } 65 | 66 | export function compareNumber(target: number, comparitor: NumberComparitor, currentValue: number): boolean { 67 | const targetValue = Number(target) 68 | if (isNaN(targetValue)) { 69 | return false 70 | } 71 | 72 | switch (comparitor) { 73 | case NumberComparitor.GreaterThan: 74 | return currentValue > targetValue 75 | case NumberComparitor.GreaterThanEqual: 76 | return currentValue >= targetValue 77 | case NumberComparitor.LessThan: 78 | return currentValue < targetValue 79 | case NumberComparitor.LessThanEqual: 80 | return currentValue <= targetValue 81 | case NumberComparitor.NotEqual: 82 | return currentValue != targetValue 83 | default: 84 | return currentValue === targetValue 85 | } 86 | } 87 | 88 | export interface IpAndPort { 89 | ip: string 90 | port: number | undefined 91 | } 92 | 93 | export interface InstanceBaseExt extends InstanceBase { 94 | config: TConfig 95 | timecodeSeconds: number 96 | displayClockSeconds: number 97 | 98 | parseIpAndPort(): IpAndPort | null 99 | } 100 | 101 | export function calculateTallyForInputId(state: AtemState, inputId: number): number[] { 102 | if (inputId < 10000 || inputId > 11000) return [] 103 | // Future: This is copied from atem-connection, and should be exposed as a helper function 104 | const nestedMeId = (inputId - (inputId % 10) - 10000) / 10 - 1 105 | const nestedMeMode = (inputId - 10000) % 10 === 0 ? 'program' : 'preview' 106 | 107 | // Ensure the ME exists in the state 108 | if (!state.video.mixEffects[nestedMeId]) return [] 109 | 110 | return listVisibleInputs(nestedMeMode, state, nestedMeId) 111 | } 112 | 113 | export function formatAudioRoutingAsString(id: number): string { 114 | const inputId = Math.floor(id >> 16) 115 | const pair: Enums.AudioChannelPair = id & 0xff 116 | 117 | const pairName = AudioRoutingChannelsNames[pair]?.replace('/', '_') 118 | 119 | return `${inputId}-${pairName}` 120 | } 121 | export function parseAudioRoutingString(ids: string): number[] { 122 | return ids 123 | .split(/[,| ]/) 124 | .map((id) => parseAudioRoutingStringSingle(id)) 125 | .filter((id): id is number => id !== null) 126 | } 127 | 128 | const ROUTING_STRING_REGEX = /(\d+)-([\d]+_[\d]+)/i 129 | export function parseAudioRoutingStringSingle(id: string): number | null { 130 | id = id.trim() 131 | if (!id) return null 132 | 133 | const match = ROUTING_STRING_REGEX.exec(id) 134 | if (match) { 135 | const inputId = Number(match[1]) 136 | 137 | const pairValueStr = match[2]?.replace('_', '/') 138 | const pairValueOption = Object.entries(AudioRoutingChannelsNames).find(([, value]) => value === pairValueStr) 139 | if (!pairValueOption) return null 140 | const pairValue = Number(pairValueOption[0]) 141 | 142 | if (isNaN(inputId) || isNaN(pairValue)) return null 143 | 144 | return combineInputId(inputId, pairValue) 145 | } 146 | 147 | const inputId = Number(id) 148 | if (isNaN(inputId)) return null 149 | 150 | return combineInputId(inputId, Enums.AudioChannelPair.Channel1_2) 151 | } 152 | --------------------------------------------------------------------------------