├── .editorconfig ├── .eslintrc.json ├── .gitattributes ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── node.yaml │ └── publish.yaml ├── .gitignore ├── .husky └── pre-commit ├── .yarnrc.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── src ├── CasparCG.ts ├── __mocks__ │ └── net.ts ├── __tests__ │ ├── connection.spec.ts │ ├── deserializers.spec.ts │ └── serializers.spec.ts ├── api.ts ├── commands.ts ├── connection.ts ├── deserializers │ ├── deserializeClipInfo.ts │ ├── deserializeInfo.ts │ ├── deserializeInfoChannel.ts │ ├── deserializeInfoConfig.ts │ ├── deserializeInfoLayer.ts │ ├── deserializeVersion.ts │ ├── deserializeXML.ts │ └── index.ts ├── enums.ts ├── index.ts ├── lib.ts ├── parameters.ts └── serializers.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = tab 3 | 4 | [*.{cs,js,ts,json}] 5 | indent_size = 4 6 | 7 | [*.{yml,yaml}] 8 | indent_size = 2 9 | indent_style = space -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@sofie-automation/code-standard-preset/eslint/main", 3 | "ignorePatterns": ["docs/**"], 4 | "rules": { 5 | "no-console": "warn" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Example Contributing Guidelines 2 | 3 | This is an example of GitHub's contributing guidelines file. Check out GitHub's [CONTRIBUTING.md help center article](https://help.github.com/articles/setting-guidelines-for-repository-contributors/) for more information. 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * **I'm submitting a ...** 2 | [ ] bug report 3 | [ ] feature request 4 | [ ] question about the decisions made in the repository 5 | [ ] question about how to use this project 6 | 7 | * **Summary** 8 | 9 | 10 | 11 | * **Other information** (e.g. detailed explanation, stacktraces, related issues, suggestions how to fix, links for us to have context, eg. StackOverflow, personal fork, etc.) 12 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * **What kind of change does this PR introduce?** (Bug fix, feature, docs update, ...) 2 | 3 | 4 | 5 | * **What is the current behavior?** (You can also link to an open issue here) 6 | 7 | 8 | 9 | * **What is the new behavior (if this is a feature change)?** 10 | 11 | 12 | 13 | * **Other information**: 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'github-actions' 4 | directory: '/' 5 | schedule: 6 | interval: 'weekly' 7 | -------------------------------------------------------------------------------- /.github/workflows/node.yaml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | tags: 8 | - '[0-9]+.[0-9]+.[0-9]+*' 9 | - 'v[0-9]+.[0-9]+.[0-9]+*' 10 | pull_request: 11 | 12 | jobs: 13 | lint: 14 | name: Lint 15 | runs-on: ubuntu-latest 16 | continue-on-error: true 17 | timeout-minutes: 15 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Use Node.js 18.x 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 18.x 25 | - name: Prepare Environment 26 | run: | 27 | corepack enable 28 | yarn install 29 | yarn build 30 | env: 31 | CI: true 32 | - name: Run typecheck and linter 33 | run: | 34 | yarn lint 35 | env: 36 | CI: true 37 | 38 | test: 39 | name: Test 40 | runs-on: ubuntu-latest 41 | timeout-minutes: 15 42 | 43 | strategy: 44 | fail-fast: false 45 | matrix: 46 | node-version: [14.x, 16.x, 18.x, 20.x, 22.x] 47 | 48 | steps: 49 | - uses: actions/checkout@v4 50 | - name: Use Node.js ${{ matrix.node-version }} 51 | uses: actions/setup-node@v4 52 | with: 53 | node-version: ${{ matrix.node-version }} 54 | - name: Prepare Environment 55 | run: | 56 | corepack enable 57 | yarn install 58 | env: 59 | CI: true 60 | - name: Run tests 61 | run: | 62 | yarn unit 63 | env: 64 | CI: true 65 | - name: Send coverage 66 | uses: codecov/codecov-action@v4 67 | env: 68 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 69 | if: matrix.node-version == '18.x' 70 | - name: Check docs generation 71 | if: matrix.node-version == '18.x' 72 | run: | 73 | yarn docs 74 | env: 75 | CI: true 76 | 77 | release: 78 | name: Release 79 | runs-on: ubuntu-latest 80 | timeout-minutes: 15 81 | 82 | # only run for tags 83 | if: contains(github.ref, 'refs/tags/') 84 | 85 | needs: 86 | - test 87 | # - validate-dependencies 88 | 89 | steps: 90 | - uses: actions/checkout@v4 91 | with: 92 | fetch-depth: 0 93 | - name: Use Node.js 18.x 94 | uses: actions/setup-node@v4 95 | with: 96 | node-version: 18.x 97 | - name: Check release is desired 98 | id: do-publish 99 | run: | 100 | if [ -z "${{ secrets.NPM_TOKEN }}" ]; then 101 | echo "No Token" 102 | else 103 | PUBLISHED_VERSION=$(yarn npm info --json . | jq -c '.version' -r) 104 | THIS_VERSION=$(node -p "require('./package.json').version") 105 | # Simple bash helper to comapre version numbers 106 | verlte() { 107 | [ "$1" = "`echo -e "$1\n$2" | sort -V | head -n1`" ] 108 | } 109 | verlt() { 110 | [ "$1" = "$2" ] && return 1 || verlte $1 $2 111 | } 112 | if verlt $PUBLISHED_VERSION $THIS_VERSION 113 | then 114 | echo "Publishing latest" 115 | echo "tag=latest" >> $GITHUB_OUTPUT 116 | else 117 | echo "Publishing hotfix" 118 | echo "tag=hotfix" >> $GITHUB_OUTPUT 119 | fi 120 | fi 121 | - name: Prepare build 122 | if: ${{ steps.do-publish.outputs.tag }} 123 | run: | 124 | corepack enable 125 | yarn install 126 | yarn build 127 | env: 128 | CI: true 129 | - name: Publish to NPM 130 | if: ${{ steps.do-publish.outputs.tag }} 131 | run: | 132 | yarn config set npmAuthToken $NPM_AUTH_TOKEN 133 | 134 | NEW_VERSION=$(node -p "require('./package.json').version") 135 | yarn npm publish --access=public --tag ${{ steps.do-publish.outputs.tag }} 136 | echo "**Published:** $NEW_VERSION" >> $GITHUB_STEP_SUMMARY 137 | env: 138 | NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 139 | CI: true 140 | - name: Generate docs 141 | if: ${{ steps.do-publish.outputs.tag }} == 'latest' 142 | run: | 143 | yarn docs 144 | - name: Publish docs 145 | uses: peaceiris/actions-gh-pages@v4 146 | with: 147 | github_token: ${{ secrets.GITHUB_TOKEN }} 148 | publish_dir: ./docs 149 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish prerelease 2 | 3 | on: 4 | # Allows you to run this workflow manually from the Actions tab 5 | workflow_dispatch: 6 | 7 | jobs: 8 | test: 9 | name: Test 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 15 12 | 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | node-version: [16.x, 14.x, 18.x, 20.x, 22.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - name: Prepare Environment 25 | run: | 26 | corepack enable 27 | yarn install 28 | env: 29 | CI: true 30 | - name: Run tests 31 | run: | 32 | yarn unit 33 | env: 34 | CI: true 35 | 36 | prerelease: 37 | name: Prerelease 38 | runs-on: ubuntu-latest 39 | timeout-minutes: 15 40 | 41 | needs: 42 | - test 43 | 44 | steps: 45 | - uses: actions/checkout@v4 46 | with: 47 | fetch-depth: 0 48 | - name: Use Node.js 18.x 49 | uses: actions/setup-node@v4 50 | with: 51 | node-version: 18.x 52 | - name: Check release is desired 53 | id: do-publish 54 | run: | 55 | if [ -z "${{ secrets.NPM_TOKEN }}" ]; then 56 | echo "No Token" 57 | elif [[ "${{ github.ref }}" == "refs/heads/master" ]]; then 58 | echo "Publish nightly" 59 | echo "publish=nightly" >> $GITHUB_OUTPUT 60 | else 61 | echo "Publish experimental" 62 | echo "publish=experimental" >> $GITHUB_OUTPUT 63 | fi 64 | - name: Prepare Environment 65 | if: ${{ steps.do-publish.outputs.publish }} 66 | run: | 67 | corepack enable 68 | yarn install 69 | env: 70 | CI: true 71 | - name: Bump version and build 72 | if: ${{ steps.do-publish.outputs.publish }} 73 | run: | 74 | PRERELEASE_TAG=nightly-$(echo "${{ github.ref_name }}" | sed -r 's/[^a-z0-9]+/-/gi') 75 | yarn release --prerelease $PRERELEASE_TAG 76 | yarn build 77 | env: 78 | CI: true 79 | - name: Publish to NPM 80 | if: ${{ steps.do-publish.outputs.publish }} 81 | run: | 82 | yarn config set npmAuthToken $NPM_AUTH_TOKEN 83 | NEW_VERSION=$(node -p "require('./package.json').version") 84 | yarn npm publish --access=public --tag "${{ steps.do-publish.outputs.publish }}" 85 | 86 | echo "**Published:** $NEW_VERSION" >> $GITHUB_STEP_SUMMARY 87 | env: 88 | NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 89 | CI: true 90 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | test 4 | src/**.js 5 | 6 | /coverage 7 | /docs 8 | .nyc_output 9 | *.log 10 | 11 | wallaby.conf.js 12 | 13 | .DS_Store 14 | .vscode/settings.json 15 | 16 | .pnp.* 17 | .yarn/* 18 | !.yarn/patches 19 | !.yarn/plugins 20 | !.yarn/releases 21 | !.yarn/sdks 22 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [Convential Commits](https://www.conventionalcommits.org/en/v1.0.0/#specification) for commit guidelines. 4 | 5 | ## [6.3.1](http://superfly.tv/compare/v6.3.0...v6.3.1) (Thu May 22 2025) 6 | 7 | 8 | ### Fixes 9 | 10 | * fix lint [67c93d99](http://superfly.tv/commit/67c93d99e1b11112837cc955208691ffb0083b0b) 11 | * in cgAdd: make cgLayer be optional [3066ee35](http://superfly.tv/commit/3066ee355e1bfa628602e3707ec980ad616b2502) 12 | 13 | ## [6.3.0](http://superfly.tv/compare/v6.2.1...v6.3.0) (Tue Jul 30 2024) 14 | 15 | 16 | ### Features 17 | 18 | * add custom commands [9763f84f](http://superfly.tv/commit/9763f84fcbd7288f9b6933d55d53cd90e4b6d7c9) 19 | 20 | ## [6.2.1](http://superfly.tv/compare/v6.2.0...v6.2.1) (Thu Feb 01 2024) 21 | 22 | 23 | ### Fixes 24 | 25 | * mixer tween parameters #199 (#200) [cd573113](http://superfly.tv/commit/cd573113ac9286ef1fc4b7a1c02f2c2c1ecc2eaf) 26 | 27 | ## [6.2.0](http://superfly.tv/compare/v6.1.1...v6.2.0) (Tue Jan 02 2024) 28 | 29 | 30 | ### Fixes 31 | 32 | * info format should be string [2fe75295](http://superfly.tv/commit/2fe752958a0e7352d7b3f1d9b500fea308736815) 33 | 34 | ### Features 35 | 36 | * add discard [d531a7d3](http://superfly.tv/commit/d531a7d34141ae86cd815b50a4661a5d846610b7) 37 | * add ping, begin and commit [29a72688](http://superfly.tv/commit/29a726881aeb0ffa4e605dc49ca346e0e0503d5c) 38 | 39 | ## [6.1.1](http://superfly.tv/compare/v6.1.0...v6.1.1) (Tue Oct 17 2023) 40 | 41 | 42 | ### Fixes 43 | 44 | * bug fix in INFO CHANNEL deserializer [985417bd](http://superfly.tv/commit/985417bd91d6b699a8d677b97a4bec105a6738f5) 45 | 46 | ## [6.1.0](http://superfly.tv/compare/v6.0.6...v6.1.0) (Mon Oct 16 2023) 47 | 48 | 49 | ### Features 50 | 51 | * add INFO Config deserializer [9af33b11](http://superfly.tv/commit/9af33b11b7234a52dd4bdca61e2dce6938443332) 52 | * add separate commands for INFO, INFO channel and INFO channel-layer, and handle return data properly [19997ce9](http://superfly.tv/commit/19997ce97c3781ca7defb407ac04bc8c260d6ae0) 53 | * Add strict types for the deserialized return-data of some of the commands [f1c67b4d](http://superfly.tv/commit/f1c67b4d63e2465f1751e4134aa0a4c4e35d88f2) 54 | 55 | ### Fixes 56 | 57 | * set all default return-data to be unknown instead of undefined [7622c142](http://superfly.tv/commit/7622c142cbfe8c988e39a2eb634e4d68cdfd5f94) 58 | 59 | ## [6.0.6](http://superfly.tv/compare/v6.0.5...v6.0.6) (Thu Oct 05 2023) 60 | 61 | 62 | ### Fixes 63 | 64 | * aFilter and vFilter serialized incorrectly SOFIE-2706 [0765fed5](http://superfly.tv/commit/0765fed57d649c76af101ea364f9a7b8a196f471) 65 | 66 | ## [6.0.5](http://superfly.tv/compare/v6.0.4...v6.0.5) (Tue Oct 03 2023) 67 | 68 | 69 | ## [6.0.4](http://superfly.tv/compare/v6.0.3...v6.0.4) (Tue Oct 03 2023) 70 | 71 | 72 | ### Fixes 73 | 74 | * receiving fragmented message gets stuck SOFIE-2680 (#188) [15739ae7](http://superfly.tv/commit/15739ae7bf50ff2f67f4778afc7dc73d0674433d) 75 | * allow lenght of 0 [02d082da](http://superfly.tv/commit/02d082da9e992364398cf241faa57a9c0fae9f50) 76 | 77 | ## [6.0.3](http://superfly.tv/compare/v6.0.2...v6.0.3) (Wed May 03 2023) 78 | 79 | 80 | ### Fixes 81 | 82 | * cgAdd didn't serialize data properly [60790958](http://superfly.tv/commit/60790958d3109a23e7121ebc81192a2eae2417d3) 83 | * make some parameters optional [23d3de2d](http://superfly.tv/commit/23d3de2da5bd3a401882a6ef9c8be81097f92fb3) 84 | 85 | ## [6.0.2](http://superfly.tv/compare/v6.0.1...v6.0.2) (Wed Mar 22 2023) 86 | 87 | 88 | ### Fixes 89 | 90 | * playOnLoad serialisation [fa661a38](http://superfly.tv/commit/fa661a38be986e8e43b357efaa4b236474eb716f) 91 | 92 | ## [6.0.1](http://superfly.tv/compare/v6.0.0...v6.0.1) (Thu Feb 16 2023) 93 | 94 | 95 | ### Fixes 96 | 97 | * multi-token PLAY/LOADBG commands serialize incorrectly [2b1bc0cb](http://superfly.tv/commit/2b1bc0cb816ae827f8f877cd321f54a1d81248af) 98 | 99 | ## [6.0.0](http://superfly.tv/compare/5.1.0...v6.0.0) (Fri Nov 11 2022) 100 | 101 | ## Breaking changes 102 | 103 | ### Features 104 | 105 | * **!** rewrite library [8d23d0d3](http://superfly.tv/commit/8d23d0d32677f86f5e63bf706c9c5967aef80638) 106 | 107 | ### Fixes 108 | 109 | * decklink format should be optional [21bc530e](http://superfly.tv/commit/21bc530e2a8a203aa1f6cfe461a1e2eb047c1974) 110 | * rework sending API [01c13f68](http://superfly.tv/commit/01c13f6893e869b9c27c2ab4d076ee55a41b741d) 111 | * disconnect flow closes requests [7e8ec251](http://superfly.tv/commit/7e8ec251ff9b027e1b327a26dc9466bc120d2c58) 112 | * do not swallow error [e4767c1e](http://superfly.tv/commit/e4767c1ebd88aacb72dec477678158b7b40f400d) 113 | * add typed events to the basic api [d4613dcf](http://superfly.tv/commit/d4613dcfee624c3b00acc0fd7cdc2d4529677b7b) 114 | * add some missing params [35beca66](http://superfly.tv/commit/35beca6618d603b8577380e205025e1ab090faa7) 115 | * improve socket connection handling [a29f690f](http://superfly.tv/commit/a29f690f5ae7b007af01e24c944ac0286bb6bb85) 116 | 117 | ### Features 118 | 119 | * multi version support [6d18b5ca](http://superfly.tv/commit/6d18b5ca4c4dfec6d1741a5890df2775c1bde51f) 120 | * add disconnect flow [8bc9b7db](http://superfly.tv/commit/8bc9b7dbc2cfebd62f371a057f2bbd369267c9d4) 121 | * command timeouts [1e28632c](http://superfly.tv/commit/1e28632c844e17880e0c7a93735b2eff6af7a9ea) 122 | * add xml parsing of responses [f8a83470](http://superfly.tv/commit/f8a83470976fb8506ee7a5ff68405e53088e2809) 123 | 124 | ## [5.1.0](https://github.com/SuperFlyTV/casparcg-connection/compare/5.0.1...5.1.0) (2020-11-16) 125 | 126 | 127 | ### Features 128 | 129 | * add ffmpeg filter options ([1b81653](https://github.com/SuperFlyTV/casparcg-connection/commit/1b81653cf9f5f898fcc62f21d4844377076b3134)) 130 | 131 | ### [5.0.1](https://github.com/SuperFlyTV/casparcg-connection/compare/5.0.0...5.0.1) (2020-09-29) 132 | 133 | ## [5.0.0](https://github.com/SuperFlyTV/casparcg-connection/compare/4.9.0...5.0.0) (2020-09-29) 134 | 135 | 136 | ### ⚠ BREAKING CHANGES 137 | 138 | * drop node 8 support 139 | 140 | ### Features 141 | 142 | * drop node 8 support ([44f6dae](https://github.com/SuperFlyTV/casparcg-connection/commit/44f6dae5102ba54163bb81385a06157530027d37)) 143 | * **ci:** use prerelease flow & optionally skip audit [skip ci] ([bd32ef1](https://github.com/SuperFlyTV/casparcg-connection/commit/bd32ef1f2b1b43053bb82a009f01143b45c5a9f8)) 144 | * update ci to run for node 8,10,12 ([8e1de30](https://github.com/SuperFlyTV/casparcg-connection/commit/8e1de3093dbee2a979eb0c9736515449d965dc47)) 145 | 146 | # [4.9.0](https://github.com/SuperFlyTV/casparcg-connection/compare/4.8.1...4.9.0) (2019-11-18) 147 | 148 | 149 | ### Features 150 | 151 | * clear_on_404 parameter for PLAY/LOAD/LOADBG ([b8f0bd6](https://github.com/SuperFlyTV/casparcg-connection/commit/b8f0bd6c519fe15d0aa36f461b18383467fd8755)) 152 | * FRAMES_DELAY parameter for PLAY/LOAD/LOADBG route ([bceabce](https://github.com/SuperFlyTV/casparcg-connection/commit/bceabce321466fcfdd28dc4dd6c41c93ede162c3)) 153 | 154 | 155 | 156 | ## [4.8.1](https://github.com/SuperFlyTV/casparcg-connection/compare/4.8.0...4.8.1) (2019-11-07) 157 | 158 | 159 | 160 | # [4.8.0](https://github.com/SuperFlyTV/casparcg-connection/compare/4.7.0...4.8.0) (2019-11-07) 161 | 162 | 163 | ### Bug Fixes 164 | 165 | * remove unnecessary overloads ([5a2ca12](https://github.com/SuperFlyTV/casparcg-connection/commit/5a2ca1213585d6fbb669a4341b28cad28e628ad8)) 166 | * sting transition property names ([6b45644](https://github.com/SuperFlyTV/casparcg-connection/commit/6b4564470cd66d41a35a51e211836430987e9dc8)) 167 | * update dependencies ([b481590](https://github.com/SuperFlyTV/casparcg-connection/commit/b48159035c1f27b29736556c6a1d5a015de83057)) 168 | * update docs ([50f3fb0](https://github.com/SuperFlyTV/casparcg-connection/commit/50f3fb02199367587d510db91d44e04f1a84ba3e)) 169 | 170 | 171 | ### Features 172 | 173 | * **call:** Adds SEEK support to call ([bad75ae](https://github.com/SuperFlyTV/casparcg-connection/commit/bad75ae625d0a3aaf7e2140447f5624ccda8c598)), closes [#5](https://github.com/SuperFlyTV/casparcg-connection/issues/5) 174 | * sting audio fade parameters ([9c64a9e](https://github.com/SuperFlyTV/casparcg-connection/commit/9c64a9ea6e61078e567927600c6e6d1d6c0c8b61)) 175 | 176 | 177 | 178 | # [4.7.0](https://github.com/SuperFlyTV/casparcg-connection/compare/4.6.1...4.7.0) (2019-04-11) 179 | 180 | 181 | ### Bug Fixes 182 | 183 | * **Validator:** refactor of PositiveNumberValidatorBetween ([1d6f41e](https://github.com/SuperFlyTV/casparcg-connection/commit/1d6f41e)) 184 | 185 | 186 | ### Features 187 | 188 | * **call:** adds all ffmpeg producer calls ([2edbe9d](https://github.com/SuperFlyTV/casparcg-connection/commit/2edbe9d)) 189 | * **call:** Adds SEEK support to call ([1704912](https://github.com/SuperFlyTV/casparcg-connection/commit/1704912)), closes [#5](https://github.com/SuperFlyTV/casparcg-connection/issues/5) 190 | * add support for IN property in PLAY command ([9e059be](https://github.com/SuperFlyTV/casparcg-connection/commit/9e059be)) 191 | 192 | 193 | 194 | 195 | ## [4.6.1](https://github.com/SuperFlyTV/casparcg-connection/compare/4.6.0...4.6.1) (2019-01-15) 196 | 197 | 198 | 199 | 200 | # [4.6.0](https://github.com/SuperFlyTV/casparcg-connection/compare/4.5.3...4.6.0) (2018-12-12) 201 | 202 | 203 | ### Bug Fixes 204 | 205 | * add back overloads for play methods ([40b12b4](https://github.com/SuperFlyTV/casparcg-connection/commit/40b12b4)) 206 | 207 | 208 | ### Features 209 | 210 | * channel_layout property ([c66fcfb](https://github.com/SuperFlyTV/casparcg-connection/commit/c66fcfb)) 211 | 212 | 213 | 214 | 215 | ## [4.5.3](https://github.com/SuperFlyTV/casparcg-connection/compare/4.5.2...4.5.3) (2018-10-03) 216 | 217 | 218 | ### Bug Fixes 219 | 220 | * **sting:** stingOverlayFilename can be the empty string ([afe1143](https://github.com/SuperFlyTV/casparcg-connection/commit/afe1143)) 221 | 222 | 223 | 224 | 225 | ## [4.5.2](https://github.com/SuperFlyTV/casparcg-connection/compare/4.5.1...4.5.2) (2018-09-24) 226 | 227 | 228 | 229 | 230 | ## [4.5.1](https://github.com/SuperFlyTV/casparcg-connection/compare/4.5.0...4.5.1) (2018-09-11) 231 | 232 | 233 | ### Bug Fixes 234 | 235 | * validation of transition paramters ([d128f98](https://github.com/SuperFlyTV/casparcg-connection/commit/d128f98)) 236 | 237 | 238 | 239 | 240 | # [4.5.0](https://github.com/SuperFlyTV/casparcg-connection/compare/4.4.0...4.5.0) (2018-08-14) 241 | 242 | 243 | ### Features 244 | 245 | * route command ([43bdf63](https://github.com/SuperFlyTV/casparcg-connection/commit/43bdf63)) 246 | 247 | 248 | 249 | 250 | # [4.4.0](https://github.com/SuperFlyTV/casparcg-connection/compare/4.3.1...4.4.0) (2018-08-07) 251 | 252 | 253 | ### Bug Fixes 254 | 255 | * **Commands:** Rejects promise on invalid command ([8632c79](https://github.com/SuperFlyTV/casparcg-connection/commit/8632c79)) 256 | 257 | 258 | ### Features 259 | 260 | * **Commands:** Better promise handling with fallback to empty promises instead of mixing promise and null handling ([8400194](https://github.com/SuperFlyTV/casparcg-connection/commit/8400194)) 261 | 262 | 263 | 264 | 265 | ## [4.3.1](https://github.com/SuperFlyTV/casparcg-connection/compare/4.3.0...4.3.1) (2018-08-02) 266 | 267 | 268 | ### Bug Fixes 269 | 270 | * Time command takes timecode parameter ([858e2a4](https://github.com/SuperFlyTV/casparcg-connection/commit/858e2a4)) 271 | 272 | 273 | 274 | 275 | # [4.3.0](https://github.com/SuperFlyTV/casparcg-connection/compare/4.2.2...4.3.0) (2018-08-02) 276 | 277 | 278 | ### Features 279 | 280 | * sting transition type ([538a4d0](https://github.com/SuperFlyTV/casparcg-connection/commit/538a4d0)) 281 | 282 | 283 | 284 | 285 | ## [4.2.2](https://github.com/SuperFlyTV/casparcg-connection/compare/4.2.1...4.2.2) (2018-07-02) 286 | 287 | 288 | ### Bug Fixes 289 | 290 | * use 'new' keyword instead of Object.create ([27c6055](https://github.com/SuperFlyTV/casparcg-connection/commit/27c6055)) 291 | 292 | 293 | 294 | 295 | ## [4.2.1](https://github.com/SuperFlyTV/casparcg-connection/compare/4.2.0...4.2.1) (2018-07-02) 296 | 297 | 298 | 299 | 300 | # [4.2.0](https://github.com/SuperFlyTV/casparcg-connection/compare/v4.1.1...v4.2.0) (2018-06-28) 301 | 302 | 303 | ### Bug Fixes 304 | 305 | * refactor package.json, add circleCI badge and correct ssh fingerprint ([37c5377](https://github.com/SuperFlyTV/casparcg-connection/commit/37c5377)) 306 | * ts lint errors ([84e830c](https://github.com/SuperFlyTV/casparcg-connection/commit/84e830c)) 307 | * typescrit imrovements ([3b8634e](https://github.com/SuperFlyTV/casparcg-connection/commit/3b8634e)) 308 | 309 | 310 | ### Features 311 | 312 | * initial convert to circleci ([842de38](https://github.com/SuperFlyTV/casparcg-connection/commit/842de38)) 313 | 314 | 315 | 316 | 317 | ## [4.1.1](https://github.com/SuperFlyTV/casparcg-connection/compare/v4.1.0...v4.1.1) (2018-06-27) 318 | 319 | 320 | ### Bug Fixes 321 | 322 | * don't set queueMode on uninitialized socket ([c6a8b06](https://github.com/SuperFlyTV/casparcg-connection/commit/c6a8b06)) 323 | 324 | 325 | 326 | 327 | # [4.1.0](https://github.com/SuperFlyTV/casparcg-connection/compare/v4.0.3...v4.1.0) (2018-05-22) 328 | 329 | 330 | 331 | 332 | ## [4.0.3](https://github.com/SuperFlyTV/casparcg-connection/compare/v4.0.2...v4.0.3) (2018-05-07) 333 | 334 | 335 | 336 | 337 | ## [4.0.2](https://github.com/SuperFlyTV/casparcg-connection/compare/v4.0.1...v4.0.2) (2018-05-04) 338 | 339 | 340 | 341 | 342 | ## [4.0.1](https://github.com/SuperFlyTV/casparcg-connection/compare/v3.0.1...v4.0.1) (2018-04-29) 343 | 344 | 345 | ### Bug Fixes 346 | 347 | * **TemplateData):** PR from Craig Sweaton escaping \r\n from template data. Prevents breaking the AMCP communication. 348 | 349 | 350 | 351 | # [4.0.0](https://github.com/SuperFlyTV/casparcg-connection/compare/v3.1.0...v4.0.0) (2018-04-10) 352 | 353 | 354 | ### Features 355 | 356 | * **Asynchronous commands:** refactor the internal command execution ([7b10d43](https://github.com/SuperFlyTV/casparcg-connection/commit/7b10d43)) 357 | * **QueueMode:** allow runtime configuration ([f39f7ea](https://github.com/SuperFlyTV/casparcg-connection/commit/f39f7ea)) 358 | * **Scheduled Commands:** keep sequential mode for compatibility ([48a760f](https://github.com/SuperFlyTV/casparcg-connection/commit/48a760f)) 359 | * **Scheduled Commands:** schedule and resolve scheduled commands ([5a9f0fb](https://github.com/SuperFlyTV/casparcg-connection/commit/5a9f0fb)) 360 | 361 | 362 | * . ([7a8d4c6](https://github.com/SuperFlyTV/casparcg-connection/commit/7a8d4c6)) 363 | 364 | 365 | ### BREAKING CHANGES 366 | 367 | * set default queue mode to SALVO, making the library fully asynchronous. 368 | 369 | 370 | 371 | 372 | # [3.1.0](https://github.com/SuperFlyTV/casparcg-connection/compare/v3.0.2...v3.1.0) (2018-03-28) 373 | 374 | 375 | ### Features 376 | 377 | * **Add/Remove Commands:** Merge branch 'Besedin86/master' into develop ([ca772e6](https://github.com/SuperFlyTV/casparcg-connection/commit/ca772e6)) 378 | 379 | 380 | 381 | 382 | ## [3.0.2](https://github.com/SuperFlyTV/casparcg-connection/compare/v3.0.1...v3.0.2) (2018-03-27) 383 | 384 | 385 | ### Bug Fixes 386 | 387 | * **CGAddCommand:** playOnLoad is required ([3fc9300](https://github.com/SuperFlyTV/casparcg-connection/commit/3fc9300)), closes [#93](https://github.com/SuperFlyTV/casparcg-connection/issues/93) 388 | 389 | 390 | 391 | 392 | ## [3.0.1](https://github.com/SuperFlyTV/casparcg-connection/compare/v3.0.0...v3.0.1) (2017-10-22) 393 | 394 | 395 | ### Bug Fixes 396 | 397 | * **MixerCommands:** Corrected copy/paste errors for several mixer commands, related to transitions. ([29bc5a5](https://github.com/SuperFlyTV/casparcg-connection/commit/29bc5a5)), closes [#89](https://github.com/SuperFlyTV/casparcg-connection/issues/89) 398 | 399 | 400 | 401 | 402 | # [3.0.0](https://github.com/SuperFlyTV/casparcg-connection/compare/v3.0.0-next.4...v3.0.0) (2017-10-16) 403 | 404 | 405 | ### Bug Fixes 406 | 407 | * **TemplateData:** Correct double escaping of quotes xml strings ([5bda229](https://github.com/SuperFlyTV/casparcg-connection/commit/5bda229)) 408 | 409 | 410 | 411 | 412 | # [3.0.0-next.4](https://github.com/SuperFlyTV/casparcg-connection/compare/v3.0.0-next.3...v3.0.0-next.4) (2017-10-03) 413 | 414 | 415 | ### Bug Fixes 416 | 417 | * **Config:** Correct cross-version-import between configVOs ([bdcedfd](https://github.com/SuperFlyTV/casparcg-connection/commit/bdcedfd)) 418 | * **onConnection:** Bug with promise resolve and queue conducting on initial connection ([4504cb9](https://github.com/SuperFlyTV/casparcg-connection/commit/4504cb9)) 419 | 420 | 421 | 422 | 423 | # [3.0.0-next.3](https://github.com/SuperFlyTV/casparcg-connection/compare/v3.0.0-next.2...v3.0.0-next.3) (2017-07-31) 424 | 425 | 426 | ### Bug Fixes 427 | 428 | * **Socket:** Fixed bug with disposing socket clients before they are created ([7a0a510](https://github.com/SuperFlyTV/casparcg-connection/commit/7a0a510)) 429 | 430 | 431 | 432 | 433 | # [3.0.0-next.2](https://github.com/SuperFlyTV/casparcg-connection/compare/3.0.0-next.1...v3.0.0-next.2) (2017-07-24) 434 | 435 | 436 | 437 | 438 | # [3.0.0-next.1](https://github.com/SuperFlyTV/casparcg-connection/compare/v3.0.0-next.0...v3.0.0-next.1) (2017-07-20) 439 | 440 | 441 | ### Features 442 | 443 | * **VirginServer:** Report server's virgin-state on connected events ([a8f3b61](https://github.com/SuperFlyTV/casparcg-connection/commit/a8f3b61)) 444 | 445 | 446 | 447 | 448 | # [3.0.0-next.0](https://github.com/SuperFlyTV/casparcg-connection/compare/v2.1.0...v3.0.0-next.0) (2017-07-20) 449 | 450 | 451 | ### Bug Fixes 452 | 453 | * **Command:** Critical error with command timeouts ([6caeacb](https://github.com/SuperFlyTV/casparcg-connection/commit/6caeacb)) 454 | * **Config:** Bugfix with manual operations parsing config XML ([e80117b](https://github.com/SuperFlyTV/casparcg-connection/commit/e80117b)) 455 | * **Events:** Critical bug introduced in v1.0.0 ([7dcd9fe](https://github.com/SuperFlyTV/casparcg-connection/commit/7dcd9fe)) 456 | * **Logging:** Better handling of errors ([d80c794](https://github.com/SuperFlyTV/casparcg-connection/commit/d80c794)) 457 | * **Socket:** Prevents timeout of extremely long responses ([613c629](https://github.com/SuperFlyTV/casparcg-connection/commit/613c629)) 458 | * **Socket:** Removed unused internal event ([5eb343c](https://github.com/SuperFlyTV/casparcg-connection/commit/5eb343c)) 459 | * **Version:** bugfix for setting manuel ServerVersion ([8362a22](https://github.com/SuperFlyTV/casparcg-connection/commit/8362a22)) 460 | 461 | 462 | ### Code Refactoring 463 | 464 | * **Enum:** Changed the public enum ServerVersion ([b7aadea](https://github.com/SuperFlyTV/casparcg-connection/commit/b7aadea)) 465 | 466 | 467 | ### Features 468 | 469 | * **Command:** Planned for better timeout retry strategy ([b0b5f19](https://github.com/SuperFlyTV/casparcg-connection/commit/b0b5f19)) 470 | * **Config:** Added members "vo" and "xml" as aliases to "VO" and "XML" on Config objects ([65dffde](https://github.com/SuperFlyTV/casparcg-connection/commit/65dffde)) 471 | * **Queue:** Added prioritized queues ([929e4ad](https://github.com/SuperFlyTV/casparcg-connection/commit/929e4ad)), closes [#15](https://github.com/SuperFlyTV/casparcg-connection/issues/15) 472 | * **Version:** Added version promise to CasparCG ([554ea41](https://github.com/SuperFlyTV/casparcg-connection/commit/554ea41)), closes [#73](https://github.com/SuperFlyTV/casparcg-connection/issues/73) 473 | 474 | 475 | ### BREAKING CHANGES 476 | 477 | * **Enum:** enum ServerVersion is now CasparCGVersion, for consistency. 478 | 479 | 480 | 481 | 482 | # [2.1.0](https://github.com/SuperFlyTV/casparcg-connection/compare/v2.0.3...v2.1.0) (2017-07-18) 483 | 484 | 485 | ### Bug Fixes 486 | 487 | * CasparCG Connection events ([a825a18](https://github.com/SuperFlyTV/casparcg-connection/commit/a825a18)) 488 | 489 | 490 | ### Features 491 | 492 | * **Commands:** Better command timeout strategy ([bd51c31](https://github.com/SuperFlyTV/casparcg-connection/commit/bd51c31)) 493 | * **Socket:** (Re)connection strategy ([3d04e5f](https://github.com/SuperFlyTV/casparcg-connection/commit/3d04e5f)), closes [#66](https://github.com/SuperFlyTV/casparcg-connection/issues/66) 494 | * **Socket:** Reconnection ([90ca334](https://github.com/SuperFlyTV/casparcg-connection/commit/90ca334)) 495 | 496 | 497 | 498 | 499 | ## [2.0.3](https://github.com/SuperFlyTV/casparcg-connection/compare/v2.0.2...v2.0.3) (2017-07-12) 500 | 501 | 502 | ### Bug Fixes 503 | 504 | * events ([1288f34](https://github.com/SuperFlyTV/casparcg-connection/commit/1288f34)) 505 | 506 | 507 | 508 | 509 | ## [2.0.2](https://github.com/SuperFlyTV/casparcg-connection/compare/v2.0.1...v2.0.2) (2017-07-12) 510 | 511 | 512 | 513 | 514 | ## [2.0.1](https://github.com/SuperFlyTV/casparcg-connection/compare/v2.0.0...v2.0.1) (2017-07-12) 515 | 516 | 517 | 518 | 519 | # [2.0.0](https://github.com/SuperFlyTV/casparcg-connection/compare/v1.0.0...v2.0.0) (2017-07-12) 520 | 521 | 522 | ### Bug Fixes 523 | 524 | * ES5 target ([f41b04f](https://github.com/SuperFlyTV/casparcg-connection/commit/f41b04f)) 525 | 526 | 527 | ### BREAKING CHANGES 528 | 529 | * Reverted ES6 target back to ES5 due to Meteor compability 530 | 531 | 532 | 533 | 534 | # [1.0.0](https://github.com/SuperFlyTV/casparcg-connection/compare/v0.17.2...v1.0.0) (2017-07-12) 535 | 536 | 537 | ### build 538 | 539 | * ES6 target ([41b1292](https://github.com/SuperFlyTV/casparcg-connection/commit/41b1292)) 540 | 541 | 542 | ### Features 543 | 544 | * SocketState changed ([e1fdd5b](https://github.com/SuperFlyTV/casparcg-connection/commit/e1fdd5b)) 545 | 546 | 547 | ### BREAKING CHANGES 548 | 549 | * build target ES2015 (ES6). This required Node.js version 6.4.0 or higher to fully function. 550 | 551 | 552 | 553 | 554 | ## [0.17.2](https://github.com/SuperFlyTV/casparcg-connection/compare/v0.17.1...v0.17.2) (2017-06-18) 555 | 556 | 557 | ### Bug Fixes 558 | 559 | * typo ([5214ead](https://github.com/SuperFlyTV/casparcg-connection/commit/5214ead)) 560 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2017 Jesper Stærkær, SuperFly.tv 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # casparcg-connection 2 | 3 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](https://raw.githubusercontent.com/SuperFlyTV/casparcg-connection/master/LICENSE) [![npm](https://img.shields.io/npm/v/casparcg-connection.svg?style=flat-square)](https://www.npmjs.com/package/casparcg-connection) 4 | 5 | [![API Docs](https://img.shields.io/badge/Docs-Api-orange.svg?style=flat-square)](https://superflytv.github.io/casparcg-connection/) [![Guide](https://img.shields.io/badge/Docs-Getting%20started%20guide-orange.svg?style=flat-square)](https://superfly-tv.gitbooks.io/casparcg-connection-getting-started-guide/content/) 6 | 7 | ## Introduction 8 | 9 | CasparCG Server is an open source graphics- and video server for broadcast and streaming productions. This library lets you connect and interact with CasparCG Servers from Node.js in Javascript. 10 | This library is also a part of the [**Sofie** TV News Studio Automation System](https://github.com/nrkno/Sofie-TV-automation/). 11 | 12 | ### Features 13 | 14 | - CasparCG AMCP 2.3 protocol implemented 15 | - CasparCG AMCP 2.1 protocol largely implemented 16 | - Parsing of command parameters and response 17 | - Queueing of commands 18 | - Promise-based commands for easy chaining and sequences 19 | 20 | ### Project 21 | 22 | - Node.js 23 | - npm package 24 | - TypeScript, strongly typed 25 | - ES2020 target 26 | - Linted with standard ESLint rules 27 | - [API Docs](https://superflytv.github.io/casparcg-connection/) 28 | - [MIT license](https://raw.githubusercontent.com/SuperFlyTV/casparcg-connection/master/LICENSE) 29 | 30 | ## Getting started 31 | 32 | ### Installing with NPM 33 | 34 | ``` 35 | npm install casparcg-connection --save 36 | ``` 37 | 38 | This installs the full project with sourcecode and dependencies, typescript project files and the compiled .js output with typings. 39 | 40 | In your code, include and use the CasparCG object from this library with a code similar to: 41 | 42 | ```javascript 43 | const { CasparCG } = require('casparcg-connection') 44 | 45 | const connection = new CasparCG() 46 | const { error, request } = await connection.play({ channel: 1, layer: 1, clip: 'amb' }) 47 | if (error) { 48 | console.log('Error when sending', error) 49 | } else { 50 | const response = await request 51 | console.log(response) 52 | } 53 | ``` 54 | 55 | _Note: as of 6.0.0 the library has had a major rewrite with significant API changes and support for CasparCG 2.0 was dropped._ 56 | 57 | ### Build from source 58 | 59 | Installing with yarn adds the dev-dependencies needed to compile TypeScript. A set of commands help you managing development and testing: 60 | 61 | - **`yarn clean`** Empties the `/dist` directory. 62 | - **`yarn build`** Runs a single build command without watching for changes. 63 | - **`yarn build -w`** Rebuilds on every change. 64 | - **`yarn lint`** Runs code linting. Pull Requests won't be accepted without lint compliance. 65 | - **`yarn test`** Runs code tests through Jest. 66 | 67 | ## Doing a release 68 | 69 | Run `yarn changelog` to generate the changelog, tags and commit. Push these changes and the newly made tag. 70 | 71 | ## Documentation 72 | 73 | Visit [https://superflytv.github.io/casparcg-connection/](https://superflytv.github.io/casparcg-connection/) for API documentation. 74 | 75 | ## About 76 | 77 | Created and published by [SuperFly.tv](http://superfly.tv) 78 | 79 | ## Acknowledgements: 80 | 81 | - Many thanks to SVT for the CasparCG project 82 | - Inspired by https://github.com/respectTheCode/node-caspar-cg 83 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ['ts', 'js'], 3 | transform: { 4 | '^.+\\.(ts|tsx)$': [ 5 | 'ts-jest', 6 | { 7 | tsconfig: 'tsconfig.json', 8 | }, 9 | ], 10 | }, 11 | testMatch: ['**/__tests__/**/*.spec.(ts|js)'], 12 | testPathIgnorePatterns: ['integrationTests'], 13 | testEnvironment: 'node', 14 | coverageThreshold: { 15 | global: { 16 | branches: 0, 17 | functions: 0, 18 | lines: 0, 19 | statements: 0, 20 | }, 21 | }, 22 | coverageDirectory: './coverage/', 23 | collectCoverage: true, 24 | } 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "casparcg-connection", 3 | "version": "6.3.0", 4 | "description": "Node.js Javascript/Typescript library for CasparCG connection and commands.", 5 | "main": "dist/index.js", 6 | "typings": "dist/index.d.ts", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/SuperFlyTV/casparcg-connection.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/SuperFlyTV/casparcg-connection/issues" 14 | }, 15 | "homepage": "http://superfly.tv", 16 | "author": { 17 | "name": "Jesper Stærkær", 18 | "email": "jesper@superfly.tv", 19 | "url": "http://superfly.tv" 20 | }, 21 | "contributors": [ 22 | { 23 | "name": "Johan Nyman", 24 | "email": "johan@superfly.tv", 25 | "url": "http://superfly.tv" 26 | }, 27 | { 28 | "name": "Balte de Wit", 29 | "email": "balte.dewit@gmail.com", 30 | "url": "http://balte.nl" 31 | }, 32 | { 33 | "name": "Andreas Jeansson", 34 | "email": "andreas.jeansson@svt.se", 35 | "url": "http://svt.se" 36 | }, 37 | { 38 | "name": "Aleksandr Besedin", 39 | "email": "sasha.besedin@gmail.com", 40 | "url": "http://cosmonova.net" 41 | }, 42 | { 43 | "name": "Craig Sweaton", 44 | "email": "craig@csweaton.com", 45 | "url": "https://csweaton.com" 46 | }, 47 | { 48 | "name": "Stephan Nordnes Eriksen", 49 | "email": "Stephanruler@gmail.com" 50 | } 51 | ], 52 | "scripts": { 53 | "info": "npm-scripts-info", 54 | "build": "rimraf dist && run build:main", 55 | "build:main": "tsc -p tsconfig.build.json", 56 | "unit": "jest --forceExit", 57 | "test": "run lint && run unit", 58 | "test:integration": "run lint && jest --config=jest-integration.config.js", 59 | "watch": "jest --watch", 60 | "cov": "jest --coverage; open-cli coverage/lcov-report/index.html", 61 | "cov-open": "open-cli coverage/lcov-report/index.html", 62 | "docs": "typedoc ./src/index.ts", 63 | "changelog": "sofie-version", 64 | "release": "run reset && run test && run changelog", 65 | "release:skiptest": "run reset && run changelog", 66 | "reset": "git clean -dfx && git reset --hard && yarn", 67 | "validate:dependencies": "yarn npm audit --environment production && run license-validate", 68 | "validate:dev-dependencies": "yarn npm audit --environment development", 69 | "prepare": "husky install", 70 | "lint:raw": "run eslint --ext .ts --ext .js --ext .tsx --ext .jsx --ignore-pattern dist", 71 | "lint": "run lint:raw .", 72 | "lint-fix": "run lint --fix", 73 | "license-validate": "sofie-licensecheck", 74 | "eslint": "./node_modules/.bin/eslint", 75 | "prettier": "./node_modules/.bin/prettier", 76 | "lint-staged": "./node_modules/.bin/lint-staged" 77 | }, 78 | "engines": { 79 | "node": ">=14.18" 80 | }, 81 | "files": [ 82 | "/dist", 83 | "/CHANGELOG.md", 84 | "/README.md", 85 | "/LICENSE" 86 | ], 87 | "devDependencies": { 88 | "@sofie-automation/code-standard-preset": "~2.5.2", 89 | "@types/jest": "^29.4.0", 90 | "@types/node": "^16.11.45", 91 | "@types/xml2js": "^0.4.11", 92 | "jest": "^29.4.3", 93 | "open-cli": "^7.1.0", 94 | "pkg-pr-new": "^0.0.50", 95 | "rimraf": "^4.3.1", 96 | "ts-jest": "^29.0.5", 97 | "typedoc": "^0.23.25", 98 | "typescript": "~4.9" 99 | }, 100 | "keywords": [ 101 | "casparcg", 102 | "caspar", 103 | "amcp", 104 | "socket", 105 | "tcp", 106 | "broadcast", 107 | "graphics", 108 | "superfly", 109 | "connection", 110 | "middleware", 111 | "remote" 112 | ], 113 | "dependencies": { 114 | "eventemitter3": "^5.0.1", 115 | "tslib": "^2.6.3", 116 | "xml2js": "^0.6.2" 117 | }, 118 | "prettier": "@sofie-automation/code-standard-preset/.prettierrc.json", 119 | "lint-staged": { 120 | "*.{css,json,md,scss}": [ 121 | "run prettier --write" 122 | ], 123 | "*.{ts,tsx,js,jsx}": [ 124 | "run lint:raw --fix" 125 | ] 126 | }, 127 | "packageManager": "yarn@3.8.3" 128 | } 129 | -------------------------------------------------------------------------------- /src/CasparCG.ts: -------------------------------------------------------------------------------- 1 | import { BasicCasparCGAPI, SendResult } from './api' 2 | import { CReturnType, Commands } from './commands' 3 | import { 4 | LoadbgParameters, 5 | LoadParameters, 6 | PlayParameters, 7 | PauseParameters, 8 | ResumeParameters, 9 | StopParameters, 10 | ClearParameters, 11 | CallParameters, 12 | SwapParameters, 13 | AddParameters, 14 | RemoveParameters, 15 | PrintParameters, 16 | LogLevelParameters, 17 | LogCategoryParameters, 18 | SetParameters, 19 | LockParameters, 20 | DataStoreParameters, 21 | DataRetrieveParameters, 22 | DataListParameters, 23 | DataRemoveParameters, 24 | CgAddParameters, 25 | CgPlayParameters, 26 | CgStopParameters, 27 | CgNextParameters, 28 | CgRemoveParameters, 29 | CgClearParameters, 30 | CgUpdateParameters, 31 | CgInvokeParameters, 32 | CgInfoParameters, 33 | MixerKeyerParameters, 34 | MixerChromaParameters, 35 | MixerBlendParameters, 36 | MixerInvertParameters, 37 | MixerOpacityParameters, 38 | MixerBrightnessParameters, 39 | MixerSaturationParameters, 40 | MixerContrastParameters, 41 | MixerLevelsParameters, 42 | MixerFillParameters, 43 | MixerClipParameters, 44 | MixerAnchorParameters, 45 | MixerCropParameters, 46 | MixerRotationParameters, 47 | MixerPerspectiveParameters, 48 | MixerMipmapParameters, 49 | MixerVolumeParameters, 50 | MixerMastervolumeParameters, 51 | MixerStraightAlphaOutputParameters, 52 | MixerGridParameters, 53 | MixerCommitParameters, 54 | MixerClearParameters, 55 | ChannelGridParameters, 56 | ThumbnailListParameters, 57 | ThumbnailRetrieveParameters, 58 | ThumbnailGenerateParameters, 59 | ThumbnailGenerateAllParameters, 60 | CinfParameters, 61 | ClsParameters, 62 | FlsParameters, 63 | TlsParameters, 64 | VersionParameters, 65 | InfoParameters, 66 | InfoTemplateParameters, 67 | InfoConfigParameters, 68 | InfoPathsParameters, 69 | InfoSystemParameters, 70 | InfoServerParameters, 71 | InfoQueuesParameters, 72 | InfoThreadsParameters, 73 | InfoDelayParameters, 74 | DiagParameters, 75 | GlInfoParameters, 76 | GlGcParameters, 77 | ByeParameters, 78 | KillParameters, 79 | RestartParameters, 80 | InfoChannelParameters, 81 | InfoLayerParameters, 82 | PingParameters, 83 | BeginParameters, 84 | CommitParameters, 85 | DiscardParameters, 86 | CustomCommandParameters, 87 | PlayHtmlParameters, 88 | LoadbgHtmlParameters, 89 | } from './parameters' 90 | 91 | export class CasparCG extends BasicCasparCGAPI { 92 | async loadbg(params: LoadbgParameters): Promise> { 93 | return this.executeCommand({ 94 | command: Commands.Loadbg, 95 | params, 96 | }) 97 | } 98 | async loadbgHtml(params: LoadbgHtmlParameters): Promise> { 99 | return this.executeCommand({ 100 | command: Commands.LoadbgHtml, 101 | params, 102 | }) 103 | } 104 | async load(params: LoadParameters): Promise> { 105 | return this.executeCommand({ 106 | command: Commands.Load, 107 | params, 108 | }) 109 | } 110 | async play(params: PlayParameters): Promise> { 111 | return this.executeCommand({ 112 | command: Commands.Play, 113 | params, 114 | }) 115 | } 116 | async playHtml(params: PlayHtmlParameters): Promise> { 117 | return this.executeCommand({ 118 | command: Commands.PlayHtml, 119 | params, 120 | }) 121 | } 122 | async pause(params: PauseParameters): Promise> { 123 | return this.executeCommand({ 124 | command: Commands.Pause, 125 | params, 126 | }) 127 | } 128 | async resume(params: ResumeParameters): Promise> { 129 | return this.executeCommand({ 130 | command: Commands.Resume, 131 | params, 132 | }) 133 | } 134 | async stop(params: StopParameters): Promise> { 135 | return this.executeCommand({ 136 | command: Commands.Stop, 137 | params, 138 | }) 139 | } 140 | async clear(params: ClearParameters): Promise> { 141 | return this.executeCommand({ 142 | command: Commands.Clear, 143 | params, 144 | }) 145 | } 146 | async call(params: CallParameters): Promise> { 147 | return this.executeCommand({ 148 | command: Commands.Call, 149 | params, 150 | }) 151 | } 152 | async swap(params: SwapParameters): Promise> { 153 | return this.executeCommand({ 154 | command: Commands.Swap, 155 | params, 156 | }) 157 | } 158 | async add(params: AddParameters): Promise> { 159 | return this.executeCommand({ 160 | command: Commands.Add, 161 | params, 162 | }) 163 | } 164 | async remove(params: RemoveParameters): Promise> { 165 | return this.executeCommand({ 166 | command: Commands.Remove, 167 | params, 168 | }) 169 | } 170 | async print(params: PrintParameters): Promise> { 171 | return this.executeCommand({ 172 | command: Commands.Print, 173 | params, 174 | }) 175 | } 176 | async logLevel(params: LogLevelParameters): Promise> { 177 | return this.executeCommand({ 178 | command: Commands.LogLevel, 179 | params, 180 | }) 181 | } 182 | async logCategory(params: LogCategoryParameters): Promise> { 183 | return this.executeCommand({ 184 | command: Commands.LogCategory, 185 | params, 186 | }) 187 | } 188 | async set(params: SetParameters): Promise> { 189 | return this.executeCommand({ 190 | command: Commands.Set, 191 | params, 192 | }) 193 | } 194 | async lock(params: LockParameters): Promise> { 195 | return this.executeCommand({ 196 | command: Commands.Lock, 197 | params, 198 | }) 199 | } 200 | async dataStore(params: DataStoreParameters): Promise> { 201 | return this.executeCommand({ 202 | command: Commands.DataStore, 203 | params, 204 | }) 205 | } 206 | async dataRetrieve(params: DataRetrieveParameters): Promise> { 207 | return this.executeCommand({ 208 | command: Commands.DataRetrieve, 209 | params, 210 | }) 211 | } 212 | async dataList(params: DataListParameters): Promise> { 213 | return this.executeCommand({ 214 | command: Commands.DataList, 215 | params, 216 | }) 217 | } 218 | async dataRemove(params: DataRemoveParameters): Promise> { 219 | return this.executeCommand({ 220 | command: Commands.DataRemove, 221 | params, 222 | }) 223 | } 224 | async cgAdd(params: CgAddParameters): Promise> { 225 | return this.executeCommand({ 226 | command: Commands.CgAdd, 227 | params, 228 | }) 229 | } 230 | async cgPlay(params: CgPlayParameters): Promise> { 231 | return this.executeCommand({ 232 | command: Commands.CgPlay, 233 | params, 234 | }) 235 | } 236 | async cgStop(params: CgStopParameters): Promise> { 237 | return this.executeCommand({ 238 | command: Commands.CgStop, 239 | params, 240 | }) 241 | } 242 | async cgNext(params: CgNextParameters): Promise> { 243 | return this.executeCommand({ 244 | command: Commands.CgNext, 245 | params, 246 | }) 247 | } 248 | async cgRemove(params: CgRemoveParameters): Promise> { 249 | return this.executeCommand({ 250 | command: Commands.CgRemove, 251 | params, 252 | }) 253 | } 254 | async cgClear(params: CgClearParameters): Promise> { 255 | return this.executeCommand({ 256 | command: Commands.CgClear, 257 | params, 258 | }) 259 | } 260 | async cgUpdate(params: CgUpdateParameters): Promise> { 261 | return this.executeCommand({ 262 | command: Commands.CgUpdate, 263 | params, 264 | }) 265 | } 266 | async cgInvoke(params: CgInvokeParameters): Promise> { 267 | return this.executeCommand({ 268 | command: Commands.CgInvoke, 269 | params, 270 | }) 271 | } 272 | async cgInfo(params: CgInfoParameters): Promise> { 273 | return this.executeCommand({ 274 | command: Commands.CgInfo, 275 | params, 276 | }) 277 | } 278 | async mixerKeyer(params: MixerKeyerParameters): Promise> { 279 | return this.executeCommand({ 280 | command: Commands.MixerKeyer, 281 | params, 282 | }) 283 | } 284 | async mixerChroma(params: MixerChromaParameters): Promise> { 285 | return this.executeCommand({ 286 | command: Commands.MixerChroma, 287 | params, 288 | }) 289 | } 290 | async mixerBlend(params: MixerBlendParameters): Promise> { 291 | return this.executeCommand({ 292 | command: Commands.MixerBlend, 293 | params, 294 | }) 295 | } 296 | async mixerInvert(params: MixerInvertParameters): Promise> { 297 | return this.executeCommand({ 298 | command: Commands.MixerInvert, 299 | params, 300 | }) 301 | } 302 | async mixerOpacity(params: MixerOpacityParameters): Promise> { 303 | return this.executeCommand({ 304 | command: Commands.MixerOpacity, 305 | params, 306 | }) 307 | } 308 | async mixerBrightness(params: MixerBrightnessParameters): Promise> { 309 | return this.executeCommand({ 310 | command: Commands.MixerBrightness, 311 | params, 312 | }) 313 | } 314 | async mixerSaturation(params: MixerSaturationParameters): Promise> { 315 | return this.executeCommand({ 316 | command: Commands.MixerSaturation, 317 | params, 318 | }) 319 | } 320 | async mixerContrast(params: MixerContrastParameters): Promise> { 321 | return this.executeCommand({ 322 | command: Commands.MixerContrast, 323 | params, 324 | }) 325 | } 326 | async mixerLevels(params: MixerLevelsParameters): Promise> { 327 | return this.executeCommand({ 328 | command: Commands.MixerLevels, 329 | params, 330 | }) 331 | } 332 | async mixerFill(params: MixerFillParameters): Promise> { 333 | return this.executeCommand({ 334 | command: Commands.MixerFill, 335 | params, 336 | }) 337 | } 338 | async mixerClip(params: MixerClipParameters): Promise> { 339 | return this.executeCommand({ 340 | command: Commands.MixerClip, 341 | params, 342 | }) 343 | } 344 | async mixerAnchor(params: MixerAnchorParameters): Promise> { 345 | return this.executeCommand({ 346 | command: Commands.MixerAnchor, 347 | params, 348 | }) 349 | } 350 | async mixerCrop(params: MixerCropParameters): Promise> { 351 | return this.executeCommand({ 352 | command: Commands.MixerCrop, 353 | params, 354 | }) 355 | } 356 | async mixerRotation(params: MixerRotationParameters): Promise> { 357 | return this.executeCommand({ 358 | command: Commands.MixerRotation, 359 | params, 360 | }) 361 | } 362 | async mixerPerspective(params: MixerPerspectiveParameters): Promise> { 363 | return this.executeCommand({ 364 | command: Commands.MixerPerspective, 365 | params, 366 | }) 367 | } 368 | async mixerMipmap(params: MixerMipmapParameters): Promise> { 369 | return this.executeCommand({ 370 | command: Commands.MixerMipmap, 371 | params, 372 | }) 373 | } 374 | async mixerVolume(params: MixerVolumeParameters): Promise> { 375 | return this.executeCommand({ 376 | command: Commands.MixerVolume, 377 | params, 378 | }) 379 | } 380 | async mixerMastervolume(params: MixerMastervolumeParameters): Promise> { 381 | return this.executeCommand({ 382 | command: Commands.MixerMastervolume, 383 | params, 384 | }) 385 | } 386 | async mixerStraightAlphaOutput( 387 | params: MixerStraightAlphaOutputParameters 388 | ): Promise> { 389 | return this.executeCommand({ 390 | command: Commands.MixerStraightAlphaOutput, 391 | params, 392 | }) 393 | } 394 | async mixerGrid(params: MixerGridParameters): Promise> { 395 | return this.executeCommand({ 396 | command: Commands.MixerGrid, 397 | params, 398 | }) 399 | } 400 | async mixerCommit(params: MixerCommitParameters): Promise> { 401 | return this.executeCommand({ 402 | command: Commands.MixerCommit, 403 | params, 404 | }) 405 | } 406 | async mixerClear(params: MixerClearParameters): Promise> { 407 | return this.executeCommand({ 408 | command: Commands.MixerClear, 409 | params, 410 | }) 411 | } 412 | async channelGrid(params: ChannelGridParameters = {}): Promise> { 413 | return this.executeCommand({ 414 | command: Commands.ChannelGrid, 415 | params, 416 | }) 417 | } 418 | async thumbnailList(params: ThumbnailListParameters = {}): Promise> { 419 | return this.executeCommand({ 420 | command: Commands.ThumbnailList, 421 | params, 422 | }) 423 | } 424 | async thumbnailRetrieve(params: ThumbnailRetrieveParameters): Promise> { 425 | return this.executeCommand({ 426 | command: Commands.ThumbnailRetrieve, 427 | params, 428 | }) 429 | } 430 | async thumbnailGenerate(params: ThumbnailGenerateParameters): Promise> { 431 | return this.executeCommand({ 432 | command: Commands.ThumbnailGenerate, 433 | params, 434 | }) 435 | } 436 | async thumbnailGenerateAll( 437 | params: ThumbnailGenerateAllParameters = {} 438 | ): Promise> { 439 | return this.executeCommand({ 440 | command: Commands.ThumbnailGenerateAll, 441 | params, 442 | }) 443 | } 444 | async cinf(params: CinfParameters): Promise> { 445 | return this.executeCommand({ 446 | command: Commands.Cinf, 447 | params, 448 | }) 449 | } 450 | async cls(params: ClsParameters = {}): Promise> { 451 | return this.executeCommand({ 452 | command: Commands.Cls, 453 | params, 454 | }) 455 | } 456 | async fls(params: FlsParameters = {}): Promise> { 457 | return this.executeCommand({ 458 | command: Commands.Fls, 459 | params, 460 | }) 461 | } 462 | async tls(params: TlsParameters = {}): Promise> { 463 | return this.executeCommand({ 464 | command: Commands.Tls, 465 | params, 466 | }) 467 | } 468 | async version(params: VersionParameters = {}): Promise> { 469 | return this.executeCommand({ 470 | command: Commands.Version, 471 | params, 472 | }) 473 | } 474 | async info(params: InfoParameters): Promise> { 475 | return this.executeCommand({ 476 | command: Commands.Info, 477 | params, 478 | }) 479 | } 480 | async infoChannel(params: InfoChannelParameters): Promise> { 481 | return this.executeCommand({ 482 | command: Commands.InfoChannel, 483 | params, 484 | }) 485 | } 486 | async infoLayer(params: InfoLayerParameters): Promise> { 487 | return this.executeCommand({ 488 | command: Commands.InfoLayer, 489 | params, 490 | }) 491 | } 492 | async infoTemplate(params: InfoTemplateParameters): Promise> { 493 | return this.executeCommand({ 494 | command: Commands.InfoTemplate, 495 | params, 496 | }) 497 | } 498 | async infoConfig(params: InfoConfigParameters = {}): Promise> { 499 | return this.executeCommand({ 500 | command: Commands.InfoConfig, 501 | params, 502 | }) 503 | } 504 | async infoPaths(params: InfoPathsParameters = {}): Promise> { 505 | return this.executeCommand({ 506 | command: Commands.InfoPaths, 507 | params, 508 | }) 509 | } 510 | async infoSystem(params: InfoSystemParameters = {}): Promise> { 511 | return this.executeCommand({ 512 | command: Commands.InfoSystem, 513 | params, 514 | }) 515 | } 516 | async infoServer(params: InfoServerParameters = {}): Promise> { 517 | return this.executeCommand({ 518 | command: Commands.InfoServer, 519 | params, 520 | }) 521 | } 522 | async infoQueues(params: InfoQueuesParameters = {}): Promise> { 523 | return this.executeCommand({ 524 | command: Commands.InfoQueues, 525 | params, 526 | }) 527 | } 528 | async infoThreads(params: InfoThreadsParameters = {}): Promise> { 529 | return this.executeCommand({ 530 | command: Commands.InfoThreads, 531 | params, 532 | }) 533 | } 534 | async infoDelay(params: InfoDelayParameters): Promise> { 535 | return this.executeCommand({ 536 | command: Commands.InfoDelay, 537 | params, 538 | }) 539 | } 540 | async diag(params: DiagParameters = {}): Promise> { 541 | return this.executeCommand({ 542 | command: Commands.Diag, 543 | params, 544 | }) 545 | } 546 | async glInfo(params: GlInfoParameters = {}): Promise> { 547 | return this.executeCommand({ 548 | command: Commands.GlInfo, 549 | params, 550 | }) 551 | } 552 | async glGc(params: GlGcParameters = {}): Promise> { 553 | return this.executeCommand({ 554 | command: Commands.GlGc, 555 | params, 556 | }) 557 | } 558 | async bye(params: ByeParameters = {}): Promise> { 559 | return this.executeCommand({ 560 | command: Commands.Bye, 561 | params, 562 | }) 563 | } 564 | async kill(params: KillParameters = {}): Promise> { 565 | return this.executeCommand({ 566 | command: Commands.Kill, 567 | params, 568 | }) 569 | } 570 | async restart(params: RestartParameters = {}): Promise> { 571 | return this.executeCommand({ 572 | command: Commands.Restart, 573 | params, 574 | }) 575 | } 576 | async ping(params: PingParameters = {}): Promise> { 577 | return this.executeCommand({ 578 | command: Commands.Ping, 579 | params, 580 | }) 581 | } 582 | async begin(params: BeginParameters = {}): Promise> { 583 | return this.executeCommand({ 584 | command: Commands.Begin, 585 | params, 586 | }) 587 | } 588 | async commit(params: CommitParameters = {}): Promise> { 589 | return this.executeCommand({ 590 | command: Commands.Commit, 591 | params, 592 | }) 593 | } 594 | async discard(params: DiscardParameters = {}): Promise> { 595 | return this.executeCommand({ 596 | command: Commands.Discard, 597 | params, 598 | }) 599 | } 600 | async sendCustom(params: CustomCommandParameters): Promise> { 601 | return this.executeCommand({ 602 | command: Commands.Custom, 603 | params, 604 | }) 605 | } 606 | } 607 | export type APIRequest = SendResult> 608 | -------------------------------------------------------------------------------- /src/__mocks__/net.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | const sockets: Array = [] 3 | const onNextSocket: Array<(socket: Socket) => void> = [] 4 | 5 | const orgSetImmediate = setImmediate 6 | 7 | export class Socket extends EventEmitter { 8 | public onWrite?: (buff: Buffer, encoding: string) => void 9 | public onConnect?: (port: number, host: string) => void 10 | public onClose?: () => void 11 | 12 | // private _port: number 13 | // private _host: string 14 | private _connected = false 15 | 16 | public destroyed = false 17 | 18 | constructor() { 19 | super() 20 | 21 | const cb = onNextSocket.shift() 22 | if (cb) { 23 | cb(this) 24 | } 25 | 26 | sockets.push(this) 27 | } 28 | 29 | public static mockSockets(): Socket[] { 30 | return sockets 31 | } 32 | public static openSockets(): Socket[] { 33 | return sockets.filter((s) => !s.destroyed) 34 | } 35 | public static mockOnNextSocket(cb: (s: Socket) => void): void { 36 | onNextSocket.push(cb) 37 | } 38 | public static clearMockOnNextSocket(): void { 39 | onNextSocket.splice(0, 99999) 40 | } 41 | // this.emit('connect') 42 | // this.emit('close') 43 | // this.emit('end') 44 | 45 | public connect(port: number, host = 'localhost', cb?: () => void): void { 46 | // this._port = port 47 | // this._host = host 48 | 49 | if (this.onConnect) this.onConnect(port, host) 50 | orgSetImmediate(() => { 51 | if (cb) { 52 | cb() 53 | } 54 | this.setConnected() 55 | }) 56 | } 57 | public write(buf: Buffer, cb?: () => void): void 58 | public write(buf: Buffer, encoding?: BufferEncoding, cb?: () => void): void 59 | public write(buf: Buffer, encodingOrCb?: BufferEncoding | (() => void), cb?: () => void): void { 60 | const DEFAULT_ENCODING = 'utf-8' 61 | cb = typeof encodingOrCb === 'function' ? encodingOrCb : cb 62 | const encoding = typeof encodingOrCb === 'function' ? DEFAULT_ENCODING : encodingOrCb 63 | if (this.onWrite) { 64 | this.onWrite(buf, encoding ?? DEFAULT_ENCODING) 65 | } 66 | if (cb) cb() 67 | } 68 | public end(): void { 69 | this.setEnd() 70 | this.setClosed() 71 | } 72 | 73 | public mockClose(): void { 74 | this.setClosed() 75 | } 76 | public mockData(data: Buffer): void { 77 | this.emit('data', data) 78 | } 79 | 80 | public setNoDelay(_noDelay?: boolean): void { 81 | // noop 82 | } 83 | 84 | public setEncoding(_encoding?: BufferEncoding): void { 85 | // noop 86 | } 87 | 88 | public destroy(): void { 89 | this.destroyed = true 90 | } 91 | 92 | private setConnected() { 93 | if (this._connected !== true) { 94 | this._connected = true 95 | } 96 | this.emit('connect') 97 | } 98 | private setClosed() { 99 | if (this._connected !== false) { 100 | this._connected = false 101 | } 102 | this.destroyed = true 103 | this.emit('close') 104 | if (this.onClose) this.onClose() 105 | } 106 | private setEnd() { 107 | if (this._connected !== false) { 108 | this._connected = false 109 | } 110 | this.emit('end') 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/__tests__/connection.spec.ts: -------------------------------------------------------------------------------- 1 | import { Version } from '../enums' 2 | import { Connection, SentRequest } from '../connection' 3 | import { serializersV21, serializers } from '../serializers' 4 | import { deserializers } from '../deserializers' 5 | import { Socket as OrgSocket } from 'net' 6 | import { Socket as MockSocket } from '../__mocks__/net' 7 | import { AMCPCommand, Commands } from '../commands' 8 | import { BasicCasparCGAPI, ResponseError } from '../api' 9 | 10 | jest.mock('net') 11 | 12 | const SocketMock = OrgSocket as any as typeof MockSocket 13 | const PARSED_INFO_CHANNEL_720p50 = { 14 | channel: 1, 15 | format: '720p5000', 16 | frameRate: 50, 17 | channelRate: 50, 18 | interlaced: false, 19 | status: 'PLAYING', 20 | } 21 | 22 | describe('connection', () => { 23 | describe('version handing', () => { 24 | function setupConnectionClass(v = Version.v23x) { 25 | const conn = new Connection('127.0.0.1', 5250, false, () => undefined) 26 | conn.version = v 27 | 28 | return conn 29 | } 30 | it('should use 2.1 serializers for 2.1 connection', () => { 31 | const conn = setupConnectionClass(Version.v21x) 32 | 33 | expect(conn['_getVersionedSerializers']()).toBe(serializersV21) 34 | }) 35 | it('should use 2.3 serializers for 2.3 connection', () => { 36 | const conn = setupConnectionClass() 37 | 38 | expect(conn['_getVersionedSerializers']()).toBe(serializers) 39 | }) 40 | it('should use 2.1 deserializers for 2.1 connection', () => { 41 | const conn = setupConnectionClass(Version.v21x) 42 | 43 | expect(conn['_getVersionedDeserializers']()).toBe(deserializers) 44 | }) 45 | it('should use 2.3 deserializers for 2.3 connection', () => { 46 | const conn = setupConnectionClass() 47 | 48 | expect(conn['_getVersionedDeserializers']()).toBe(deserializers) 49 | }) 50 | }) 51 | 52 | describe('receiving', () => { 53 | const onSocketCreate = jest.fn() 54 | const onConnection = jest.fn() 55 | const onSocketClose = jest.fn() 56 | const onSocketWrite = jest.fn() 57 | const onConnectionChanged = jest.fn() 58 | 59 | function setupSocketMock() { 60 | SocketMock.mockOnNextSocket((socket: any) => { 61 | onSocketCreate() 62 | 63 | socket.onConnect = onConnection 64 | socket.onWrite = onSocketWrite 65 | socket.onClose = onSocketClose 66 | }) 67 | } 68 | 69 | function extractReqId(index: number) { 70 | const str = onSocketWrite.mock.calls[index - 1][0] 71 | const match = str.match(/REQ (\w+) /) 72 | if (!match) throw new Error(`Failed to find REQ id in "${str}"`) 73 | return match[1] 74 | } 75 | 76 | beforeEach(() => { 77 | setupSocketMock() 78 | }) 79 | afterEach(() => { 80 | const sockets = SocketMock.openSockets() 81 | // Destroy any lingering sockets, to prevent a failing test from affecting other tests: 82 | sockets.forEach((s) => s.destroy()) 83 | 84 | SocketMock.clearMockOnNextSocket() 85 | onSocketCreate.mockClear() 86 | onConnection.mockClear() 87 | onSocketClose.mockClear() 88 | onSocketWrite.mockClear() 89 | onConnectionChanged.mockClear() 90 | 91 | // Just a check to ensure that the unit tests cleaned up the socket after themselves: 92 | // eslint-disable-next-line jest/no-standalone-expect 93 | expect(sockets).toHaveLength(0) 94 | }) 95 | 96 | async function runWithConnection( 97 | fn: ( 98 | connection: Connection, 99 | socket: MockSocket, 100 | onConnError: jest.Mock, 101 | onConnData: jest.Mock, 102 | getRequestForResponse: jest.Mock 103 | ) => Promise 104 | ) { 105 | const getRequestForResponse = jest.fn() 106 | const conn = new Connection('127.0.0.1', 5250, true, getRequestForResponse) 107 | try { 108 | expect(conn).toBeTruthy() 109 | 110 | const onConnError = jest.fn() 111 | const onConnData = jest.fn() 112 | conn.on('error', onConnError) 113 | conn.on('data', onConnData) 114 | 115 | const sockets = SocketMock.openSockets() 116 | expect(sockets).toHaveLength(1) 117 | 118 | await fn(conn, sockets[0], onConnError, onConnData, getRequestForResponse) 119 | } finally { 120 | // Ensure cleaned up 121 | conn.disconnect() 122 | } 123 | } 124 | 125 | it('receive whole response', async () => { 126 | await runWithConnection(async (conn, socket, onConnError, onConnData, getRequestForResponse) => { 127 | // Dispatch a command 128 | const command: AMCPCommand = { 129 | command: Commands.Info, 130 | params: {}, 131 | } 132 | const sendError = await conn.sendCommand(command) 133 | expect(sendError).toBeFalsy() 134 | expect(onConnError).toHaveBeenCalledTimes(0) 135 | expect(onConnData).toHaveBeenCalledTimes(0) 136 | getRequestForResponse.mockReturnValue({ command }) 137 | // Info was sent 138 | expect(onSocketWrite).toHaveBeenCalledTimes(1) 139 | expect(onSocketWrite).toHaveBeenLastCalledWith('INFO\r\n', 'utf-8') 140 | 141 | // Reply with a single blob 142 | socket.mockData(Buffer.from(`201 INFO OK\r\n1 720p5000 PLAYING\r\n\r\n`)) 143 | 144 | // Wait for deserializer to run 145 | await new Promise(setImmediate) 146 | 147 | expect(onConnError).toHaveBeenCalledTimes(0) 148 | expect(onConnData).toHaveBeenCalledTimes(1) 149 | 150 | // Check result looks good 151 | expect(onConnData).toHaveBeenLastCalledWith( 152 | { 153 | command: 'INFO', 154 | data: [PARSED_INFO_CHANNEL_720p50], 155 | message: 'The command has been executed and data is being returned.', 156 | reqId: undefined, 157 | responseCode: 201, 158 | type: 'OK', 159 | }, 160 | undefined 161 | ) 162 | }) 163 | }) 164 | 165 | it('receive fragmented response', async () => { 166 | await runWithConnection(async (conn, socket, onConnError, onConnData, getRequestForResponse) => { 167 | // Dispatch a command 168 | const command: AMCPCommand = { 169 | command: Commands.Info, 170 | params: {}, 171 | } 172 | const sendError = await conn.sendCommand(command) 173 | expect(sendError).toBeFalsy() 174 | expect(onConnError).toHaveBeenCalledTimes(0) 175 | expect(onConnData).toHaveBeenCalledTimes(0) 176 | getRequestForResponse.mockReturnValue({ command }) 177 | 178 | // Info was sent 179 | expect(onSocketWrite).toHaveBeenCalledTimes(1) 180 | expect(onSocketWrite).toHaveBeenLastCalledWith('INFO\r\n', 'utf-8') 181 | 182 | // Reply with a fragmented message 183 | socket.mockData(Buffer.from(`201 INFO OK\r\n1 720p`)) 184 | socket.mockData(Buffer.from(`5000 PLAYING\r\n\r\n`)) 185 | 186 | // Wait for deserializer to run 187 | await new Promise(setImmediate) 188 | 189 | expect(onConnError).toHaveBeenCalledTimes(0) 190 | expect(onConnData).toHaveBeenCalledTimes(1) 191 | 192 | // Check result looks good 193 | expect(onConnData).toHaveBeenLastCalledWith( 194 | { 195 | command: 'INFO', 196 | data: [PARSED_INFO_CHANNEL_720p50], 197 | message: 'The command has been executed and data is being returned.', 198 | reqId: undefined, 199 | responseCode: 201, 200 | type: 'OK', 201 | }, 202 | undefined 203 | ) 204 | }) 205 | }) 206 | 207 | it('receive fast responses', async () => { 208 | await runWithConnection(async (conn, socket, onConnError, onConnData, getRequestForResponse) => { 209 | // Dispatch a command 210 | const command1: AMCPCommand = { 211 | command: Commands.Info, 212 | params: {}, 213 | } 214 | const reqId1 = 'cmd1' 215 | const sendError = await conn.sendCommand(command1, reqId1) 216 | expect(sendError).toBeFalsy() 217 | const command2: AMCPCommand = { 218 | command: Commands.Play, 219 | params: { 220 | channel: 1, 221 | layer: 10, 222 | }, 223 | } 224 | const reqId2 = 'cmd2' 225 | const sendError2 = await conn.sendCommand(command2, reqId2) 226 | expect(sendError2).toBeFalsy() 227 | expect(onConnError).toHaveBeenCalledTimes(0) 228 | expect(onConnData).toHaveBeenCalledTimes(0) 229 | 230 | // Info was sent 231 | expect(onSocketWrite).toHaveBeenCalledTimes(2) 232 | expect(onSocketWrite).toHaveBeenNthCalledWith(1, 'REQ cmd1 INFO\r\n', 'utf-8') 233 | expect(onSocketWrite).toHaveBeenNthCalledWith(2, 'REQ cmd2 PLAY 1-10\r\n', 'utf-8') 234 | 235 | getRequestForResponse.mockImplementation((arg) => { 236 | if (arg.reqId === reqId1) return { command: command1 } 237 | return { command: command2 } 238 | }) 239 | // Send replies 240 | socket.mockData(Buffer.from(`RES ${reqId1} 201 INFO OK\r\n1 720p5000 PLAYING\r\n\r\n`)) 241 | socket.mockData(Buffer.from(`RES ${reqId2} 202 PLAY OK\r\n`)) 242 | 243 | // Wait for deserializer to run 244 | await new Promise(setImmediate) 245 | 246 | expect(onConnError).toHaveBeenCalledTimes(0) 247 | expect(onConnData).toHaveBeenCalledTimes(2) 248 | 249 | // Check result looks good 250 | expect(onConnData).toHaveBeenNthCalledWith( 251 | 2, 252 | { 253 | command: 'INFO', 254 | data: [PARSED_INFO_CHANNEL_720p50], 255 | message: 'The command has been executed and data is being returned.', 256 | reqId: reqId1, 257 | responseCode: 201, 258 | type: 'OK', 259 | }, 260 | undefined 261 | ) 262 | expect(onConnData).toHaveBeenNthCalledWith( 263 | 1, 264 | { 265 | command: 'PLAY', 266 | data: undefined, 267 | message: 'The command has been executed.', 268 | reqId: reqId2, 269 | responseCode: 202, 270 | type: 'OK', 271 | }, 272 | undefined 273 | ) 274 | }) 275 | }) 276 | 277 | it('receive broken response', async () => { 278 | await runWithConnection(async (conn, socket, onConnError, onConnData, getRequestForResponse) => { 279 | // Dispatch a command 280 | const command1: AMCPCommand = { 281 | command: Commands.InfoChannel, 282 | params: { channel: 1 }, 283 | } 284 | const sendError = await conn.sendCommand(command1, 'cmd1') 285 | expect(sendError).toBeFalsy() 286 | const command2: AMCPCommand = { 287 | command: Commands.Play, 288 | params: { 289 | channel: 1, 290 | layer: 10, 291 | }, 292 | } 293 | const sendError2 = await conn.sendCommand(command2, 'cmd2') 294 | expect(sendError2).toBeFalsy() 295 | expect(onConnError).toHaveBeenCalledTimes(0) 296 | expect(onConnData).toHaveBeenCalledTimes(0) 297 | 298 | // Info was sent 299 | expect(onSocketWrite).toHaveBeenCalledTimes(2) 300 | expect(onSocketWrite).toHaveBeenNthCalledWith(1, 'REQ cmd1 INFO 1\r\n', 'utf-8') 301 | expect(onSocketWrite).toHaveBeenNthCalledWith(2, 'REQ cmd2 PLAY 1-10\r\n', 'utf-8') 302 | 303 | getRequestForResponse.mockReturnValueOnce({ command: command1 }) 304 | // Reply with a blob designed to crash the xml parser 305 | socket.mockData(Buffer.from(`RES cmd1 201 INFO OK\r\n { 352 | const client = new BasicCasparCGAPI({ 353 | host: '127.0.0.1', 354 | port: 5250, 355 | autoConnect: true, 356 | }) 357 | try { 358 | expect(client).toBeTruthy() 359 | 360 | const onConnError = jest.fn() 361 | // const onConnData = jest.fn() 362 | client.on('error', onConnError) 363 | // client.on('data', onConnData) 364 | 365 | const onCommandOk = jest.fn() 366 | const onCommandError = jest.fn() 367 | 368 | const sockets = SocketMock.openSockets() 369 | expect(sockets).toHaveLength(1) 370 | 371 | // Dispatch a command 372 | const sendError = await client.executeCommand({ 373 | command: Commands.InfoChannel, 374 | params: { channel: 1 }, 375 | }) 376 | sendError.request?.then(onCommandOk, onCommandError) 377 | const sendError2 = await client.executeCommand({ 378 | command: Commands.Play, 379 | params: { 380 | channel: 1, 381 | layer: 10, 382 | }, 383 | }) 384 | sendError2.request?.then(onCommandOk, onCommandError) 385 | expect(onConnError).toHaveBeenCalledTimes(0) 386 | expect(onCommandOk).toHaveBeenCalledTimes(0) 387 | expect(onCommandError).toHaveBeenCalledTimes(0) 388 | 389 | // Info was sent 390 | expect(onSocketWrite).toHaveBeenCalledTimes(2) 391 | expect(onSocketWrite).toHaveBeenNthCalledWith(1, expect.stringMatching(/REQ (\w+) INFO 1\r\n/), 'utf-8') 392 | expect(onSocketWrite).toHaveBeenNthCalledWith( 393 | 2, 394 | expect.stringMatching(/REQ (\w+) PLAY 1-10\r\n/), 395 | 'utf-8' 396 | ) 397 | 398 | // Reply with a blob designed to crash the xml parser 399 | const infoReqId = extractReqId(1) 400 | sockets[0].mockData(Buffer.from(`RES ${infoReqId} 201 INFO OK\r\n { 447 | await runWithConnection(async (conn, socket, onConnError, onConnData, getRequestForResponse) => { 448 | // Dispatch a command 449 | const command1: AMCPCommand = { 450 | command: Commands.InfoChannel, 451 | params: { channel: 1 }, 452 | } 453 | const sendError = await conn.sendCommand(command1, 'cmd1') 454 | expect(sendError).toBeFalsy() 455 | expect(onConnError).toHaveBeenCalledTimes(0) 456 | expect(onConnData).toHaveBeenCalledTimes(0) 457 | 458 | // Info was sent 459 | expect(onSocketWrite).toHaveBeenCalledTimes(1) 460 | expect(onSocketWrite).toHaveBeenNthCalledWith(1, 'REQ cmd1 INFO 1\r\n', 'utf-8') 461 | // expect(onSocketWrite).toHaveBeenNthCalledWith(2, 'REQ cmd2 PLAY 1-10\r\n', 'utf-8') 462 | onSocketWrite.mockClear() 463 | 464 | getRequestForResponse.mockReturnValue({ command: command1 }) 465 | // Reply with a part of a fragmented message 466 | socket.mockData(Buffer.from(`RES cmd1 201 INFO OK\r\n { 9 | it('should deserialize CINF', async () => { 10 | // "AMB" MOVIE size datetime frames rate 11 | const input = '"AMB" MOVIE 1234 20230609070542 12 1/25' 12 | 13 | const output = await deserializers[Commands.Cinf]([input]) 14 | 15 | expect(output).toMatchObject({ 16 | clip: 'AMB', 17 | type: 'MOVIE', 18 | size: 1234, 19 | datetime: new Date(2023, 5, 9, 7, 5, 42).getTime(), 20 | frames: 12, 21 | framerate: 25, 22 | }) 23 | }) 24 | 25 | it('should deserialize CLS', async () => { 26 | // "AMB" MOVIE size datetime frames rate 27 | const input = ['"AMB" MOVIE 1234 20230609070542 19876 1/50', '"AMB2" MOVIE 2345 20230609070543 29876 1/60'] 28 | 29 | const output = await deserializers[Commands.Cls](input) 30 | 31 | expect(output).toHaveLength(2) 32 | expect(output[0]).toMatchObject({ 33 | clip: 'AMB', 34 | type: 'MOVIE', 35 | size: 1234, 36 | datetime: new Date(2023, 5, 9, 7, 5, 42).getTime(), 37 | frames: 19876, 38 | framerate: 50, 39 | }) 40 | expect(output[1]).toMatchObject({ 41 | clip: 'AMB2', 42 | type: 'MOVIE', 43 | size: 2345, 44 | datetime: new Date(2023, 5, 9, 7, 5, 43).getTime(), 45 | frames: 29876, 46 | framerate: 60, 47 | }) 48 | }) 49 | 50 | it('should deserialize VERSION for 2.1', async () => { 51 | const input = ['2.1.0.f207a33 STABLE'] 52 | 53 | const output = await deserializers[Commands.Version](input) 54 | 55 | expect(output).toMatchObject({ 56 | version: Version.v21x, 57 | fullVersion: '2.1.0.f207a33 STABLE', 58 | }) 59 | }) 60 | it('should deserialize VERSION for 2.2', async () => { 61 | const input = ['2.2.0.f207a33 STABLE'] 62 | 63 | const output = await deserializers[Commands.Version](input) 64 | 65 | expect(output).toMatchObject({ 66 | version: Version.v22x, 67 | fullVersion: '2.2.0.f207a33 STABLE', 68 | }) 69 | }) 70 | it('should deserialize VERSION for 2.3', async () => { 71 | const input = ['2.3.0.f207a33 STABLE'] 72 | 73 | const output = await deserializers[Commands.Version](input) 74 | 75 | expect(output).toMatchObject({ 76 | version: Version.v23x, 77 | fullVersion: '2.3.0.f207a33 STABLE', 78 | }) 79 | }) 80 | it('should be unsupported VERSION for 2.0', async () => { 81 | const input = ['2.0.7.f207a33 STABLE'] 82 | 83 | const output = await deserializers[Commands.Version](input) 84 | 85 | expect(output).toMatchObject({ 86 | version: Version.Unsupported, 87 | fullVersion: '2.0.7.f207a33 STABLE', 88 | }) 89 | }) 90 | it('should deserialize VERSION for 2.4 into 2.3', async () => { 91 | const input = ['2.4.0.f207a33 STABLE'] 92 | 93 | const output = await deserializers[Commands.Version](input) 94 | 95 | expect(output).toMatchObject({ 96 | version: Version.v23x, 97 | fullVersion: '2.4.0.f207a33 STABLE', 98 | }) 99 | }) 100 | it('should deserialize INFO', async () => { 101 | const input = ['1 720p5000 PLAYING', '2 1080i5000 PLAYING'] 102 | 103 | const output = await deserializers[Commands.Info](input) 104 | 105 | expect(output).toHaveLength(2) 106 | expect(output[0]).toMatchObject( 107 | literal({ 108 | channel: 1, 109 | format: '720p5000', 110 | channelRate: 50, 111 | frameRate: 50, 112 | interlaced: false, 113 | status: 'PLAYING', 114 | }) 115 | ) 116 | expect(output[1]).toMatchObject( 117 | literal({ 118 | channel: 2, 119 | format: '1080i5000', 120 | channelRate: 50, 121 | frameRate: 50, 122 | interlaced: true, 123 | status: 'PLAYING', 124 | }) 125 | ) 126 | }) 127 | it('should deserialize INFO Channel (empty channel)', async () => { 128 | const input = [ 129 | ` 130 | 131 | 50 132 | 1 133 | 134 | 138 | 139 | `, 140 | ] 141 | 142 | const output = await deserializers[Commands.InfoChannel](input) 143 | 144 | expect(output).toMatchObject( 145 | literal({ 146 | channel: { 147 | framerate: 50, 148 | mixer: { 149 | audio: { 150 | volumes: [0, 0], 151 | }, 152 | }, 153 | 154 | layers: [], 155 | }, 156 | }) 157 | ) 158 | }) 159 | it('should deserialize INFO Channel', async () => { 160 | const input = [ 161 | ` 162 | 163 | 50 164 | 1 165 | 166 | 176 | 177 | 178 | 179 | 180 | 181 | empty 182 | 183 | 184 | 185 | 0 186 | 596.48000000000002 187 | AMB.mp4 188 | media/AMB.mp4 189 | 190 | 191 | 192 | 0 193 | 0 194 | 195 | 196 | 24 197 | 1 198 | 199 | 200 | 201 | 202 | 203 | 204 | false 205 | true 206 | ffmpeg 207 | 208 | 209 | 210 | 211 | `, 212 | ] 213 | 214 | const output = await deserializers[Commands.InfoChannel](input) 215 | 216 | expect(output).toMatchObject( 217 | literal({ 218 | channel: { 219 | framerate: 50, 220 | mixer: { 221 | audio: { 222 | volumes: [0, 0, 0, 0, 0, 0, 0, 0], 223 | }, 224 | }, 225 | 226 | layers: [ 227 | { 228 | layer: 10, 229 | background: expect.anything(), 230 | foreground: expect.anything(), 231 | }, 232 | ], 233 | }, 234 | }) 235 | ) 236 | }) 237 | describe('INFO Config', () => { 238 | it('should deserialize correctly formed config', async () => { 239 | const input = [ 240 | ` 241 | 242 | 243 | debug 244 | 245 | media/ 246 | log/ 247 | data/ 248 | templates/ 249 | 250 | secret 251 | 252 | 253 | 1080p5000 254 | 255 | 256 | 1 257 | default 258 | fill 259 | true 260 | 261 | 262 | 263 | 264 | 1080p2500 265 | 266 | 267 | 2 268 | normal 269 | 270 | 271 | 272 | AMB LOOP 273 | DECKLINK DEVICE 2 274 | 275 | 276 | 277 | 278 | 279 | 5250 280 | AMCP 281 | 282 | 283 | 284 | 285 | localhost 286 | 8000 287 | 288 | 289 | 290 | 291 | interlaced 292 | 4 293 | 294 | 295 | 296 | 0 297 | true 298 | 299 | 300 | false 301 | 302 | 303 | 304 | 1024x768p60 305 | 1024 306 | 768 307 | 60000 308 | 1000 309 | 800 310 | 311 | 312 | `, 313 | ] 314 | 315 | const output = await deserializers[Commands.InfoConfig](input) 316 | 317 | expect(output).toMatchObject( 318 | literal({ 319 | logLevel: LogLevel.Debug, 320 | paths: { 321 | media: 'media/', 322 | data: 'data/', 323 | logs: 'log/', 324 | templates: 'templates/', 325 | }, 326 | channels: [ 327 | { 328 | videoMode: '1080p5000', 329 | consumers: [ 330 | { 331 | type: ConsumerType.SCREEN, 332 | device: 1, 333 | aspectRatio: 'default', 334 | windowed: true, 335 | }, 336 | ], 337 | producers: [], 338 | }, 339 | { 340 | videoMode: '1080p2500', 341 | consumers: [ 342 | { 343 | type: ConsumerType.DECKLINK, 344 | device: 2, 345 | latency: 'normal', 346 | }, 347 | ], 348 | producers: [ 349 | { 350 | id: 0, 351 | producer: 'AMB LOOP', 352 | }, 353 | { 354 | id: 10, 355 | producer: 'DECKLINK DEVICE 2', 356 | }, 357 | ], 358 | }, 359 | ], 360 | controllers: { 361 | tcp: { 362 | port: 5250, 363 | protocol: 'AMCP', 364 | }, 365 | }, 366 | amcp: { 367 | mediaServer: { 368 | host: 'localhost', 369 | port: 8000, 370 | }, 371 | }, 372 | ffmpeg: { 373 | producer: { 374 | autoDeinterlace: 'interlaced', 375 | threads: 4, 376 | }, 377 | }, 378 | html: { 379 | enableGpu: true, 380 | remoteDebuggingPort: 0, 381 | }, 382 | videoModes: [ 383 | { cadence: 800, duration: 1000, height: 768, id: '1024x768p60', timeScale: 60000, width: 1024 }, 384 | ], 385 | ndi: { 386 | autoLoad: false, 387 | }, 388 | raw: {}, 389 | rawXml: input[0], 390 | }) 391 | ) 392 | }) 393 | it('should not crash on non-xml', async () => { 394 | const input = [`NOT XML`] 395 | 396 | const output = await deserializers[Commands.InfoConfig](input) 397 | 398 | expect(output).toEqual( 399 | literal({ 400 | rawXml: input[0], 401 | }) 402 | ) 403 | }) 404 | it('should not crash on non-config', async () => { 405 | const input = [ 406 | ` 407 | 408 | `, 409 | ] 410 | 411 | const output = await deserializers[Commands.InfoConfig](input) 412 | 413 | expect(output).toEqual( 414 | literal({ 415 | raw: { 416 | 'something-else': '', 417 | }, 418 | rawXml: input[0], 419 | }) 420 | ) 421 | }) 422 | }) 423 | }) 424 | -------------------------------------------------------------------------------- /src/__tests__/serializers.spec.ts: -------------------------------------------------------------------------------- 1 | import { serializers, serializersV21 } from '../serializers' 2 | import { 3 | CgAddCommand, 4 | Commands, 5 | CustomCommand, 6 | InfoChannelCommand, 7 | InfoCommand, 8 | InfoLayerCommand, 9 | LoadbgDecklinkCommand, 10 | MixerFillCommand, 11 | PlayCommand, 12 | PlayHtmlCommand, 13 | } from '../commands' 14 | import { TransitionTween, TransitionType } from '../enums' 15 | 16 | describe('serializers', () => { 17 | it('should have serializers for every command', () => { 18 | for (const c of Object.values(Commands)) { 19 | expect(serializers[c]).toBeDefined() 20 | } 21 | }) 22 | 23 | it('should have command for every serializers', () => { 24 | for (const c of Object.keys(serializers)) { 25 | expect(Object.values(Commands).includes(c as Commands)).toBeTruthy() 26 | } 27 | }) 28 | 29 | it('should serialize a play command', () => { 30 | const command: PlayCommand = { 31 | command: Commands.Play, 32 | params: { 33 | channel: 1, 34 | layer: 10, 35 | clip: 'AMB', 36 | }, 37 | } 38 | 39 | const serialized = serializers[Commands.Play].map((fn) => fn(command.command, command.params)) 40 | 41 | expect(serialized).toHaveLength(serializers[Commands.Play].length) 42 | 43 | const result = serialized.filter((l) => l !== '').join(' ') 44 | 45 | expect(result).toBe('PLAY 1-10 "AMB"') 46 | }) 47 | 48 | it('should serialize a play command with filters', () => { 49 | const command: PlayCommand = { 50 | command: Commands.Play, 51 | params: { 52 | channel: 1, 53 | layer: 10, 54 | clip: 'AMB', 55 | vFilter: 'A B C', 56 | aFilter: 'D E F', 57 | }, 58 | } 59 | 60 | const serialized = serializers[Commands.Play].map((fn) => fn(command.command, command.params)) 61 | 62 | expect(serialized).toHaveLength(serializers[Commands.Play].length) 63 | 64 | const result = serialized.filter((l) => l !== '').join(' ') 65 | 66 | expect(result).toBe('PLAY 1-10 "AMB" VF "A B C" AF "D E F"') 67 | }) 68 | 69 | it('should serialize a play command with transition', () => { 70 | const command: PlayCommand = { 71 | command: Commands.Play, 72 | params: { 73 | channel: 1, 74 | layer: 10, 75 | clip: 'AMB', 76 | transition: { 77 | transitionType: TransitionType.Mix, 78 | duration: 10, 79 | }, 80 | }, 81 | } 82 | 83 | const serialized = serializers[Commands.Play].map((fn) => fn(command.command, command.params)) 84 | 85 | expect(serialized).toHaveLength(serializers[Commands.Play].length) 86 | 87 | const result = serialized.filter((l) => l !== '').join(' ') 88 | 89 | expect(result).toBe('PLAY 1-10 "AMB" MIX 10') 90 | }) 91 | 92 | it('should serialize a play [html] command', () => { 93 | const command: PlayHtmlCommand = { 94 | command: Commands.PlayHtml, 95 | params: { 96 | channel: 1, 97 | layer: 10, 98 | url: 'http://example.com', 99 | }, 100 | } 101 | 102 | const serialized = serializers[Commands.PlayHtml].map((fn) => fn(command.command, command.params)) 103 | 104 | expect(serialized).toHaveLength(serializers[Commands.Play].length) 105 | 106 | const result = serialized.filter((l) => l !== '').join(' ') 107 | 108 | expect(result).toBe('PLAY 1-10 [html] http://example.com') 109 | }) 110 | 111 | it('should serialize a laodbg decklink command', () => { 112 | const command: LoadbgDecklinkCommand = { 113 | command: Commands.LoadbgDecklink, 114 | params: { 115 | channel: 1, 116 | layer: 10, 117 | device: 23, 118 | }, 119 | } 120 | 121 | const serialized = serializers[Commands.LoadbgDecklink].map((fn) => fn(command.command, command.params)) 122 | 123 | expect(serialized).toHaveLength(serializers[Commands.Play].length) 124 | 125 | const result = serialized.filter((l) => l !== '').join(' ') 126 | 127 | expect(result).toBe('LOADBG 1-10 DECKLINK 23') 128 | }) 129 | 130 | it('v2.1 should serialize a play [html] command', () => { 131 | const command: PlayHtmlCommand = { 132 | command: Commands.PlayHtml, 133 | params: { 134 | channel: 1, 135 | layer: 10, 136 | url: 'http://example.com', 137 | }, 138 | } 139 | 140 | const serialized = serializersV21[Commands.PlayHtml].map((fn) => fn(command.command, command.params)) 141 | 142 | expect(serialized).toHaveLength(serializers[Commands.Play].length) 143 | 144 | const result = serialized.filter((l) => l !== '').join(' ') 145 | 146 | expect(result).toBe('PLAY 1-10 [html] http://example.com') 147 | }) 148 | 149 | it('should serialize a cgAdd command with data', () => { 150 | const command: CgAddCommand = { 151 | command: Commands.CgAdd, 152 | params: { 153 | channel: 1, 154 | layer: 10, 155 | cgLayer: 1, 156 | playOnLoad: true, 157 | template: 'myFolder/myTemplate', 158 | data: { 159 | label: `These are difficult: "'&$\\/`, 160 | }, 161 | }, 162 | } 163 | 164 | const serialized = serializers[Commands.CgAdd].map((fn) => fn(command.command, command.params)) 165 | 166 | expect(serialized).toHaveLength(serializers[Commands.CgAdd].length) 167 | 168 | const result = serialized.filter((l) => l !== '').join(' ') 169 | 170 | expect(result).toBe( 171 | `CG 1-10 ADD 1 "myFolder/myTemplate" 1 "{\\"label\\":\\"These are difficult: \\\\\\"'&\\$\\\\\\\\/\\"}"` 172 | ) 173 | }) 174 | it('should serialize a INFO command', () => { 175 | const command: InfoCommand = { 176 | command: Commands.Info, 177 | params: {}, 178 | } 179 | const serialized = serializers[Commands.Info].map((fn) => fn(command.command, command.params)) 180 | expect(serialized).toHaveLength(serializers[Commands.Info].length) 181 | const result = serialized.filter((l) => l !== '').join(' ') 182 | 183 | expect(result).toBe(`INFO`) 184 | }) 185 | it('should serialize a INFO Channel command', () => { 186 | const command: InfoChannelCommand = { 187 | command: Commands.InfoChannel, 188 | params: { 189 | channel: 1, 190 | }, 191 | } 192 | const serialized = serializers[Commands.InfoChannel].map((fn) => fn(command.command, command.params)) 193 | expect(serialized).toHaveLength(serializers[Commands.InfoChannel].length) 194 | const result = serialized.filter((l) => l !== '').join(' ') 195 | 196 | expect(result).toBe(`INFO 1`) 197 | }) 198 | it('should serialize a INFO Channel Layer command', () => { 199 | const command: InfoLayerCommand = { 200 | command: Commands.InfoLayer, 201 | params: { 202 | channel: 1, 203 | layer: 10, 204 | }, 205 | } 206 | const serialized = serializers[Commands.InfoLayer].map((fn) => fn(command.command, command.params)) 207 | expect(serialized).toHaveLength(serializers[Commands.InfoLayer].length) 208 | const result = serialized.filter((l) => l !== '').join(' ') 209 | 210 | expect(result).toBe(`INFO 1-10`) 211 | }) 212 | 213 | it('should serialize a MIXER FILL with tweening properties', () => { 214 | const command: MixerFillCommand = { 215 | command: Commands.MixerFill, 216 | params: { 217 | channel: 1, 218 | layer: 10, 219 | x: 0.1, 220 | y: 0.2, 221 | xScale: 0.7, 222 | yScale: 0.8, 223 | 224 | duration: 20, 225 | tween: TransitionTween.IN_CIRC, 226 | }, 227 | } 228 | const serialized = serializers[Commands.MixerFill].map((fn) => fn(command.command, command.params)) 229 | expect(serialized).toHaveLength(serializers[Commands.MixerFill].length) 230 | const result = serialized.filter((l) => l !== '').join(' ') 231 | 232 | expect(result).toBe(`MIXER 1-10 FILL 0.1 0.2 0.7 0.8 20 EASEINCIRC`) 233 | }) 234 | 235 | it('should serialize a MIXER FILL with duration', () => { 236 | const command: MixerFillCommand = { 237 | command: Commands.MixerFill, 238 | params: { 239 | channel: 1, 240 | layer: 10, 241 | x: 0.1, 242 | y: 0.2, 243 | xScale: 0.7, 244 | yScale: 0.8, 245 | 246 | duration: 20, 247 | }, 248 | } 249 | const serialized = serializers[Commands.MixerFill].map((fn) => fn(command.command, command.params)) 250 | expect(serialized).toHaveLength(serializers[Commands.MixerFill].length) 251 | const result = serialized.filter((l) => l !== '').join(' ') 252 | 253 | expect(result).toBe(`MIXER 1-10 FILL 0.1 0.2 0.7 0.8 20`) 254 | }) 255 | 256 | it('should serialize a custom', () => { 257 | const command: CustomCommand = { 258 | command: Commands.Custom, 259 | params: { 260 | command: 'INFO 1', 261 | }, 262 | } 263 | 264 | const serialized = serializers[Commands.Custom].map((fn) => fn(command.command, command.params)) 265 | 266 | expect(serialized).toHaveLength(serializers[Commands.Custom].length) 267 | 268 | const result = serialized.filter((l) => l !== '').join(' ') 269 | 270 | expect(result).toBe('INFO 1') 271 | }) 272 | 273 | it('should serialize a cgAdd command with minimum params', () => { 274 | const command: CgAddCommand = { 275 | command: Commands.CgAdd, 276 | params: { 277 | channel: 1, 278 | layer: 10, 279 | template: 'myFolder/myTemplate', 280 | playOnLoad: false, 281 | }, 282 | } 283 | 284 | const serialized = serializers[Commands.CgAdd].map((fn) => fn(command.command, command.params)) 285 | 286 | expect(serialized).toHaveLength(serializers[Commands.CgAdd].length) 287 | 288 | const result = serialized.filter((l) => l !== '').join(' ') 289 | 290 | expect(result).toBe(`CG 1-10 ADD 1 "myFolder/myTemplate" 0`) 291 | }) 292 | 293 | it('should serialize a cgAdd command with all params defined', () => { 294 | const command: CgAddCommand = { 295 | command: Commands.CgAdd, 296 | params: { 297 | channel: 1, 298 | layer: 10, 299 | template: 'myFolder/myTemplate', 300 | playOnLoad: true, 301 | cgLayer: 2, 302 | data: { 303 | hello: 'world', 304 | }, 305 | }, 306 | } 307 | 308 | const serialized = serializers[Commands.CgAdd].map((fn) => fn(command.command, command.params)) 309 | 310 | expect(serialized).toHaveLength(serializers[Commands.CgAdd].length) 311 | 312 | const result = serialized.filter((l) => l !== '').join(' ') 313 | 314 | expect(result).toBe(`CG 1-10 ADD 2 "myFolder/myTemplate" 1 "{\\"hello\\":\\"world\\"}"`) 315 | }) 316 | 317 | it('should serialize a custom command with custom parameters', () => { 318 | const command: CustomCommand = { 319 | command: Commands.Custom, 320 | params: { 321 | command: 'MYCOMMAND', 322 | customParams: { 323 | channel: 1, 324 | layer: 10, 325 | name: 'test', 326 | enabled: true, 327 | loop: null, 328 | optional: null, 329 | }, 330 | }, 331 | } 332 | 333 | const serialized = serializers[Commands.Custom].map((fn) => fn(command.command, command.params)) 334 | const result = serialized.filter((l) => l !== '').join(' ') 335 | 336 | expect(result).toBe('MYCOMMAND channel 1 layer 10 name "test" enabled true loop optional') 337 | }) 338 | 339 | it('should serialize a regular command with custom parameters', () => { 340 | const command: PlayCommand = { 341 | command: Commands.Play, 342 | params: { 343 | channel: 1, 344 | layer: 10, 345 | clip: 'test', 346 | customParams: { 347 | loop: null, 348 | seek: 1000, 349 | length: 5000, 350 | note: 'test clip', 351 | optional: null, 352 | }, 353 | }, 354 | } 355 | 356 | const serialized = serializers[Commands.Play].map((fn) => fn(command.command, command.params)) 357 | const result = serialized.filter((l) => l !== '').join(' ') 358 | 359 | expect(result).toBe('PLAY 1-10 "test" loop seek 1000 length 5000 note "test clip" optional') 360 | }) 361 | }) 362 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'eventemitter3' 2 | import { AMCPCommand, CReturnType, Commands } from './commands' 3 | import { Connection, ResponseTypes } from './connection' 4 | 5 | export interface Options { 6 | /** Host name of the machine to connect to. Defaults to 127.0.0.1 */ 7 | host?: string 8 | /** Port number to connect to. Defaults to 5250 */ 9 | port?: number 10 | /** Minimum amount of time before a request is considered to be timed out */ 11 | timeoutTime?: number 12 | /** Immediately connects after instantiating the class, defaults to false */ 13 | autoConnect?: boolean 14 | } 15 | 16 | export type SendResult = 17 | | { 18 | error: Error 19 | request: undefined 20 | } 21 | | { 22 | error: undefined 23 | request: Promise> 24 | } 25 | 26 | interface InternalRequest { 27 | requestId?: string 28 | command: AMCPCommand 29 | 30 | resolve: (response: Response) => void 31 | reject: (error: Error) => void 32 | 33 | processed: boolean 34 | processedTime?: number 35 | sentResolve: (sent: SendResult) => void 36 | sentTime?: number 37 | } 38 | 39 | export interface Response { 40 | reqId: string | undefined 41 | command: Commands 42 | responseCode: number 43 | data: ReturnData 44 | 45 | type: ResponseTypes 46 | message: string 47 | } 48 | 49 | export type ConnectionEvents = { 50 | connect: [] 51 | disconnect: [] 52 | error: [error: Error] 53 | } 54 | 55 | export class ResponseError extends Error { 56 | constructor(public readonly deserializeError: Error, public readonly response: Response) { 57 | super('Failed to deserialize response') 58 | } 59 | } 60 | 61 | export class BasicCasparCGAPI extends EventEmitter { 62 | private _connection: Connection 63 | private _host: string 64 | private _port: number 65 | 66 | private _requestQueue: Array = [] 67 | private _timeoutTimer: NodeJS.Timer 68 | private _timeoutTime: number 69 | 70 | constructor(options?: Options) { 71 | super() 72 | 73 | this._host = options?.host || '127.0.0.1' 74 | this._port = options?.port || 5250 75 | 76 | this._connection = new Connection( 77 | this._host, 78 | this._port, 79 | !(options?.autoConnect === false), 80 | (response: Response) => { 81 | // Connection asks: "what request does this response belong to?" 82 | const request = this.findRequestFromResponse(response) 83 | if (request) return { command: request.command } 84 | else return undefined 85 | } 86 | ) 87 | 88 | this._connection.on('connect', () => { 89 | this.executeCommand({ command: Commands.Version, params: {} }) 90 | .then(async ({ request, error }) => { 91 | if (error) { 92 | throw error 93 | } 94 | const result = await request 95 | const version = result.data 96 | 97 | this._connection.version = version.version 98 | }) 99 | .catch((e) => this.emit('error', e)) 100 | .finally(() => this.emit('connect')) 101 | this._processQueue().catch((e) => this.emit('error', e)) 102 | }) 103 | this._connection.on('disconnect', () => this.emit('disconnect')) 104 | this._connection.on('error', (e) => this.emit('error', e)) 105 | 106 | this._connection.on('data', (response, error) => { 107 | const request = this.findRequestFromResponse(response) 108 | 109 | if (request) { 110 | if (error) { 111 | request.reject(new ResponseError(error, response)) 112 | } else { 113 | request.resolve(response) 114 | } 115 | this._requestQueue = this._requestQueue.filter((req) => req.requestId !== response.reqId) 116 | } 117 | 118 | this._processQueue().catch((e) => this.emit('error', e)) 119 | }) 120 | 121 | this._timeoutTime = options?.timeoutTime || 5000 122 | this._timeoutTimer = setInterval(() => this._checkTimeouts(), this._timeoutTime) 123 | } 124 | 125 | get host(): string { 126 | return this._host 127 | } 128 | 129 | set host(host: string) { 130 | this._host = host 131 | this._connection.changeConnection(this._host, this._port) 132 | } 133 | 134 | get port(): number { 135 | return this._port 136 | } 137 | 138 | set port(port: number) { 139 | this._port = port 140 | this._connection.changeConnection(this._host, this._port) 141 | } 142 | 143 | get connected(): boolean { 144 | return this._connection.connected 145 | } 146 | 147 | connect(host?: string, port?: number): void { 148 | this._host = host ? host : this._host 149 | this._port = port ? port : this._port 150 | this._connection.changeConnection(this._host, this._port) 151 | } 152 | 153 | disconnect(): void { 154 | this._connection.disconnect() 155 | this._requestQueue.forEach((r) => { 156 | if (r.processed) { 157 | r.reject(new Error('Disconnected before response was received')) 158 | } else { 159 | r.sentResolve({ request: undefined, error: new Error('Disconnected before response was received') }) 160 | } 161 | }) 162 | } 163 | 164 | /** Stops internal timers so that the class is ready for garbage disposal */ 165 | discard(): void { 166 | this._connection.disconnect() 167 | clearInterval(this._timeoutTimer) 168 | } 169 | 170 | /** 171 | * Sends a command to CasparCG 172 | * @return { error: Error } if there was an error when sending the command (such as being disconnected) 173 | * @return { request: Promise } a Promise that resolves when CasparCG replies after a command has been sent. 174 | * If this throws, there's something seriously wrong :) 175 | */ 176 | async executeCommand( 177 | command: Command 178 | ): Promise>> { 179 | const reqId = Math.random().toString(35).slice(2, 7) 180 | 181 | let outerResolve: InternalRequest['sentResolve'] = () => null 182 | const s = new Promise>((resolve) => { 183 | outerResolve = resolve 184 | }) 185 | 186 | const internalRequest: InternalRequest = { 187 | requestId: reqId, 188 | command, 189 | 190 | // stubs to be replaced 191 | resolve: () => null, 192 | reject: () => null, 193 | 194 | processed: false, 195 | sentResolve: outerResolve, 196 | } 197 | 198 | this._requestQueue.push(internalRequest) 199 | this._processQueue().catch((e) => this.emit('error', e)) 200 | 201 | return s 202 | } 203 | 204 | private async _processQueue(): Promise { 205 | if (this._requestQueue.length < 1) return 206 | 207 | this._requestQueue.forEach((r) => { 208 | if (!r.processed) { 209 | this._connection 210 | .sendCommand(r.command, r.requestId) 211 | .then((sendError) => { 212 | if (sendError) { 213 | this._requestQueue = this._requestQueue.filter((req) => req !== r) 214 | r.sentResolve({ error: sendError, request: undefined }) 215 | } else { 216 | const request = new Promise>((resolve, reject) => { 217 | r.resolve = resolve 218 | r.reject = reject 219 | }) 220 | r.sentTime = Date.now() 221 | r.sentResolve({ error: undefined, request }) 222 | } 223 | }) 224 | .catch((e: string) => { 225 | r.sentResolve({ error: Error(e), request: undefined }) 226 | r.reject(new Error(e)) 227 | this._requestQueue = this._requestQueue.filter((req) => req !== r) 228 | }) 229 | 230 | r.processed = true 231 | r.processedTime = Date.now() 232 | } 233 | }) 234 | } 235 | 236 | private _checkTimeouts() { 237 | const deadRequests = this._requestQueue.filter( 238 | (req) => req.processed && req.processedTime && req.processedTime < Date.now() - this._timeoutTime 239 | ) 240 | deadRequests.forEach((req) => { 241 | req.reject(new Error('Time out')) 242 | req.sentResolve({ request: undefined, error: new Error('Time out') }) 243 | }) 244 | this._requestQueue = this._requestQueue.filter((req) => !deadRequests.includes(req)) 245 | } 246 | 247 | private findRequestFromResponse(response: Response): InternalRequest | undefined { 248 | return this._requestQueue.find((req) => req.requestId === response.reqId) 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LoadbgParameters, 3 | LoadParameters, 4 | PlayParameters, 5 | PauseParameters, 6 | ResumeParameters, 7 | StopParameters, 8 | ClearParameters, 9 | CallParameters, 10 | SwapParameters, 11 | RemoveParameters, 12 | PrintParameters, 13 | LogLevelParameters, 14 | LogCategoryParameters, 15 | SetParameters, 16 | LockParameters, 17 | DataStoreParameters, 18 | DataRetrieveParameters, 19 | DataListParameters, 20 | DataRemoveParameters, 21 | CgAddParameters, 22 | CgPlayParameters, 23 | CgStopParameters, 24 | CgNextParameters, 25 | CgRemoveParameters, 26 | CgClearParameters, 27 | CgUpdateParameters, 28 | CgInvokeParameters, 29 | CgInfoParameters, 30 | MixerKeyerParameters, 31 | MixerChromaParameters, 32 | MixerBlendParameters, 33 | MixerInvertParameters, 34 | MixerOpacityParameters, 35 | MixerBrightnessParameters, 36 | MixerSaturationParameters, 37 | MixerContrastParameters, 38 | MixerLevelsParameters, 39 | MixerFillParameters, 40 | MixerClipParameters, 41 | MixerAnchorParameters, 42 | MixerCropParameters, 43 | MixerRotationParameters, 44 | MixerPerspectiveParameters, 45 | MixerMipmapParameters, 46 | MixerVolumeParameters, 47 | MixerMastervolumeParameters, 48 | MixerStraightAlphaOutputParameters, 49 | MixerGridParameters, 50 | MixerCommitParameters, 51 | MixerClearParameters, 52 | ThumbnailListParameters, 53 | ThumbnailRetrieveParameters, 54 | ThumbnailGenerateParameters, 55 | CinfParameters, 56 | ClsParameters, 57 | FlsParameters, 58 | TlsParameters, 59 | VersionParameters, 60 | InfoParameters, 61 | InfoTemplateParameters, 62 | InfoConfigParameters, 63 | InfoPathsParameters, 64 | InfoSystemParameters, 65 | InfoServerParameters, 66 | InfoQueuesParameters, 67 | InfoThreadsParameters, 68 | InfoDelayParameters, 69 | DiagParameters, 70 | GlInfoParameters, 71 | GlGcParameters, 72 | ByeParameters, 73 | KillParameters, 74 | RestartParameters, 75 | AddParameters, 76 | ThumbnailGenerateAllParameters, 77 | ChannelGridParameters, 78 | PlayDecklinkParameters, 79 | LoadbgDecklinkParameters, 80 | LoadbgHtmlParameters, 81 | LoadbgRouteParameters, 82 | PlayHtmlParameters, 83 | PlayRouteParameters, 84 | ClipInfo, 85 | VersionInfo, 86 | InfoChannelParameters, 87 | InfoLayerParameters, 88 | InfoEntry, 89 | InfoChannelEntry, 90 | InfoLayerEntry, 91 | InfoConfig, 92 | BeginParameters, 93 | CommitParameters, 94 | PingParameters, 95 | DiscardParameters, 96 | CustomCommandParameters, 97 | } from './parameters' 98 | 99 | export enum Commands { 100 | Loadbg = 'LOADBG', 101 | LoadbgDecklink = 'LOADBG DECKLINK', 102 | LoadbgRoute = 'LOADBG route://', 103 | LoadbgHtml = 'LOADBG [html]', 104 | Load = 'LOAD', 105 | Play = 'PLAY', 106 | PlayDecklink = 'PLAY DECKLINK', 107 | PlayRoute = 'PLAY route://', 108 | PlayHtml = 'PLAY [html]', 109 | Pause = 'PAUSE', 110 | Resume = 'RESUME', 111 | Stop = 'STOP', 112 | Clear = 'CLEAR', 113 | Call = 'CALL', 114 | Swap = 'SWAP', 115 | Add = 'ADD', 116 | Remove = 'REMOVE', 117 | Print = 'PRINT', 118 | LogLevel = 'LOG LEVEL', 119 | LogCategory = 'LOG CATEGORY', 120 | Set = 'SET', 121 | Lock = 'LOCK', 122 | DataStore = 'DATA STORE', 123 | DataRetrieve = 'DATA RETRIEVE', 124 | DataList = 'DATA LIST', 125 | DataRemove = 'DATA REMOVE', 126 | CgAdd = 'CG ADD', 127 | CgPlay = 'CG PLAY', 128 | CgStop = 'CG STOP', 129 | CgNext = 'CG NEXT', 130 | CgRemove = 'CG REMOVE', 131 | CgClear = 'CG CLEAR', 132 | CgUpdate = 'CG UPDATE', 133 | CgInvoke = 'CG INVOKE', 134 | CgInfo = 'CG INFO', 135 | MixerKeyer = 'MIXER KEYER', 136 | MixerChroma = 'MIXER CHROMA', 137 | MixerBlend = 'MIXER BLEND', 138 | MixerInvert = 'MIXER INVERT', 139 | MixerOpacity = 'MIXER OPACITY', 140 | MixerBrightness = 'MIXER BRIGHTNESS', 141 | MixerSaturation = 'MIXER SATURATION', 142 | MixerContrast = 'MIXER CONTRAST', 143 | MixerLevels = 'MIXER LEVELS', 144 | MixerFill = 'MIXER FILL', 145 | MixerClip = 'MIXER CLIP', 146 | MixerAnchor = 'MIXER ANCHOR', 147 | MixerCrop = 'MIXER CROP', 148 | MixerRotation = 'MIXER ROTATION', 149 | MixerPerspective = 'MIXER PERSPECTIVE', 150 | MixerMipmap = 'MIXER MIPMAP', 151 | MixerVolume = 'MIXER VOLUME', 152 | MixerMastervolume = 'MIXER MASTERVOLUME', 153 | MixerStraightAlphaOutput = 'MIXER STRAIGHT_ALPHA_OUTPUT', 154 | MixerGrid = 'MIXER GRID', 155 | MixerCommit = 'MIXER COMMIT', 156 | MixerClear = 'MIXER CLEAR', 157 | ChannelGrid = 'CHANNEL_GRID', 158 | ThumbnailList = 'THUMBNAIL LIST', 159 | ThumbnailRetrieve = 'THUMBNAIL RETRIEVE', 160 | ThumbnailGenerate = 'THUMBNAIL GENERATE', 161 | ThumbnailGenerateAll = 'THUMBNAIL GENERATE_ALL', 162 | Cinf = 'CINF', 163 | Cls = 'CLS', 164 | Fls = 'FLS', 165 | Tls = 'TLS', 166 | Version = 'VERSION', 167 | Info = 'INFO', 168 | InfoChannel = 'INFO CHANNEL', 169 | InfoLayer = 'INFO LAYER', 170 | InfoTemplate = 'INFO TEMPLATE', 171 | InfoConfig = 'INFO CONFIG', 172 | InfoPaths = 'INFO PATHS', 173 | InfoSystem = 'INFO SYSTEM', 174 | InfoServer = 'INFO SERVER', 175 | InfoQueues = 'INFO QUEUES', 176 | InfoThreads = 'INFO THREADS', 177 | InfoDelay = 'INFO DELAY', 178 | Diag = 'DIAG', 179 | GlInfo = 'GL INFO', 180 | GlGc = 'GL GC', 181 | Bye = 'BYE', 182 | Kill = 'KILL', 183 | Restart = 'RESTART', 184 | Ping = 'PING', 185 | Begin = 'BEGIN', 186 | Commit = 'COMMIT', 187 | Discard = 'DISCARD', 188 | 189 | Custom = 'CUSTOM', 190 | } 191 | 192 | export interface Command { 193 | readonly command: Cmd 194 | params: Params 195 | } 196 | /** 197 | * This interface contains both the command as well as the typings for the return object 198 | */ 199 | export interface TypedResponseCommand { 200 | command: Command 201 | returnType: ReturnType 202 | } 203 | 204 | export type CReturnType = AllTypedCommands[C]['returnType'] 205 | 206 | export interface AllTypedCommands { 207 | [Commands.Loadbg]: TypedResponseCommand 208 | [Commands.LoadbgDecklink]: TypedResponseCommand 209 | [Commands.LoadbgHtml]: TypedResponseCommand 210 | [Commands.LoadbgRoute]: TypedResponseCommand 211 | [Commands.Load]: TypedResponseCommand 212 | [Commands.Play]: TypedResponseCommand 213 | [Commands.PlayDecklink]: TypedResponseCommand 214 | [Commands.PlayHtml]: TypedResponseCommand 215 | [Commands.PlayRoute]: TypedResponseCommand 216 | [Commands.Pause]: TypedResponseCommand 217 | [Commands.Resume]: TypedResponseCommand 218 | [Commands.Stop]: TypedResponseCommand 219 | [Commands.Clear]: TypedResponseCommand 220 | [Commands.Call]: TypedResponseCommand 221 | [Commands.Swap]: TypedResponseCommand 222 | [Commands.Add]: TypedResponseCommand 223 | [Commands.Remove]: TypedResponseCommand 224 | [Commands.Print]: TypedResponseCommand 225 | [Commands.LogLevel]: TypedResponseCommand 226 | [Commands.LogCategory]: TypedResponseCommand 227 | [Commands.Set]: TypedResponseCommand 228 | [Commands.Lock]: TypedResponseCommand 229 | [Commands.DataStore]: TypedResponseCommand 230 | [Commands.DataRetrieve]: TypedResponseCommand 231 | [Commands.DataList]: TypedResponseCommand 232 | [Commands.DataRemove]: TypedResponseCommand 233 | [Commands.CgAdd]: TypedResponseCommand 234 | [Commands.CgPlay]: TypedResponseCommand 235 | [Commands.CgStop]: TypedResponseCommand 236 | [Commands.CgNext]: TypedResponseCommand 237 | [Commands.CgRemove]: TypedResponseCommand 238 | [Commands.CgClear]: TypedResponseCommand 239 | [Commands.CgUpdate]: TypedResponseCommand 240 | [Commands.CgInvoke]: TypedResponseCommand 241 | [Commands.CgInfo]: TypedResponseCommand 242 | [Commands.MixerKeyer]: TypedResponseCommand 243 | [Commands.MixerChroma]: TypedResponseCommand 244 | [Commands.MixerBlend]: TypedResponseCommand 245 | [Commands.MixerInvert]: TypedResponseCommand 246 | [Commands.MixerOpacity]: TypedResponseCommand 247 | [Commands.MixerBrightness]: TypedResponseCommand 248 | [Commands.MixerSaturation]: TypedResponseCommand 249 | [Commands.MixerContrast]: TypedResponseCommand 250 | [Commands.MixerLevels]: TypedResponseCommand 251 | [Commands.MixerFill]: TypedResponseCommand 252 | [Commands.MixerClip]: TypedResponseCommand 253 | [Commands.MixerAnchor]: TypedResponseCommand 254 | [Commands.MixerCrop]: TypedResponseCommand 255 | [Commands.MixerRotation]: TypedResponseCommand 256 | [Commands.MixerPerspective]: TypedResponseCommand 257 | [Commands.MixerMipmap]: TypedResponseCommand 258 | [Commands.MixerVolume]: TypedResponseCommand 259 | [Commands.MixerMastervolume]: TypedResponseCommand 260 | [Commands.MixerStraightAlphaOutput]: TypedResponseCommand< 261 | Commands.MixerStraightAlphaOutput, 262 | MixerStraightAlphaOutputParameters, 263 | unknown 264 | > 265 | [Commands.MixerGrid]: TypedResponseCommand 266 | [Commands.MixerCommit]: TypedResponseCommand 267 | [Commands.MixerClear]: TypedResponseCommand 268 | [Commands.ChannelGrid]: TypedResponseCommand 269 | [Commands.ThumbnailList]: TypedResponseCommand 270 | [Commands.ThumbnailRetrieve]: TypedResponseCommand 271 | [Commands.ThumbnailGenerate]: TypedResponseCommand 272 | [Commands.ThumbnailGenerateAll]: TypedResponseCommand< 273 | Commands.ThumbnailGenerateAll, 274 | ThumbnailGenerateAllParameters, 275 | unknown 276 | > 277 | [Commands.Cinf]: TypedResponseCommand 278 | [Commands.Cls]: TypedResponseCommand 279 | [Commands.Fls]: TypedResponseCommand 280 | [Commands.Tls]: TypedResponseCommand 281 | [Commands.Version]: TypedResponseCommand 282 | [Commands.Info]: TypedResponseCommand 283 | [Commands.InfoChannel]: TypedResponseCommand< 284 | Commands.InfoChannel, 285 | InfoChannelParameters, 286 | InfoChannelEntry | undefined 287 | > 288 | [Commands.InfoLayer]: TypedResponseCommand 289 | [Commands.InfoTemplate]: TypedResponseCommand 290 | [Commands.InfoConfig]: TypedResponseCommand 291 | [Commands.InfoPaths]: TypedResponseCommand 292 | [Commands.InfoSystem]: TypedResponseCommand 293 | [Commands.InfoServer]: TypedResponseCommand 294 | [Commands.InfoQueues]: TypedResponseCommand 295 | [Commands.InfoThreads]: TypedResponseCommand 296 | [Commands.InfoDelay]: TypedResponseCommand 297 | [Commands.Diag]: TypedResponseCommand 298 | [Commands.GlInfo]: TypedResponseCommand 299 | [Commands.GlGc]: TypedResponseCommand 300 | [Commands.Bye]: TypedResponseCommand 301 | [Commands.Kill]: TypedResponseCommand 302 | [Commands.Restart]: TypedResponseCommand 303 | [Commands.Ping]: TypedResponseCommand 304 | [Commands.Begin]: TypedResponseCommand 305 | [Commands.Commit]: TypedResponseCommand 306 | [Commands.Discard]: TypedResponseCommand 307 | 308 | [Commands.Custom]: TypedResponseCommand 309 | } 310 | 311 | export type LoadbgCommand = AllTypedCommands[Commands.Loadbg]['command'] 312 | export type LoadbgDecklinkCommand = AllTypedCommands[Commands.LoadbgDecklink]['command'] 313 | export type LoadbgHtmlCommand = AllTypedCommands[Commands.LoadbgHtml]['command'] 314 | export type LoadbgRouteCommand = AllTypedCommands[Commands.LoadbgRoute]['command'] 315 | export type LoadCommand = AllTypedCommands[Commands.Load]['command'] 316 | export type PlayCommand = AllTypedCommands[Commands.Play]['command'] 317 | export type PlayDecklinkCommand = AllTypedCommands[Commands.PlayDecklink]['command'] 318 | export type PlayHtmlCommand = AllTypedCommands[Commands.PlayHtml]['command'] 319 | export type PlayRouteCommand = AllTypedCommands[Commands.PlayRoute]['command'] 320 | export type PauseCommand = AllTypedCommands[Commands.Pause]['command'] 321 | export type ResumeCommand = AllTypedCommands[Commands.Resume]['command'] 322 | export type StopCommand = AllTypedCommands[Commands.Stop]['command'] 323 | export type ClearCommand = AllTypedCommands[Commands.Clear]['command'] 324 | export type CallCommand = AllTypedCommands[Commands.Call]['command'] 325 | export type SwapCommand = AllTypedCommands[Commands.Swap]['command'] 326 | export type AddCommand = AllTypedCommands[Commands.Add]['command'] 327 | export type RemoveCommand = AllTypedCommands[Commands.Remove]['command'] 328 | export type PrintCommand = AllTypedCommands[Commands.Print]['command'] 329 | export type LogLevelCommand = AllTypedCommands[Commands.LogLevel]['command'] 330 | export type LogCategoryCommand = AllTypedCommands[Commands.LogCategory]['command'] 331 | export type SetCommand = AllTypedCommands[Commands.Set]['command'] 332 | export type LockCommand = AllTypedCommands[Commands.Lock]['command'] 333 | export type DataStoreCommand = AllTypedCommands[Commands.DataStore]['command'] 334 | export type DataRetrieveCommand = AllTypedCommands[Commands.DataRetrieve]['command'] 335 | export type DataListCommand = AllTypedCommands[Commands.DataList]['command'] 336 | export type DataRemoveCommand = AllTypedCommands[Commands.DataRemove]['command'] 337 | export type CgAddCommand = AllTypedCommands[Commands.CgAdd]['command'] 338 | export type CgPlayCommand = AllTypedCommands[Commands.CgPlay]['command'] 339 | export type CgStopCommand = AllTypedCommands[Commands.CgStop]['command'] 340 | export type CgNextCommand = AllTypedCommands[Commands.CgNext]['command'] 341 | export type CgRemoveCommand = AllTypedCommands[Commands.CgRemove]['command'] 342 | export type CgClearCommand = AllTypedCommands[Commands.CgClear]['command'] 343 | export type CgUpdateCommand = AllTypedCommands[Commands.CgUpdate]['command'] 344 | export type CgInvokeCommand = AllTypedCommands[Commands.CgInvoke]['command'] 345 | export type CgInfoCommand = AllTypedCommands[Commands.CgInfo]['command'] 346 | export type MixerKeyerCommand = AllTypedCommands[Commands.MixerKeyer]['command'] 347 | export type MixerChromaCommand = AllTypedCommands[Commands.MixerChroma]['command'] 348 | export type MixerBlendCommand = AllTypedCommands[Commands.MixerBlend]['command'] 349 | export type MixerInvertCommand = AllTypedCommands[Commands.MixerInvert]['command'] 350 | export type MixerOpacityCommand = AllTypedCommands[Commands.MixerOpacity]['command'] 351 | export type MixerBrightnessCommand = AllTypedCommands[Commands.MixerBrightness]['command'] 352 | export type MixerSaturationCommand = AllTypedCommands[Commands.MixerSaturation]['command'] 353 | export type MixerContrastCommand = AllTypedCommands[Commands.MixerContrast]['command'] 354 | export type MixerLevelsCommand = AllTypedCommands[Commands.MixerLevels]['command'] 355 | export type MixerFillCommand = AllTypedCommands[Commands.MixerFill]['command'] 356 | export type MixerClipCommand = AllTypedCommands[Commands.MixerClip]['command'] 357 | export type MixerAnchorCommand = AllTypedCommands[Commands.MixerAnchor]['command'] 358 | export type MixerCropCommand = AllTypedCommands[Commands.MixerCrop]['command'] 359 | export type MixerRotationCommand = AllTypedCommands[Commands.MixerRotation]['command'] 360 | export type MixerPerspectiveCommand = AllTypedCommands[Commands.MixerPerspective]['command'] 361 | export type MixerMipmapCommand = AllTypedCommands[Commands.MixerMipmap]['command'] 362 | export type MixerVolumeCommand = AllTypedCommands[Commands.MixerVolume]['command'] 363 | export type MixerMastervolumeCommand = AllTypedCommands[Commands.MixerMastervolume]['command'] 364 | export type MixerStraightAlphaOutputCommand = AllTypedCommands[Commands.MixerStraightAlphaOutput]['command'] 365 | export type MixerGridCommand = AllTypedCommands[Commands.MixerGrid]['command'] 366 | export type MixerCommitCommand = AllTypedCommands[Commands.MixerCommit]['command'] 367 | export type MixerClearCommand = AllTypedCommands[Commands.MixerClear]['command'] 368 | export type Channel_gridCommand = AllTypedCommands[Commands.ChannelGrid]['command'] 369 | export type ThumbnailListCommand = AllTypedCommands[Commands.ThumbnailList]['command'] 370 | export type ThumbnailRetrieveCommand = AllTypedCommands[Commands.ThumbnailRetrieve]['command'] 371 | export type ThumbnailGenerateCommand = AllTypedCommands[Commands.ThumbnailGenerate]['command'] 372 | export type ThumbnailGenerate_allCommand = AllTypedCommands[Commands.ThumbnailGenerateAll]['command'] 373 | export type CinfCommand = AllTypedCommands[Commands.Cinf]['command'] 374 | export type ClsCommand = AllTypedCommands[Commands.Cls]['command'] 375 | export type FlsCommand = AllTypedCommands[Commands.Fls]['command'] 376 | export type TlsCommand = AllTypedCommands[Commands.Tls]['command'] 377 | export type VersionCommand = AllTypedCommands[Commands.Version]['command'] 378 | export type InfoCommand = AllTypedCommands[Commands.Info]['command'] 379 | export type InfoChannelCommand = AllTypedCommands[Commands.InfoChannel]['command'] 380 | export type InfoLayerCommand = AllTypedCommands[Commands.InfoLayer]['command'] 381 | export type InfoTemplateCommand = AllTypedCommands[Commands.InfoTemplate]['command'] 382 | export type InfoConfigCommand = AllTypedCommands[Commands.InfoConfig]['command'] 383 | export type InfoPathsCommand = AllTypedCommands[Commands.InfoPaths]['command'] 384 | export type InfoSystemCommand = AllTypedCommands[Commands.InfoSystem]['command'] 385 | export type InfoServerCommand = AllTypedCommands[Commands.InfoServer]['command'] 386 | export type InfoQueuesCommand = AllTypedCommands[Commands.InfoQueues]['command'] 387 | export type InfoThreadsCommand = AllTypedCommands[Commands.InfoThreads]['command'] 388 | export type InfoDelayCommand = AllTypedCommands[Commands.InfoDelay]['command'] 389 | export type DiagCommand = AllTypedCommands[Commands.Diag]['command'] 390 | export type GlInfoCommand = AllTypedCommands[Commands.GlInfo]['command'] 391 | export type GlGcCommand = AllTypedCommands[Commands.GlGc]['command'] 392 | export type ByeCommand = AllTypedCommands[Commands.Bye]['command'] 393 | export type KillCommand = AllTypedCommands[Commands.Kill]['command'] 394 | export type RestartCommand = AllTypedCommands[Commands.Restart]['command'] 395 | export type PingCommand = AllTypedCommands[Commands.Ping]['command'] 396 | export type BeginCommand = AllTypedCommands[Commands.Begin]['command'] 397 | export type CommitCommand = AllTypedCommands[Commands.Commit]['command'] 398 | export type DiscardCommand = AllTypedCommands[Commands.Discard]['command'] 399 | export type CustomCommand = AllTypedCommands[Commands.Custom]['command'] 400 | 401 | export type AMCPCommand = 402 | | LoadbgCommand 403 | | LoadbgDecklinkCommand 404 | | LoadbgHtmlCommand 405 | | LoadbgRouteCommand 406 | | LoadCommand 407 | | PlayCommand 408 | | PlayDecklinkCommand 409 | | PlayHtmlCommand 410 | | PlayRouteCommand 411 | | PauseCommand 412 | | ResumeCommand 413 | | StopCommand 414 | | ClearCommand 415 | | CallCommand 416 | | SwapCommand 417 | | AddCommand 418 | | RemoveCommand 419 | | PrintCommand 420 | | LogLevelCommand 421 | | LogCategoryCommand 422 | | SetCommand 423 | | LockCommand 424 | | DataStoreCommand 425 | | DataRetrieveCommand 426 | | DataListCommand 427 | | DataRemoveCommand 428 | | CgAddCommand 429 | | CgPlayCommand 430 | | CgStopCommand 431 | | CgNextCommand 432 | | CgRemoveCommand 433 | | CgClearCommand 434 | | CgUpdateCommand 435 | | CgInvokeCommand 436 | | CgInfoCommand 437 | | MixerKeyerCommand 438 | | MixerChromaCommand 439 | | MixerBlendCommand 440 | | MixerInvertCommand 441 | | MixerOpacityCommand 442 | | MixerBrightnessCommand 443 | | MixerSaturationCommand 444 | | MixerContrastCommand 445 | | MixerLevelsCommand 446 | | MixerFillCommand 447 | | MixerClipCommand 448 | | MixerAnchorCommand 449 | | MixerCropCommand 450 | | MixerRotationCommand 451 | | MixerPerspectiveCommand 452 | | MixerMipmapCommand 453 | | MixerVolumeCommand 454 | | MixerMastervolumeCommand 455 | | MixerStraightAlphaOutputCommand 456 | | MixerGridCommand 457 | | MixerCommitCommand 458 | | MixerClearCommand 459 | | Channel_gridCommand 460 | | ThumbnailListCommand 461 | | ThumbnailRetrieveCommand 462 | | ThumbnailGenerateCommand 463 | | ThumbnailGenerate_allCommand 464 | | CinfCommand 465 | | ClsCommand 466 | | FlsCommand 467 | | TlsCommand 468 | | VersionCommand 469 | | InfoCommand 470 | | InfoChannelCommand 471 | | InfoLayerCommand 472 | | InfoTemplateCommand 473 | | InfoConfigCommand 474 | | InfoPathsCommand 475 | | InfoSystemCommand 476 | | InfoServerCommand 477 | | InfoQueuesCommand 478 | | InfoThreadsCommand 479 | | InfoDelayCommand 480 | | DiagCommand 481 | | GlInfoCommand 482 | | GlGcCommand 483 | | ByeCommand 484 | | KillCommand 485 | | RestartCommand 486 | | PingCommand 487 | | BeginCommand 488 | | CommitCommand 489 | | DiscardCommand 490 | | CustomCommand 491 | -------------------------------------------------------------------------------- /src/connection.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'eventemitter3' 2 | import { Socket } from 'net' 3 | import { Response } from './api' 4 | import { AMCPCommand, Commands } from './commands' 5 | import { deserializers } from './deserializers' 6 | import { Version } from './enums' 7 | import { serializers, serializersV21 } from './serializers' 8 | 9 | const RESPONSE_REGEX = /(RES (?.+) )?(?\d{3}) ((?.+) )?(OK|ERROR|FAILED)/i 10 | 11 | export enum ResponseTypes { 12 | Info = 'INFO', 13 | OK = 'OK', 14 | ClientError = 'ERROR', 15 | ServerError = 'FAILED', 16 | } 17 | 18 | const RESPONSES = { 19 | 100: { 20 | type: ResponseTypes.Info, 21 | message: 'Information about an event.', 22 | }, 23 | 101: { 24 | type: ResponseTypes.Info, 25 | message: 'Information about an event. A line of data is being returned.', 26 | }, 27 | 200: { 28 | type: ResponseTypes.OK, 29 | message: 'The command has been executed and several lines of data are being returned', 30 | }, 31 | 201: { 32 | type: ResponseTypes.OK, 33 | message: 'The command has been executed and data is being returned.', 34 | }, 35 | 202: { 36 | type: ResponseTypes.OK, 37 | message: 'The command has been executed.', 38 | }, 39 | 400: { 40 | type: ResponseTypes.ClientError, 41 | message: 'Command not understood and data is being returned.', 42 | }, 43 | 401: { 44 | type: ResponseTypes.ClientError, 45 | message: 'Illegal video_channel', 46 | }, 47 | 402: { 48 | type: ResponseTypes.ClientError, 49 | message: 'Parameter missing', 50 | }, 51 | 403: { 52 | type: ResponseTypes.ClientError, 53 | message: 'Illegal parameter', 54 | }, 55 | 404: { 56 | type: ResponseTypes.ClientError, 57 | message: 'Media file not found', 58 | }, 59 | 500: { 60 | type: ResponseTypes.ServerError, 61 | message: 'Internal server error', 62 | }, 63 | 501: { 64 | type: ResponseTypes.ServerError, 65 | message: 'Internal server error', 66 | }, 67 | 502: { 68 | type: ResponseTypes.ServerError, 69 | message: 'Media file unreadable', 70 | }, 71 | 503: { 72 | type: ResponseTypes.ServerError, 73 | message: 'Access error', 74 | }, 75 | } 76 | 77 | export type ConnectionEvents = { 78 | data: [response: Response, error: Error | undefined] 79 | connect: [] 80 | disconnect: [] 81 | error: [error: Error] 82 | } 83 | export interface SentRequest { 84 | command: AMCPCommand 85 | } 86 | 87 | export class Connection extends EventEmitter { 88 | private _socket?: Socket 89 | private _unprocessedData = '' 90 | private _unprocessedLines: string[] = [] 91 | private _reconnectTimeout?: NodeJS.Timeout 92 | private _connected = false 93 | private _version = Version.v23x 94 | 95 | constructor( 96 | private host: string, 97 | private port = 5250, 98 | autoConnect: boolean, 99 | private _getRequestForResponse: (response: Response) => SentRequest | undefined 100 | ) { 101 | super() 102 | if (autoConnect) this._setupSocket() 103 | } 104 | 105 | get connected(): boolean { 106 | return this._connected 107 | } 108 | 109 | set version(version: Version) { 110 | this._version = version 111 | } 112 | 113 | changeConnection(host: string, port = 5250): void { 114 | this.host = host 115 | this.port = port 116 | 117 | this._socket?.end() 118 | 119 | this._setupSocket() 120 | } 121 | 122 | disconnect(): void { 123 | this._socket?.end() 124 | } 125 | 126 | async sendCommand(cmd: AMCPCommand, reqId?: string): Promise { 127 | if (!cmd.command) throw new Error('No command specified') 128 | if (!cmd.params) throw new Error('No parameters specified') 129 | 130 | const payload = this._serializeCommand(cmd, reqId) 131 | 132 | return new Promise((r) => { 133 | this._socket?.write(payload + '\r\n', (e) => (e ? r(e) : r(undefined))) 134 | }) 135 | } 136 | 137 | private _processIncomingData(data: Buffer) { 138 | /** 139 | * This is a simple strategy to handle receiving newline separated data, factoring in arbitrary TCP fragmentation. 140 | * It is common for a long response to be split across multiple packets, most likely with the split happening in the middle of a line. 141 | */ 142 | this._unprocessedData += data.toString('utf-8') 143 | const newLines = this._unprocessedData.split('\r\n') 144 | // Pop and preserve the last fragment as unprocessed. In most cases this will be an empty string, but it could be the first portion of a line 145 | this._unprocessedData = newLines.pop() ?? '' 146 | this._unprocessedLines.push(...newLines) 147 | 148 | while (this._unprocessedLines.length > 0) { 149 | const result = RESPONSE_REGEX.exec(this._unprocessedLines[0]) 150 | 151 | if (result?.groups?.['ResponseCode']) { 152 | let processedLines = 1 153 | 154 | // create a response object 155 | const responseCode = parseInt(result?.groups?.['ResponseCode']) 156 | const response: Response = { 157 | reqId: result?.groups?.['ReqId'], 158 | command: result?.groups?.['Action'] as Commands, 159 | responseCode, 160 | data: undefined, 161 | ...RESPONSES[responseCode as keyof typeof RESPONSES], 162 | } 163 | 164 | let responseData: string[] | undefined = undefined 165 | // parse additional lines if needed 166 | if (response.responseCode === 200) { 167 | const indexOfTerminationLine = this._unprocessedLines.indexOf('') 168 | if (indexOfTerminationLine === -1) break // No termination yet, try again later 169 | 170 | // multiple lines of data 171 | responseData = this._unprocessedLines.slice(1, indexOfTerminationLine) 172 | processedLines += responseData.length + 1 // data lines + 1 empty line 173 | } else if (response.responseCode === 201 || response.responseCode === 400) { 174 | if (this._unprocessedLines.length < 2) break // No data line, try again later 175 | 176 | responseData = [this._unprocessedLines[1]] 177 | processedLines++ 178 | } 179 | 180 | // Assign the preliminary data, to be possibly deserialized later: 181 | response.data = responseData 182 | 183 | // remove processed lines 184 | this._unprocessedLines.splice(0, processedLines) 185 | 186 | // Deserialize the response 187 | this._deserializeAndEmitResponse(response, responseData) 188 | } else { 189 | // well this is not happy, do we do something? 190 | // perhaps this is the infamous 100 or 101 response code, although that doesn't appear in casparcg source code 191 | this._unprocessedLines.splice(0, 1) 192 | } 193 | } 194 | } 195 | 196 | private _deserializeAndEmitResponse(response: Response, responseData: string[] | undefined) { 197 | Promise.resolve() 198 | .then(async () => { 199 | // Ask what the request was for this response: 200 | const previouslySentRequest = this._getRequestForResponse(response) 201 | if (previouslySentRequest) { 202 | const deserializers = this._getVersionedDeserializers() 203 | const deserializer = deserializers[previouslySentRequest.command.command] as 204 | | ((input: string[]) => Promise) 205 | | undefined 206 | // attempt to deserialize the response if we can 207 | if (deserializer && responseData?.length) { 208 | response.data = await deserializer(responseData) 209 | } 210 | } 211 | 212 | // now do something with response 213 | this.emit('data', response, undefined) 214 | }) 215 | .catch((e) => { 216 | this.emit('data', response, e) 217 | }) 218 | } 219 | 220 | private _triggerReconnect() { 221 | if (!this._reconnectTimeout) { 222 | this._reconnectTimeout = setTimeout(() => { 223 | this._reconnectTimeout = undefined 224 | 225 | if (!this._connected) this._setupSocket() 226 | }, 5000) 227 | } 228 | } 229 | 230 | private _setupSocket() { 231 | if (this._socket) { 232 | this._socket.removeAllListeners() 233 | if (!this._socket.destroyed) { 234 | this._socket.destroy() 235 | } 236 | } 237 | 238 | this._socket = new Socket() 239 | this._socket.setEncoding('utf-8') 240 | 241 | this._socket.on('data', (data) => { 242 | try { 243 | this._processIncomingData(data) 244 | } catch (e: any) { 245 | this.emit('error', e) 246 | } 247 | }) 248 | this._socket.on('connect', () => { 249 | this._setConnected(true) 250 | 251 | // Any data which hasn't been parsed yet is now incomplete, and can be discarded 252 | this._discardUnprocessed() 253 | }) 254 | this._socket.on('close', () => { 255 | this._discardUnprocessed() 256 | 257 | this._setConnected(false) 258 | this._triggerReconnect() 259 | }) 260 | this._socket.on('error', (e) => { 261 | this._discardUnprocessed() 262 | 263 | if (`${e}`.match(/ECONNREFUSED/)) { 264 | // Unable to connect, no need to handle this error 265 | this._setConnected(false) 266 | } else { 267 | this.emit('error', e) 268 | } 269 | }) 270 | 271 | this._socket.connect(this.port, this.host) 272 | } 273 | 274 | private _discardUnprocessed() { 275 | this._unprocessedData = '' 276 | this._unprocessedLines = [] 277 | } 278 | 279 | private _setConnected(connected: boolean) { 280 | if (connected) { 281 | if (!this._connected) { 282 | this._connected = true 283 | this.emit('connect') 284 | } 285 | } else { 286 | if (this._connected) { 287 | this._connected = false 288 | this.emit('disconnect') 289 | } 290 | } 291 | } 292 | 293 | private _serializeCommand(cmd: AMCPCommand, reqId?: string): string { 294 | const serializers = this._getVersionedSerializers() 295 | 296 | // use a cheeky type assertion here to easen up a bit, TS doesn't let us use just cmd.command 297 | const serializer = serializers[cmd.command] as (( 298 | c: AMCPCommand['command'], 299 | p: AMCPCommand['params'] 300 | ) => string)[] 301 | let payload = serializer 302 | .map((fn) => fn(cmd.command, cmd.params).trim()) 303 | .filter((p) => p !== '') 304 | .join(' ') 305 | 306 | if (reqId) payload = 'REQ ' + reqId + ' ' + payload 307 | 308 | return payload 309 | } 310 | 311 | private _getVersionedSerializers() { 312 | if (this._version <= Version.v21x) { 313 | return serializersV21 314 | } 315 | 316 | return serializers 317 | } 318 | 319 | private _getVersionedDeserializers(): { 320 | [key: string]: (input: string[]) => Promise 321 | } { 322 | return deserializers 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /src/deserializers/deserializeClipInfo.ts: -------------------------------------------------------------------------------- 1 | import { ClipInfo } from '../parameters' 2 | 3 | export function deserializeClipInfo(line: string): ClipInfo | undefined { 4 | const groups = line.match(/"([\s\S]*)" +(MOVIE|STILL|AUDIO) +([\s\S]*)/i) 5 | 6 | if (!groups) { 7 | return undefined 8 | } 9 | const data = groups[3].split(' ') 10 | 11 | let datetime = 0 12 | { 13 | // Handle datetime on the form "20230609071314" 14 | const m = `${data[1]}`.match(/(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/) 15 | if (m) { 16 | const d = new Date( 17 | parseInt(m[1], 10), 18 | parseInt(m[2], 10) - 1, 19 | parseInt(m[3], 10), 20 | parseInt(m[4], 10), 21 | parseInt(m[5], 10), 22 | parseInt(m[6], 10) 23 | ) 24 | // is valid? 25 | if (d.getTime() > 0) datetime = d.getTime() 26 | } 27 | } 28 | 29 | let framerate = 0 30 | { 31 | // Handle framerate on the form "1/25" 32 | const m = `${data[3]}`.match(/(\d+)\/(\d+)/) 33 | if (m) { 34 | framerate = parseInt(m[2], 10) 35 | } 36 | } 37 | 38 | return { 39 | clip: groups[1], 40 | type: groups[2] as 'MOVIE' | 'STILL' | 'AUDIO', 41 | size: parseInt(data[0], 10), 42 | datetime, 43 | frames: parseInt(data[2]) || 0, 44 | framerate, 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/deserializers/deserializeInfo.ts: -------------------------------------------------------------------------------- 1 | import { InfoEntry } from '../parameters' 2 | 3 | export const deserializeInfo = (line: string): InfoEntry | undefined => { 4 | // 1 720p5000 PLAYING 5 | const info = line.match(/(?\d) (?\d+(?p|i)(?\d+))(?.*)/i) 6 | if (info && info.groups) { 7 | return { 8 | channel: parseInt(info.groups.ChannelNo, 10), 9 | format: info.groups.Format, 10 | channelRate: parseInt(info.groups.Channelrate || '', 10) / 100, 11 | frameRate: parseInt(info.groups.Channelrate || '', 10) / 100, 12 | interlaced: info.groups.Interlaced === 'i', 13 | status: info.groups.Status.trim(), 14 | } 15 | } 16 | return undefined 17 | } 18 | -------------------------------------------------------------------------------- /src/deserializers/deserializeInfoChannel.ts: -------------------------------------------------------------------------------- 1 | import { deserializeXML } from './deserializeXML' 2 | import { InfoChannelEntry } from '../parameters' 3 | import { ensureArray, compact } from '../lib' 4 | 5 | export const deserializeInfoChannel = async (line: string): Promise => { 6 | if (!line.startsWith(' parseInt(v, 10)), 23 | }, 24 | }, 25 | 26 | layers: compact( 27 | Object.entries(mixerLayer).map(([layerName, layer0]) => { 28 | const m = layerName.match(/layer_(\d+)/) 29 | if (!m) return undefined 30 | 31 | const layer = ensureArray(layer0)[0] 32 | return { 33 | layer: parseInt(m[1], 10), 34 | // perhaps parse these later: 35 | background: ensureArray(layer.background)[0], 36 | foreground: ensureArray(layer.foreground)[0], 37 | } 38 | }) 39 | ), 40 | }, 41 | } 42 | 43 | return data 44 | } 45 | -------------------------------------------------------------------------------- /src/deserializers/deserializeInfoConfig.ts: -------------------------------------------------------------------------------- 1 | import { deserializeXML } from './deserializeXML' 2 | 3 | import { LogLevel } from '../enums' 4 | import { InfoConfig, ConsumerConfigAny, ConsumerType, ProducerConfig } from '../parameters' 5 | import { parseString, parseNumber, parseBoolean } from './deserializeXML' 6 | 7 | export const deserializeInfoConfig = async (line: string): Promise => { 8 | if (!line.startsWith(' ({ 49 | videoMode: parseString(channel, 'video-mode'), 50 | consumers: parseConsumers(channel.consumers?.[0]), 51 | producers: parseProducers(channel.producers?.[0]), 52 | })) 53 | } 54 | 55 | function parseConsumers(consumers: any): ConsumerConfigAny[] { 56 | if (!consumers) return [] 57 | return [ 58 | ConsumerType.DECKLINK, 59 | ConsumerType.BLUEFISH, 60 | ConsumerType.FFMPEG, 61 | ConsumerType.NDI, 62 | ConsumerType.NEWTEK_IVGA, 63 | ConsumerType.SCREEN, 64 | ConsumerType.SYSTEM_AUDIO, 65 | ].flatMap((consumerType: ConsumerType) => { 66 | const matchingConsumers = consumers[consumerType] 67 | return Array.isArray(matchingConsumers) 68 | ? matchingConsumers.flatMap((consumer) => parseConsumer(consumerType, consumer)) 69 | : [] 70 | }) 71 | } 72 | 73 | function parseConsumer(type: ConsumerType, consumer: any): ConsumerConfigAny { 74 | switch (type) { 75 | case ConsumerType.DECKLINK: { 76 | const subregion = consumer['subregion']?.[0] 77 | return { 78 | type, 79 | device: parseNumber(consumer, 'device'), 80 | latency: parseString(consumer, 'latency'), 81 | bufferDepth: parseNumber(consumer, 'buffer-depth'), 82 | embeddedAudio: parseBoolean(consumer, 'embedded-audio'), 83 | keyDevice: parseNumber(consumer, 'key-device'), 84 | keyer: parseString(consumer, 'keyer'), 85 | keyOnly: parseBoolean(consumer, 'key-only'), 86 | subregion: subregion 87 | ? { 88 | srcX: parseNumber(consumer, 'src-x'), 89 | srcY: parseNumber(consumer, 'src-y'), 90 | destX: parseNumber(consumer, 'dest-x'), 91 | destY: parseNumber(consumer, 'dest-y'), 92 | width: parseNumber(consumer, 'width'), 93 | height: parseNumber(consumer, 'height'), 94 | } 95 | : undefined, 96 | videoMode: parseString(consumer, 'video-mode'), 97 | } 98 | } 99 | case ConsumerType.BLUEFISH: 100 | return { 101 | type, 102 | device: parseNumber(consumer, 'device'), 103 | embeddedAudio: parseBoolean(consumer, 'embedded-audio'), 104 | internalKeyerAudioSource: parseString(consumer, 'internal-keyer-audio-source'), 105 | keyer: parseString(consumer, 'keyer'), 106 | sdiStream: parseNumber(consumer, 'sdi-stream'), 107 | uhdMode: parseNumber(consumer, 'uhd-mode'), 108 | watchdog: parseNumber(consumer, 'watchdog'), 109 | } 110 | case ConsumerType.FFMPEG: 111 | return { 112 | type, 113 | args: parseString(consumer, 'args'), 114 | path: parseString(consumer, 'path'), 115 | } 116 | case ConsumerType.NDI: 117 | return { 118 | type, 119 | allowFields: parseBoolean(consumer, 'allow-fields'), 120 | name: parseString(consumer, 'name'), 121 | } 122 | case ConsumerType.NEWTEK_IVGA: 123 | return { 124 | type, 125 | } 126 | case ConsumerType.SYSTEM_AUDIO: 127 | return { 128 | type, 129 | channelLayout: parseString(consumer, 'channel-layout'), 130 | latency: parseNumber(consumer, 'latency'), 131 | } 132 | case ConsumerType.SCREEN: 133 | return { 134 | type, 135 | alwaysOnTop: parseBoolean(consumer, 'always-on-top'), 136 | aspectRatio: parseString(consumer, 'aspect-ratio'), 137 | borderless: parseBoolean(consumer, 'borderless'), 138 | colourSpace: parseString(consumer, 'colour-space'), 139 | device: parseNumber(consumer, 'device'), 140 | interactive: parseBoolean(consumer, 'interactive'), 141 | keyOnly: parseBoolean(consumer, 'key-only'), 142 | sbsKey: parseBoolean(consumer, 'sbs-key'), 143 | stretch: parseString(consumer, 'stretch'), 144 | vsync: parseBoolean(consumer, 'vsync'), 145 | width: parseNumber(consumer, 'width'), 146 | height: parseNumber(consumer, 'height'), 147 | windowed: parseBoolean(consumer, 'windowed'), 148 | x: parseNumber(consumer, 'x'), 149 | y: parseNumber(consumer, 'y'), 150 | } 151 | } 152 | } 153 | 154 | function parseProducers(producers: any): ProducerConfig[] { 155 | if (!Array.isArray(producers?.producer)) return [] 156 | return producers.producer.map((producer: any) => ({ 157 | id: parseFloat(producer?.$?.id), 158 | producer: producer?._, 159 | })) 160 | } 161 | 162 | function parseAmcp(config: any): InfoConfig['amcp'] | undefined { 163 | const mediaServer = config?.amcp?.[0]?.['media-server']?.[0] 164 | return mediaServer 165 | ? { 166 | mediaServer: { 167 | host: parseString(mediaServer, 'host'), 168 | port: parseNumber(mediaServer, 'port'), 169 | }, 170 | } 171 | : undefined 172 | } 173 | 174 | function parseControllers(config: any): InfoConfig['controllers'] | undefined { 175 | const tcp = config?.controllers?.[0]?.['tcp']?.[0] 176 | return tcp 177 | ? { 178 | tcp: { 179 | port: parseNumber(tcp, 'port'), 180 | protocol: parseString(tcp, 'protocol'), 181 | }, 182 | } 183 | : undefined 184 | } 185 | 186 | function parseFFmpeg(config: any): InfoConfig['ffmpeg'] | undefined { 187 | const producer = config?.ffmpeg?.[0]?.['producer']?.[0] 188 | return producer 189 | ? { 190 | producer: { 191 | threads: parseNumber(producer, 'threads'), 192 | autoDeinterlace: parseString(producer, 'auto-deinterlace'), 193 | }, 194 | } 195 | : undefined 196 | } 197 | 198 | function parseHtml(config: any): InfoConfig['html'] | undefined { 199 | const html = config?.html?.[0] 200 | return html 201 | ? { 202 | enableGpu: parseBoolean(html, 'enable-gpu'), 203 | remoteDebuggingPort: parseNumber(html, 'remote-debugging-port'), 204 | } 205 | : undefined 206 | } 207 | 208 | function parseNdi(config: any): InfoConfig['ndi'] | undefined { 209 | const ndi = config?.ndi?.[0] 210 | return ndi 211 | ? { 212 | autoLoad: parseBoolean(ndi, 'auto-load'), 213 | } 214 | : undefined 215 | } 216 | 217 | function parseOsc(config: any): InfoConfig['osc'] | undefined { 218 | const osc = config?.osc?.[0] 219 | const clients = osc?.['predefined-clients']?.[0]?.['predefined-client'] 220 | return osc 221 | ? { 222 | defaulPort: parseNumber(osc, 'default-port'), 223 | disableSendToAmcpClients: parseBoolean(osc, 'disable-send-to-amcp-clients'), 224 | predefinedClients: Array.isArray(clients) 225 | ? clients.map((client) => ({ 226 | address: parseString(client, 'address'), 227 | port: parseNumber(client, 'port'), 228 | })) 229 | : undefined, 230 | } 231 | : undefined 232 | } 233 | 234 | function parseTemplateHosts(config: any): InfoConfig['templateHosts'] | undefined { 235 | const hosts = config?.['template-hosts']?.[0]?.['template-host'] 236 | return Array.isArray(hosts) 237 | ? hosts.map((host) => ({ 238 | videoMode: parseString(host, 'video-mode'), 239 | fileName: parseString(host, 'filename'), 240 | width: parseNumber(host, 'width'), 241 | height: parseNumber(host, 'height'), 242 | })) 243 | : undefined 244 | } 245 | 246 | function parseVideoModes(config: any): InfoConfig['videoModes'] | undefined { 247 | const modes = config?.['video-modes']?.[0]?.['video-mode'] 248 | return Array.isArray(modes) 249 | ? modes.map((mode) => ({ 250 | id: parseString(mode, 'id'), 251 | width: parseNumber(mode, 'width'), 252 | height: parseNumber(mode, 'height'), 253 | timeScale: parseNumber(mode, 'time-scale'), 254 | duration: parseNumber(mode, 'duration'), 255 | cadence: parseNumber(mode, 'cadence'), 256 | })) 257 | : undefined 258 | } 259 | -------------------------------------------------------------------------------- /src/deserializers/deserializeInfoLayer.ts: -------------------------------------------------------------------------------- 1 | import { InfoLayerEntry } from '../parameters' 2 | import { deserializeInfoChannel } from './deserializeInfoChannel' 3 | 4 | export const deserializeInfoLayer = async (line: string): Promise => { 5 | // Is this actually correct? 6 | // The data seems to be equal to info channel in 2.3.2 7 | return deserializeInfoChannel(line) 8 | } 9 | -------------------------------------------------------------------------------- /src/deserializers/deserializeVersion.ts: -------------------------------------------------------------------------------- 1 | import { Version } from '../enums' 2 | 3 | export const deserializeVersion = ( 4 | line: string 5 | ): { 6 | version: Version 7 | fullVersion: string 8 | } => { 9 | let version = Version.Unsupported 10 | const v = line.split('.') 11 | const major = Number(v[0]) 12 | const minor = Number(v[1]) 13 | 14 | if (major <= 2) { 15 | if (minor === 1) { 16 | version = Version.v21x 17 | } else if (minor === 2) { 18 | version = Version.v22x 19 | } else if (minor >= 3) { 20 | // just parse anything newer as v2.3 as it's most likely closest 21 | version = Version.v23x 22 | } 23 | } else { 24 | version = Version.v23x 25 | } 26 | 27 | return { 28 | version, 29 | fullVersion: line, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/deserializers/deserializeXML.ts: -------------------------------------------------------------------------------- 1 | import { ParserOptions, parseStringPromise } from 'xml2js' 2 | 3 | export const deserializeXML = async (line: string, options?: ParserOptions): Promise => { 4 | return await parseStringPromise(line, options) // todo - this seems to get stuck when we pass it non-xml 5 | } 6 | 7 | export function parseString(object: any, key: string): string | undefined 8 | export function parseString(object: any, key: string, defaultValue: string): string 9 | export function parseString( 10 | object: Record | undefined, 11 | key: string, 12 | defaultValue?: string 13 | ): string | undefined { 14 | const value = object?.[key]?.[0]?._ 15 | return value?.toString() ?? defaultValue 16 | } 17 | 18 | export function parseNumber(object: any, key: string): number | undefined 19 | export function parseNumber(object: any, key: string, defaultValue: number): number 20 | export function parseNumber( 21 | object: Record | undefined, 22 | key: string, 23 | defaultValue?: number 24 | ): number | undefined { 25 | const value = object?.[key]?.[0]?._ 26 | if (value === undefined) return defaultValue 27 | if (typeof value === 'number') return value 28 | const parsed = parseFloat(value) 29 | return isNaN(parsed) ? defaultValue : parsed 30 | } 31 | 32 | export function parseBoolean(object: any, key: string): boolean | undefined 33 | export function parseBoolean(object: any, key: string, defaultValue?: boolean): boolean 34 | export function parseBoolean( 35 | object: Record | undefined, 36 | key: string, 37 | defaultValue?: boolean 38 | ): boolean | undefined { 39 | const value = object?.[key]?.[0]?._ 40 | if (value === undefined) return defaultValue 41 | return value?.toString()?.trim()?.toLowerCase() === 'true' 42 | } 43 | -------------------------------------------------------------------------------- /src/deserializers/index.ts: -------------------------------------------------------------------------------- 1 | import { CReturnType, Commands } from '../commands' 2 | import { deserializeInfoConfig } from './deserializeInfoConfig' 3 | import { deserializeInfoChannel } from './deserializeInfoChannel' 4 | import { deserializeInfoLayer } from './deserializeInfoLayer' 5 | import { deserializeInfo } from './deserializeInfo' 6 | import { deserializeClipInfo } from './deserializeClipInfo' 7 | import { deserializeVersion } from './deserializeVersion' 8 | import { compact } from '../lib' 9 | 10 | export type Deserializer = (data: string[]) => Promise> 11 | /** Just a type guard to ensure that the inner function returns a value as defined in AllInternalCommands */ 12 | function deserializer(fcn: Deserializer): Deserializer { 13 | return fcn 14 | } 15 | 16 | export const deserializers = { 17 | [Commands.Cls]: deserializer(async (data: string[]) => compact(data.map(deserializeClipInfo))), 18 | [Commands.Cinf]: deserializer(async (data: string[]) => deserializeClipInfo(data[0])), 19 | [Commands.Version]: deserializer(async (data: string[]) => deserializeVersion(data[0])), 20 | [Commands.Info]: deserializer(async (data: string[]) => compact(data.map(deserializeInfo))), 21 | [Commands.InfoChannel]: deserializer(async (data: string[]) => 22 | deserializeInfoChannel(data[0]) 23 | ), 24 | [Commands.InfoLayer]: deserializer(async (data: string[]) => deserializeInfoLayer(data[0])), 25 | [Commands.InfoConfig]: deserializer(async (data: string[]) => deserializeInfoConfig(data[0])), 26 | } 27 | -------------------------------------------------------------------------------- /src/enums.ts: -------------------------------------------------------------------------------- 1 | export enum TransitionType { 2 | Cut = 'CUT', 3 | Mix = 'MIX', 4 | Push = 'PUSH', 5 | Wipe = 'WIPE', 6 | Slide = 'SLIDE', 7 | Sting = 'STING', 8 | } 9 | 10 | export enum TransitionTween { 11 | LINEAR = 'LINEAR', 12 | NONE = 'EASENONE', 13 | IN_QUAD = 'EASEINQUAD', 14 | OUT_QUAD = 'EASEOUTQUAD', 15 | IN_OUT_QUAD = 'EASEINOUTQUAD', 16 | OUT_IN_QUAD = 'EASEOUTINQUAD', 17 | IN_CUBIC = 'EASEINCUBIC', 18 | OUT_CUBIC = 'EASEOUTCUBIC', 19 | IN_OUT_CUBIC = 'EASEINOUTCUBIC', 20 | OUT_IN_CUBIC = 'EASEOUTINCUBIC', 21 | IN_QUART = 'EASEINQUART', 22 | OUT_QUART = 'EASEOUTQUART', 23 | IN_OUT_QUART = 'EASEINOUTQUART', 24 | OUT_IN_QUART = 'EASEOUTINQUART', 25 | IN_QUINT = 'EASEINQUINT', 26 | OUT_QUINT = 'EASEOUTQUINT', 27 | IN_OUT_QUINT = 'EASEINOUTQUINT', 28 | OUT_IN_QUINT = 'EASEOUTINQUINT', 29 | IN_SINE = 'EASEINSINE', 30 | OUT_SINE = 'EASEOUTSINE', 31 | IN_OUT_SINE = 'EASEINOUTSINE', 32 | OUT_IN_SINE = 'EASEOUTINSINE', 33 | IN_EXPO = 'EASEINEXPO', 34 | OUT_EXPO = 'EASEOUTEXPO', 35 | IN_OUT_EXPO = 'EASEINOUTEXPO', 36 | OUT_IN_EXPO = 'EASEOUTINEXPO', 37 | IN_CIRC = 'EASEINCIRC', 38 | OUT_CIRC = 'EASEOUTCIRC', 39 | IN_OUT_CIRC = 'EASEINOUTCIRC', 40 | OUT_IN_CIRC = 'EASEOUTINCIRC', 41 | IN_ELASTIC = 'EASEINELASTIC', 42 | OUT_ELASTIC = 'EASEOUTELASTIC', 43 | IN_OUT_ELASTIC = 'EASEINOUTELASTIC', 44 | OUT_IN_ELASTIC = 'EASEOUTINELASTIC', 45 | IN_BACK = 'EASEINBACK', 46 | OUT_BACK = 'EASEOUTBACK', 47 | IN_OUT_BACK = 'EASEINOUTBACK', 48 | OUT_IN_BACK = 'EASEOUTINTBACK', 49 | OUT_BOUNCE = 'EASEOUTBOUNCE', 50 | IN_BOUNCE = 'EASEINBOUNCE', 51 | IN_OUT_BOUNCE = 'EASEINOUTBOUNCE', 52 | OUT_IN_BOUNCE = 'EASEOUTINBOUNCE', 53 | } 54 | 55 | export enum Direction { 56 | Left = 'LEFT', 57 | Right = 'RIGHT', 58 | } 59 | 60 | export enum LogLevel { 61 | Trace = 'trace', 62 | Debug = 'debug', 63 | Info = 'info', 64 | Warning = 'warning', 65 | Error = 'error', 66 | Fatal = 'fatal', 67 | } 68 | 69 | export enum LogCategory { 70 | Calltrace = 'calltrace', 71 | Communication = 'communication', 72 | } 73 | 74 | export enum SetVariable { 75 | Mode = 'MODE', 76 | Channel_layout = 'CHANNEL_LAYOUT', 77 | } 78 | 79 | export enum LockAction { 80 | Acquire = 'ACQUIRE', 81 | Release = 'RELEASE', 82 | Clear = 'CLEAR', 83 | } 84 | 85 | export enum BlendMode { 86 | Normal = 'NORMAL', 87 | Lighten = 'LIGHTEN', 88 | Darken = 'DARKEN', 89 | Multiply = 'MULTIPLY', 90 | Average = 'AVERAGE', 91 | Add = 'ADD', 92 | Subtract = 'SUBTRACT', 93 | Difference = 'DIFFERENCE', 94 | Negation = 'NEGATION', 95 | Exclusion = 'EXCLUSION', 96 | Screen = 'SCREEN', 97 | Overlay = 'OVERLAY', 98 | SoftLight = 'SOFT LIGHT', 99 | HardLight = 'HARD LIGHT', 100 | ColorDodge = 'COLOR DODGE', 101 | ColorBurn = 'COLOR BURN', 102 | LinearDodge = 'LINEAR DODGE', 103 | LinearBurn = 'LINEAR BURN', 104 | LinearLight = 'LINEAR LIGHT', 105 | VividLight = 'VIVID LIGHT', 106 | PinLight = 'PIN LIGHT', 107 | HardMix = 'HARD MIX', 108 | Reflect = 'REFLECT', 109 | Glow = 'GLOW', 110 | Phoenix = 'PHOENIX', 111 | Contrast = 'CONTRAST', 112 | Saturation = 'SATURATION', 113 | Color = 'COLOR', 114 | Luminosity = 'LUMINOSITY', 115 | } 116 | 117 | export enum RouteMode { 118 | Background = 'BACKGROUND', 119 | Next = 'NEXT', 120 | } 121 | 122 | export enum Version { 123 | Unsupported = 0, 124 | v21x = 20100, 125 | v22x = 20200, 126 | v23x = 20300, 127 | } 128 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CasparCG' 2 | export * from './api' 3 | export * as Enum from './enums' 4 | export * from './commands' 5 | export * from './parameters' 6 | -------------------------------------------------------------------------------- /src/lib.ts: -------------------------------------------------------------------------------- 1 | export function literal(o: T): T { 2 | return o 3 | } 4 | 5 | export function ensureArray(v: T | T[]): T[] { 6 | return Array.isArray(v) ? v : [v] 7 | } 8 | 9 | export function compact(array: (T | undefined)[]): T[] { 10 | return array.filter((item) => item !== undefined) as T[] 11 | } 12 | -------------------------------------------------------------------------------- /src/parameters.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TransitionType, 3 | TransitionTween, 4 | Direction, 5 | LogLevel, 6 | LogCategory, 7 | SetVariable, 8 | LockAction, 9 | BlendMode, 10 | RouteMode, 11 | Version, 12 | } from './enums' 13 | 14 | export interface CustomParams { 15 | /** Optional custom parameters that will be appended to any command */ 16 | customParams?: Record 17 | } 18 | 19 | export type Empty = CustomParams & Record 20 | 21 | export interface Channel extends CustomParams { 22 | channel: number 23 | } 24 | 25 | export interface ChannelLayer extends CustomParams { 26 | channel: number 27 | layer: number 28 | } 29 | 30 | export interface TransitionParameters extends CustomParams { 31 | transitionType: TransitionType 32 | duration: number 33 | tween?: TransitionTween 34 | direction?: Direction 35 | stingProperties?: { 36 | maskFile: string 37 | overlayFile?: string 38 | delay?: number 39 | audioFadeStart?: number 40 | audioFadeDuration?: number 41 | } 42 | } 43 | 44 | export interface ClipParameters { 45 | clip?: string 46 | loop?: boolean 47 | inPoint?: number 48 | seek?: number 49 | length?: number 50 | clearOn404?: boolean 51 | } 52 | 53 | export interface DecklinkParameters { 54 | device: number 55 | format?: string 56 | } 57 | 58 | export interface HtmlParameters { 59 | url: string 60 | } 61 | 62 | export interface RouteParameters { 63 | route: { 64 | channel: number 65 | layer?: number 66 | } 67 | framesDelay?: number 68 | mode?: RouteMode 69 | } 70 | 71 | export interface ProducerOptions { 72 | /** 73 | * Ffmpeg video filter, serialized as vfilter in 2.2+ and filter before 2.2 74 | */ 75 | vFilter?: string 76 | /** 77 | * 2.2+ only option, specifify a channelLayout for backwards compatibility 78 | */ 79 | aFilter?: string 80 | /** 81 | * Channel layout is a 2.1 only option 82 | */ 83 | channelLayout?: string 84 | transition?: TransitionParameters 85 | } 86 | 87 | export interface PlayParameters extends ChannelLayer, ClipParameters, ProducerOptions {} 88 | export interface PlayDecklinkParameters extends ChannelLayer, DecklinkParameters, ProducerOptions {} 89 | export interface PlayHtmlParameters extends ChannelLayer, HtmlParameters, ProducerOptions {} 90 | export interface PlayRouteParameters extends ChannelLayer, RouteParameters, ProducerOptions {} 91 | 92 | export interface LoadbgParameters extends PlayParameters { 93 | clip: string 94 | auto?: boolean 95 | } 96 | export type LoadbgDecklinkParameters = PlayDecklinkParameters 97 | export type LoadbgHtmlParameters = PlayHtmlParameters 98 | export type LoadbgRouteParameters = PlayRouteParameters 99 | 100 | export type LoadParameters = PlayParameters 101 | 102 | export type PauseParameters = ChannelLayer 103 | export type ResumeParameters = ChannelLayer 104 | export type StopParameters = ChannelLayer 105 | export type ClearParameters = { 106 | channel: number 107 | layer?: number 108 | } 109 | 110 | export interface CallParameters extends ChannelLayer { 111 | param: string 112 | value?: string | number 113 | } 114 | 115 | export interface SwapParameters extends ChannelLayer { 116 | channel2: number 117 | layer2: number 118 | transforms: boolean 119 | } 120 | 121 | export interface AddParameters extends Channel { 122 | consumer: string 123 | parameters: string 124 | } 125 | 126 | export interface RemoveParameters extends Channel { 127 | consumer: string | number 128 | } 129 | 130 | export type PrintParameters = Channel 131 | 132 | export interface LogLevelParameters { 133 | level: LogLevel 134 | } 135 | 136 | export interface LogCategoryParameters { 137 | category: LogCategory 138 | enable: boolean 139 | } 140 | export interface SetParameters extends Channel { 141 | variable: SetVariable 142 | value: string 143 | } 144 | export interface LockParameters extends Channel { 145 | action: LockAction 146 | secret: string 147 | } 148 | 149 | export interface DataStoreParameters { 150 | name: string 151 | data: string 152 | } 153 | export interface DataRetrieveParameters { 154 | name: string 155 | } 156 | export interface DataListParameters { 157 | subDirectory?: string 158 | } 159 | export interface DataRemoveParameters { 160 | name: string 161 | } 162 | 163 | export interface CGLayer { 164 | /** cgLayer (defaults to 1) */ 165 | cgLayer?: number 166 | } 167 | 168 | export interface CgAddParameters extends ChannelLayer, CGLayer { 169 | template: string 170 | /** If true, CasparCG will call play() in the template after load. */ 171 | playOnLoad: boolean 172 | data?: Record | string 173 | } 174 | export interface CgPlayParameters extends ChannelLayer, CGLayer {} 175 | export interface CgStopParameters extends ChannelLayer, CGLayer {} 176 | export interface CgNextParameters extends ChannelLayer, CGLayer {} 177 | export interface CgRemoveParameters extends ChannelLayer, CGLayer {} 178 | export type CgClearParameters = ChannelLayer 179 | export interface CgUpdateParameters extends ChannelLayer, CGLayer { 180 | data: string 181 | } 182 | export interface CgInvokeParameters extends ChannelLayer, CGLayer { 183 | method: string 184 | } 185 | export type CgInfoParameters = ChannelLayer & CGLayer 186 | 187 | export interface MixerTween { 188 | duration?: number 189 | tween?: TransitionTween 190 | } 191 | export interface MixerDefer { 192 | defer?: boolean 193 | } 194 | export interface MixerNumberValue extends ChannelLayer, MixerDefer, MixerTween { 195 | value: number 196 | } 197 | 198 | export interface MixerKeyerParameters extends ChannelLayer { 199 | keyer: boolean 200 | } 201 | export interface MixerChromaParameters extends ChannelLayer, MixerTween { 202 | enable: boolean 203 | targetHue: number 204 | hueWidth: number 205 | minSaturation: number 206 | minBrightness: number 207 | softness: number 208 | spillSuppress: number 209 | spillSuppressSaturation: number 210 | showMask: boolean 211 | } 212 | export interface MixerBlendParameters extends ChannelLayer, MixerDefer { 213 | value: BlendMode 214 | } 215 | export interface MixerInvertParameters extends ChannelLayer, MixerDefer { 216 | value: boolean 217 | } 218 | export type MixerOpacityParameters = MixerNumberValue 219 | export type MixerBrightnessParameters = MixerNumberValue 220 | export type MixerSaturationParameters = MixerNumberValue 221 | export type MixerContrastParameters = MixerNumberValue 222 | export interface MixerLevelsParameters extends ChannelLayer, MixerDefer, MixerTween { 223 | minInput: number 224 | maxInput: number 225 | gamma: number 226 | minOutput: number 227 | maxOutput: number 228 | } 229 | export interface MixerFillParameters extends ChannelLayer, MixerDefer, MixerTween { 230 | x: number 231 | y: number 232 | xScale: number 233 | yScale: number 234 | } 235 | export interface MixerClipParameters extends ChannelLayer, MixerDefer, MixerTween { 236 | x: number 237 | y: number 238 | width: number 239 | height: number 240 | } 241 | export interface MixerAnchorParameters extends ChannelLayer, MixerDefer, MixerTween { 242 | x: number 243 | y: number 244 | } 245 | export interface MixerCropParameters extends ChannelLayer, MixerDefer, MixerTween { 246 | left: number 247 | top: number 248 | right: number 249 | bottom: number 250 | } 251 | export type MixerRotationParameters = MixerNumberValue 252 | export interface MixerPerspectiveParameters extends ChannelLayer, MixerDefer, MixerTween { 253 | topLeftX: number 254 | topLeftY: number 255 | topRightX: number 256 | topRightY: number 257 | bottomRightX: number 258 | bottomRightY: number 259 | bottomLeftX: number 260 | bottomLeftY: number 261 | } 262 | export interface MixerMipmapParameters extends ChannelLayer, MixerDefer { 263 | value: boolean 264 | } 265 | export type MixerVolumeParameters = MixerNumberValue 266 | export interface MixerMastervolumeParameters extends Channel, MixerDefer, MixerTween { 267 | value: number 268 | } 269 | export interface MixerStraightAlphaOutputParameters extends Channel, MixerDefer { 270 | value: boolean 271 | } 272 | export interface MixerGridParameters extends Channel, MixerDefer, MixerTween, MixerNumberValue {} 273 | export type MixerCommitParameters = Channel 274 | export type MixerClearParameters = ChannelLayer 275 | 276 | export type ChannelGridParameters = Empty 277 | 278 | export interface ThumbnailListParameters { 279 | subDirectory?: string 280 | } 281 | export interface ThumbnailRetrieveParameters { 282 | filename: string 283 | } 284 | export interface ThumbnailGenerateParameters { 285 | filename: string 286 | } 287 | export type ThumbnailGenerateAllParameters = Empty 288 | 289 | export interface CinfParameters { 290 | filename: string 291 | } 292 | export interface ClsParameters { 293 | subDirectory?: string 294 | } 295 | export interface ClipInfo { 296 | /** Clip filename, eg "myFolder/AMB" */ 297 | clip: string 298 | /** Type of media */ 299 | type: 'MOVIE' | 'STILL' | 'AUDIO' // | 'HTML' | 'ROUTE' 300 | /** Size, in bytes */ 301 | size: number 302 | /** Datetime (unix timestamp) */ 303 | datetime: number 304 | /** Number of frames */ 305 | frames: number 306 | /** Number of frames per second, eg 25 */ 307 | framerate: number 308 | } 309 | export type FlsParameters = Empty 310 | export interface TlsParameters { 311 | subDirectory?: string 312 | } 313 | 314 | export type VersionParameters = Empty 315 | export interface VersionInfo { 316 | /** The version of the CasparCG server */ 317 | version: Version 318 | /** Unparsed version as string */ 319 | fullVersion: string 320 | } 321 | 322 | export type InfoParameters = Empty 323 | export interface InfoEntry { 324 | /** Channel number, eg 1,2,3 */ 325 | channel: number 326 | /** Channel format, eg "720p5000" */ 327 | format: string 328 | /** Channel frame rate, eg 50 */ 329 | channelRate: number 330 | /** Channel frame rate, eg 50 */ 331 | frameRate: number 332 | /** If interlaced or not */ 333 | interlaced: boolean 334 | 335 | /** eg "PLAYING" */ 336 | status: string 337 | } 338 | 339 | export interface InfoChannelParameters { 340 | channel: number 341 | } 342 | export interface InfoChannelEntry { 343 | channel: { 344 | framerate: number 345 | mixer: { 346 | audio: { 347 | /** Current volumes on audio channels */ 348 | volumes: number[] 349 | } 350 | } 351 | layers: { 352 | layer: number 353 | background: unknown 354 | foreground: unknown 355 | }[] 356 | } 357 | } 358 | export interface InfoLayerParameters { 359 | channel: number 360 | layer: number 361 | } 362 | export type InfoLayerEntry = InfoChannelEntry 363 | export interface InfoTemplateParameters { 364 | template: string 365 | } 366 | 367 | export type InfoConfigParameters = Empty 368 | export const enum ConsumerType { 369 | DECKLINK = 'decklink', 370 | BLUEFISH = 'bluefish', 371 | SYSTEM_AUDIO = 'system-audio', 372 | SCREEN = 'screen', 373 | NEWTEK_IVGA = 'newtek-ivga', 374 | NDI = 'ndi', 375 | FFMPEG = 'ffmpeg', 376 | } 377 | export interface ConsumerConfig { 378 | type: ConsumerType 379 | } 380 | export interface DeckLinkConsumerConfig extends ConsumerConfig { 381 | type: ConsumerType.DECKLINK 382 | device?: number 383 | keyDevice?: number 384 | embeddedAudio?: boolean 385 | latency?: string 386 | keyer?: string 387 | keyOnly?: boolean 388 | bufferDepth?: number 389 | videoMode?: string 390 | subregion?: { 391 | srcX?: number 392 | srcY?: number 393 | destX?: number 394 | destY?: number 395 | width?: number 396 | height?: number 397 | } 398 | } 399 | export interface BluefishConsumerConfig extends ConsumerConfig { 400 | type: ConsumerType.BLUEFISH 401 | device?: number 402 | sdiStream?: number 403 | embeddedAudio?: boolean 404 | keyer?: string 405 | internalKeyerAudioSource?: string 406 | watchdog?: number 407 | uhdMode?: number 408 | } 409 | export interface SystemAudioConsumerConfig extends ConsumerConfig { 410 | type: ConsumerType.SYSTEM_AUDIO 411 | channelLayout?: string 412 | latency?: number 413 | } 414 | export interface ScreenConsumerConfig extends ConsumerConfig { 415 | type: ConsumerType.SCREEN 416 | device?: number 417 | aspectRatio?: string 418 | stretch?: string 419 | windowed?: boolean 420 | keyOnly?: boolean 421 | vsync?: boolean 422 | borderless?: boolean 423 | interactive?: boolean 424 | alwaysOnTop?: boolean 425 | x?: number 426 | y?: number 427 | width?: number 428 | height?: number 429 | sbsKey?: boolean 430 | colourSpace?: string 431 | } 432 | export interface IVgaConsumerConfig extends ConsumerConfig { 433 | type: ConsumerType.NEWTEK_IVGA 434 | } 435 | export interface NdiConsumerConfig extends ConsumerConfig { 436 | type: ConsumerType.NDI 437 | name?: string 438 | allowFields?: boolean 439 | } 440 | export interface FFmpegConsumerConfig extends ConsumerConfig { 441 | type: ConsumerType.FFMPEG 442 | path?: string 443 | args?: string 444 | } 445 | export type ConsumerConfigAny = 446 | | DeckLinkConsumerConfig 447 | | BluefishConsumerConfig 448 | | SystemAudioConsumerConfig 449 | | ScreenConsumerConfig 450 | | IVgaConsumerConfig 451 | | NdiConsumerConfig 452 | | FFmpegConsumerConfig 453 | export interface ProducerConfig { 454 | id: number 455 | producer: string 456 | } 457 | export interface InfoChannelConfig { 458 | videoMode?: string 459 | consumers: ConsumerConfigAny[] 460 | producers: ProducerConfig[] 461 | } 462 | export interface TemplateHostConfig { 463 | videoMode?: string 464 | fileName?: string 465 | width?: number 466 | height?: number 467 | } 468 | export interface InfoVideoModeConfig { 469 | id?: string 470 | width?: number 471 | height?: number 472 | timeScale?: number 473 | duration?: number 474 | cadence?: number 475 | } 476 | export interface InfoConfig { 477 | logLevel?: LogLevel 478 | paths?: { 479 | media?: string 480 | logs?: string 481 | data?: string 482 | templates?: string 483 | } 484 | lockClearPhrase?: string 485 | channels?: InfoChannelConfig[] 486 | templateHosts?: TemplateHostConfig[] 487 | ffmpeg?: { 488 | producer?: { 489 | autoDeinterlace?: string 490 | threads?: number 491 | } 492 | } 493 | html?: { 494 | remoteDebuggingPort?: number 495 | enableGpu?: boolean 496 | } 497 | ndi?: { 498 | autoLoad?: boolean 499 | } 500 | osc?: { 501 | defaulPort?: number 502 | disableSendToAmcpClients?: boolean 503 | predefinedClients?: Array<{ address?: string; port?: number }> 504 | } 505 | controllers?: { 506 | tcp?: { 507 | port?: number 508 | protocol?: string 509 | } 510 | } 511 | amcp?: { 512 | mediaServer?: { 513 | host?: string 514 | port?: number 515 | } 516 | } 517 | videoModes?: InfoVideoModeConfig[] 518 | 519 | // Contents of the config file, parsed with xml2js. It might contain elements present in the config, but not typed in the InfoConfig interface. 520 | raw?: unknown 521 | // Unparsed contents of the config file 522 | rawXml: string 523 | } 524 | 525 | export type InfoPathsParameters = Empty 526 | export type InfoSystemParameters = Empty 527 | export type InfoServerParameters = Empty 528 | export type InfoQueuesParameters = Empty 529 | export type InfoThreadsParameters = Empty 530 | export type InfoDelayParameters = ChannelLayer 531 | 532 | export type DiagParameters = Empty 533 | 534 | export type GlInfoParameters = Empty 535 | export type GlGcParameters = Empty 536 | 537 | export type ByeParameters = Empty 538 | export type KillParameters = Empty 539 | export type RestartParameters = Empty 540 | export type PingParameters = Empty 541 | export type BeginParameters = Empty 542 | export type CommitParameters = Empty 543 | export type DiscardParameters = Empty 544 | 545 | export interface CustomCommandParameters extends CustomParams { 546 | command: string 547 | } 548 | -------------------------------------------------------------------------------- /src/serializers.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 2 | import { AMCPCommand, Commands } from './commands' 3 | import { TransitionType } from './enums' 4 | import { 5 | AddParameters, 6 | CallParameters, 7 | CgAddParameters, 8 | CGLayer, 9 | CgUpdateParameters, 10 | ClipParameters, 11 | CustomCommandParameters, 12 | DecklinkParameters, 13 | HtmlParameters, 14 | MixerTween, 15 | ProducerOptions, 16 | RemoveParameters, 17 | RouteParameters, 18 | TransitionParameters, 19 | } from './parameters' 20 | 21 | const commandNameSerializer = (command: Commands): string => command 22 | const splitCommandSerializer = (command: Commands): string => command.split(' ')[0] 23 | const splitCommandKeywordSerializer = (command: Commands): string => command.split(' ')[1] 24 | 25 | const channelSerializer = (_command: Commands, { channel }: { channel: number }): string => channel + '' 26 | const channelLayerSerializer = (_command: Commands, { channel, layer }: { channel: number; layer: number }): string => 27 | `${channel}-${layer}` 28 | const channelLayer2Serializer = ( 29 | _command: Commands, 30 | { channel2, layer2 }: { channel2: number; layer2: number } 31 | ): string => `${channel2}-${layer2}` 32 | const channelLayerOptSerializer = ( 33 | _command: Commands, 34 | { channel, layer }: { channel: number; layer?: number } 35 | ): string => channel + (layer ? '-' + layer : '') 36 | 37 | const clipCommandSerializer = (_command: Commands, { clip, loop, inPoint, seek, length, clearOn404 }: ClipParameters) => 38 | (clip ? `"${clip}"` : '') + 39 | (loop === true ? ' LOOP' : '') + 40 | (inPoint !== undefined ? ' IN ' + inPoint : '') + 41 | (seek !== undefined ? ' SEEK ' + seek : '') + 42 | (length !== undefined ? ' LENGTH ' + length : '') + 43 | (clearOn404 === true ? ' CLEAR_ON_404' : '') 44 | const decklinkCommandSerializer = (_: Commands, { device, format }: DecklinkParameters) => 45 | 'DECKLINK ' + device + (format ? ' FORMAT ' + format : '') 46 | const htmlCommandSerializerr = (_: Commands, { url }: HtmlParameters) => '[html] ' + url 47 | const routeCommandSerializer = (_: Commands, { route, mode, framesDelay }: RouteParameters) => 48 | 'route://' + 49 | route.channel + 50 | (route.layer !== undefined ? '-' + route.layer : '') + 51 | (mode ? ' ' + mode : '') + 52 | (framesDelay ? 'BUFFER ' + framesDelay : '') 53 | const producerOptionsSerializer = (_: Commands, { vFilter, aFilter }: ProducerOptions) => { 54 | return [vFilter ? `VF "${vFilter}"` : undefined, aFilter ? `AF "${aFilter}"` : undefined] 55 | .filter((p) => p !== undefined) 56 | .join(' ') 57 | } 58 | const producerV21Serializer = (_: Commands, { channelLayout, vFilter }: ProducerOptions) => { 59 | return [vFilter ? 'FILTER ' + vFilter : undefined, channelLayout ? 'CHANNEL_LAYOUT ' + channelLayout : undefined] 60 | .filter((p) => p !== undefined) 61 | .join(' ') 62 | } 63 | 64 | const transitionOptSerializer = (_command: Commands, { transition }: { transition?: TransitionParameters }) => 65 | (transition && transitionSerializer(transition)) || '' 66 | const transitionSerializer = ({ 67 | transitionType, 68 | duration, 69 | tween, 70 | direction, 71 | stingProperties, 72 | }: TransitionParameters) => { 73 | if (transitionType === TransitionType.Sting) { 74 | const params = { 75 | MASK: stingProperties?.maskFile, 76 | OVERLAY: stingProperties?.overlayFile, 77 | TRIGGER_POINT: stingProperties?.delay, 78 | AUDIO_FADE_START: stingProperties?.audioFadeStart, 79 | AUDIO_FADE_DURATION: stingProperties?.audioFadeDuration, 80 | } 81 | 82 | return ( 83 | 'STING (' + 84 | Object.entries(params) 85 | .filter(([_, v]) => v !== undefined && v !== null) 86 | .map(([k, v]) => k + '=' + v) 87 | .join(' ') + 88 | ')' 89 | ) 90 | } else { 91 | return [transitionType, duration, tween, direction].filter((p) => p !== undefined).join(' ') 92 | } 93 | } 94 | const callAttributeSerializer = (_: Commands, { param, value }: CallParameters) => 95 | param + (value !== undefined ? ' ' + value : '') 96 | const consumerSerializer = (_: Commands, { consumer, parameters }: AddParameters) => consumer + ' ' + parameters 97 | const removeSerializer = (_: Commands, { consumer }: RemoveParameters) => consumer + '' 98 | 99 | const cgLayerSerializer = (_: Commands, { cgLayer }: CGLayer) => (cgLayer === undefined ? '1' : `${cgLayer}`) 100 | const cgDataSerializer = (_: Commands, { data }: CgUpdateParameters | CgAddParameters) => { 101 | if (!data) { 102 | return '' 103 | } else if (typeof data === 'string') { 104 | return data 105 | } else { 106 | // Escape the data so that CasparCG can process it and send into templates: 107 | // * Escape \, $ and " 108 | // * Wrap in "-quotes 109 | return `"${JSON.stringify(data).replace(/[\\$"]/g, '\\$&')}"` 110 | } 111 | } 112 | 113 | const mixerTweenSerializer = (_: Commands, { tween, duration }: MixerTween) => 114 | (duration || '') + (tween ? ' ' + tween : '') 115 | const mixerSimpleValueSerializer = (_: Commands, { value }: { value: number | boolean | string }) => 116 | value !== undefined ? (typeof value === 'boolean' ? (value ? '1' : '0') : value + '') : '' 117 | 118 | const customCommandSerializer = (_: Commands, { command }: CustomCommandParameters) => command 119 | 120 | const customParamsSerializer = ( 121 | _: Commands, 122 | params: { customParams?: Record } 123 | ): string => { 124 | if (!params.customParams) { 125 | return '' 126 | } 127 | 128 | return Object.entries(params.customParams) 129 | .map(([key, value]) => { 130 | // For null, undefined, or empty string values, just return the key name 131 | if (value === null || value === undefined || value === '') { 132 | return key 133 | } 134 | // Quote string values, leave others as is 135 | const paramValue = typeof value === 'string' ? `"${value}"` : value 136 | return `${key} ${paramValue}` 137 | }) 138 | .join(' ') 139 | } 140 | 141 | const optional: (fn: (command: T, params: Y) => string) => (command: T, params: Y) => string = 142 | (fn) => (command, params) => { 143 | const keys = Object.keys(params) 144 | 145 | if (keys.length > 2) { 146 | return fn(command, params) 147 | } else { 148 | return '' 149 | } 150 | } 151 | 152 | type Serializers = { 153 | [command in C as command['command']]: Array<(c: command['command'], p: command['params']) => string> 154 | } 155 | 156 | export const serializers: Readonly> = { 157 | [Commands.Loadbg]: [ 158 | commandNameSerializer, 159 | channelLayerSerializer, 160 | clipCommandSerializer, 161 | (_, { auto }) => (auto ? 'AUTO' : ''), 162 | producerOptionsSerializer, 163 | transitionOptSerializer, 164 | customParamsSerializer, 165 | ], 166 | [Commands.LoadbgDecklink]: [ 167 | splitCommandSerializer, 168 | channelLayerSerializer, 169 | decklinkCommandSerializer, 170 | producerOptionsSerializer, 171 | transitionOptSerializer, 172 | customParamsSerializer, 173 | ], 174 | [Commands.LoadbgHtml]: [ 175 | splitCommandSerializer, 176 | channelLayerSerializer, 177 | htmlCommandSerializerr, 178 | producerOptionsSerializer, 179 | transitionOptSerializer, 180 | customParamsSerializer, 181 | ], 182 | [Commands.LoadbgRoute]: [ 183 | splitCommandSerializer, 184 | channelLayerSerializer, 185 | routeCommandSerializer, 186 | producerOptionsSerializer, 187 | transitionOptSerializer, 188 | customParamsSerializer, 189 | ], 190 | [Commands.Load]: [ 191 | commandNameSerializer, 192 | channelLayerSerializer, 193 | clipCommandSerializer, 194 | producerOptionsSerializer, 195 | transitionOptSerializer, 196 | customParamsSerializer, 197 | ], 198 | [Commands.Play]: [ 199 | commandNameSerializer, 200 | channelLayerSerializer, 201 | clipCommandSerializer, 202 | producerOptionsSerializer, 203 | transitionOptSerializer, 204 | customParamsSerializer, 205 | ], 206 | [Commands.PlayDecklink]: [ 207 | splitCommandSerializer, 208 | channelLayerSerializer, 209 | decklinkCommandSerializer, 210 | producerOptionsSerializer, 211 | transitionOptSerializer, 212 | customParamsSerializer, 213 | ], 214 | [Commands.PlayHtml]: [ 215 | splitCommandSerializer, 216 | channelLayerSerializer, 217 | htmlCommandSerializerr, 218 | producerOptionsSerializer, 219 | transitionOptSerializer, 220 | customParamsSerializer, 221 | ], 222 | [Commands.PlayRoute]: [ 223 | splitCommandSerializer, 224 | channelLayerSerializer, 225 | routeCommandSerializer, 226 | producerOptionsSerializer, 227 | transitionOptSerializer, 228 | customParamsSerializer, 229 | ], 230 | [Commands.Pause]: [commandNameSerializer, channelLayerSerializer], 231 | [Commands.Resume]: [commandNameSerializer, channelLayerSerializer], 232 | [Commands.Stop]: [commandNameSerializer, channelLayerSerializer], 233 | [Commands.Clear]: [commandNameSerializer, channelLayerOptSerializer], 234 | [Commands.Call]: [commandNameSerializer, channelLayerSerializer, callAttributeSerializer], 235 | [Commands.Swap]: [commandNameSerializer, channelLayerSerializer, channelLayer2Serializer], 236 | 237 | [Commands.Add]: [commandNameSerializer, channelSerializer, consumerSerializer], 238 | [Commands.Remove]: [commandNameSerializer, channelSerializer, removeSerializer], 239 | [Commands.Print]: [commandNameSerializer, channelSerializer], 240 | 241 | [Commands.LogLevel]: [commandNameSerializer, (_, { level }) => level], 242 | [Commands.LogCategory]: [ 243 | commandNameSerializer, 244 | (_: Commands, { category, enable }) => category + '' + (enable ? '1' : '0'), 245 | ], 246 | [Commands.Set]: [ 247 | commandNameSerializer, 248 | channelSerializer, 249 | (_: Commands, { variable, value }) => variable + ' ' + value, 250 | ], 251 | [Commands.Lock]: [ 252 | commandNameSerializer, 253 | channelSerializer, 254 | (_: Commands, { action, secret }) => action + (secret ? ' ' + secret : ''), 255 | ], 256 | 257 | [Commands.DataStore]: [commandNameSerializer, (_, { name, data }) => name + ' ' + data], 258 | [Commands.DataRetrieve]: [commandNameSerializer, (_, { name }) => name], 259 | [Commands.DataList]: [commandNameSerializer, (_, { subDirectory }) => subDirectory || ''], 260 | [Commands.DataRemove]: [commandNameSerializer, (_, { name }) => name], 261 | 262 | [Commands.CgAdd]: [ 263 | splitCommandSerializer, 264 | channelLayerSerializer, 265 | splitCommandKeywordSerializer, 266 | cgLayerSerializer, 267 | (_, { template, playOnLoad }) => `"${template}" ${playOnLoad ? '1' : '0'}`, 268 | cgDataSerializer, 269 | customParamsSerializer, 270 | ], 271 | [Commands.CgPlay]: [ 272 | splitCommandSerializer, 273 | channelLayerSerializer, 274 | splitCommandKeywordSerializer, 275 | cgLayerSerializer, 276 | customParamsSerializer, 277 | ], 278 | [Commands.CgStop]: [ 279 | splitCommandSerializer, 280 | channelLayerSerializer, 281 | splitCommandKeywordSerializer, 282 | cgLayerSerializer, 283 | customParamsSerializer, 284 | ], 285 | [Commands.CgNext]: [ 286 | splitCommandSerializer, 287 | channelLayerSerializer, 288 | splitCommandKeywordSerializer, 289 | cgLayerSerializer, 290 | customParamsSerializer, 291 | ], 292 | [Commands.CgRemove]: [ 293 | splitCommandSerializer, 294 | channelLayerSerializer, 295 | splitCommandKeywordSerializer, 296 | cgLayerSerializer, 297 | customParamsSerializer, 298 | ], 299 | [Commands.CgClear]: [splitCommandSerializer, channelLayerSerializer, splitCommandKeywordSerializer], 300 | [Commands.CgUpdate]: [ 301 | splitCommandSerializer, 302 | channelLayerSerializer, 303 | splitCommandKeywordSerializer, 304 | cgLayerSerializer, 305 | cgDataSerializer, 306 | customParamsSerializer, 307 | ], 308 | [Commands.CgInvoke]: [ 309 | splitCommandSerializer, 310 | channelLayerSerializer, 311 | splitCommandKeywordSerializer, 312 | cgLayerSerializer, 313 | (_, { method }) => method, 314 | customParamsSerializer, 315 | ], 316 | [Commands.CgInfo]: [ 317 | splitCommandSerializer, 318 | channelLayerSerializer, 319 | splitCommandKeywordSerializer, 320 | cgLayerSerializer, 321 | customParamsSerializer, 322 | ], 323 | 324 | [Commands.MixerKeyer]: [ 325 | splitCommandSerializer, 326 | channelLayerSerializer, 327 | splitCommandKeywordSerializer, 328 | (_, { keyer }) => (keyer ? '1' : '0'), 329 | customParamsSerializer, 330 | ], 331 | [Commands.MixerChroma]: [ 332 | splitCommandSerializer, 333 | channelLayerSerializer, 334 | splitCommandKeywordSerializer, 335 | optional( 336 | (_, params) => 337 | `${params.enable ? 1 : 0} ${params.targetHue} ${params.hueWidth} ${params.minSaturation} ${ 338 | params.minBrightness 339 | } ${params.softness} ${params.spillSuppress} ${params.spillSuppressSaturation} ${params.showMask}` 340 | ), 341 | mixerTweenSerializer, 342 | customParamsSerializer, 343 | ], 344 | [Commands.MixerBlend]: [ 345 | splitCommandSerializer, 346 | channelLayerSerializer, 347 | splitCommandKeywordSerializer, 348 | mixerSimpleValueSerializer, 349 | customParamsSerializer, 350 | ], 351 | [Commands.MixerInvert]: [ 352 | splitCommandSerializer, 353 | channelLayerSerializer, 354 | splitCommandKeywordSerializer, 355 | mixerSimpleValueSerializer, 356 | customParamsSerializer, 357 | ], 358 | [Commands.MixerOpacity]: [ 359 | splitCommandSerializer, 360 | channelLayerSerializer, 361 | splitCommandKeywordSerializer, 362 | mixerSimpleValueSerializer, 363 | mixerTweenSerializer, 364 | customParamsSerializer, 365 | ], 366 | [Commands.MixerBrightness]: [ 367 | splitCommandSerializer, 368 | channelLayerSerializer, 369 | splitCommandKeywordSerializer, 370 | mixerSimpleValueSerializer, 371 | mixerTweenSerializer, 372 | customParamsSerializer, 373 | ], 374 | [Commands.MixerSaturation]: [ 375 | splitCommandSerializer, 376 | channelLayerSerializer, 377 | splitCommandKeywordSerializer, 378 | mixerSimpleValueSerializer, 379 | mixerTweenSerializer, 380 | customParamsSerializer, 381 | ], 382 | [Commands.MixerContrast]: [ 383 | splitCommandSerializer, 384 | channelLayerSerializer, 385 | splitCommandKeywordSerializer, 386 | mixerSimpleValueSerializer, 387 | mixerTweenSerializer, 388 | customParamsSerializer, 389 | ], 390 | [Commands.MixerLevels]: [ 391 | splitCommandSerializer, 392 | channelLayerSerializer, 393 | splitCommandKeywordSerializer, 394 | optional((_, params) => 395 | [params.minInput, params.maxInput, params.gamma, params.minOutput, params.maxOutput].join(' ') 396 | ), 397 | mixerTweenSerializer, 398 | customParamsSerializer, 399 | ], 400 | [Commands.MixerFill]: [ 401 | splitCommandSerializer, 402 | channelLayerSerializer, 403 | splitCommandKeywordSerializer, 404 | optional((_, params) => [params.x, params.y, params.xScale, params.yScale].join(' ')), 405 | mixerTweenSerializer, 406 | customParamsSerializer, 407 | ], 408 | [Commands.MixerClip]: [ 409 | splitCommandSerializer, 410 | channelLayerSerializer, 411 | splitCommandKeywordSerializer, 412 | optional((_, params) => [params.x, params.y, params.width, params.height].join(' ')), 413 | mixerTweenSerializer, 414 | customParamsSerializer, 415 | ], 416 | [Commands.MixerAnchor]: [ 417 | splitCommandSerializer, 418 | channelLayerSerializer, 419 | splitCommandKeywordSerializer, 420 | optional((_, params) => [params.x, params.y].join(' ')), 421 | mixerTweenSerializer, 422 | customParamsSerializer, 423 | ], 424 | [Commands.MixerCrop]: [ 425 | splitCommandSerializer, 426 | channelLayerSerializer, 427 | splitCommandKeywordSerializer, 428 | optional((_, params) => [params.left, params.top, params.right, params.bottom].join(' ')), 429 | mixerTweenSerializer, 430 | customParamsSerializer, 431 | ], 432 | [Commands.MixerRotation]: [ 433 | splitCommandSerializer, 434 | channelLayerSerializer, 435 | splitCommandKeywordSerializer, 436 | mixerSimpleValueSerializer, 437 | mixerTweenSerializer, 438 | customParamsSerializer, 439 | ], 440 | [Commands.MixerPerspective]: [ 441 | splitCommandSerializer, 442 | channelLayerSerializer, 443 | splitCommandKeywordSerializer, 444 | optional((_, params) => 445 | [ 446 | params.topLeftX, 447 | params.topLeftY, 448 | params.topRightX, 449 | params.topRightY, 450 | params.bottomRightX, 451 | params.bottomRightY, 452 | params.bottomLeftX, 453 | params.bottomLeftY, 454 | ].join(' ') 455 | ), 456 | mixerTweenSerializer, 457 | customParamsSerializer, 458 | ], 459 | [Commands.MixerMipmap]: [ 460 | splitCommandSerializer, 461 | channelLayerSerializer, 462 | splitCommandKeywordSerializer, 463 | mixerSimpleValueSerializer, 464 | customParamsSerializer, 465 | ], 466 | [Commands.MixerVolume]: [ 467 | splitCommandSerializer, 468 | channelLayerSerializer, 469 | splitCommandKeywordSerializer, 470 | mixerSimpleValueSerializer, 471 | mixerTweenSerializer, 472 | customParamsSerializer, 473 | ], 474 | [Commands.MixerMastervolume]: [ 475 | splitCommandSerializer, 476 | channelSerializer, 477 | splitCommandKeywordSerializer, 478 | mixerSimpleValueSerializer, 479 | mixerTweenSerializer, 480 | customParamsSerializer, 481 | ], 482 | [Commands.MixerStraightAlphaOutput]: [ 483 | splitCommandSerializer, 484 | channelSerializer, 485 | splitCommandKeywordSerializer, 486 | mixerSimpleValueSerializer, 487 | customParamsSerializer, 488 | ], 489 | [Commands.MixerGrid]: [ 490 | splitCommandSerializer, 491 | channelSerializer, 492 | splitCommandKeywordSerializer, 493 | mixerSimpleValueSerializer, 494 | mixerTweenSerializer, 495 | customParamsSerializer, 496 | ], 497 | [Commands.MixerCommit]: [splitCommandSerializer, channelSerializer, splitCommandKeywordSerializer], 498 | [Commands.MixerClear]: [splitCommandSerializer, channelLayerOptSerializer, splitCommandKeywordSerializer], 499 | 500 | [Commands.ChannelGrid]: [commandNameSerializer], 501 | 502 | [Commands.ThumbnailList]: [commandNameSerializer, (_, { subDirectory }) => subDirectory ?? ''], 503 | [Commands.ThumbnailRetrieve]: [commandNameSerializer, (_, { filename }) => filename], 504 | [Commands.ThumbnailGenerate]: [commandNameSerializer, (_, { filename }) => filename], 505 | [Commands.ThumbnailGenerateAll]: [commandNameSerializer], 506 | 507 | [Commands.Cinf]: [commandNameSerializer, (_, { filename }) => filename], 508 | [Commands.Cls]: [commandNameSerializer, (_, { subDirectory }) => subDirectory ?? ''], 509 | [Commands.Fls]: [commandNameSerializer], 510 | [Commands.Tls]: [commandNameSerializer, (_, { subDirectory }) => subDirectory ?? ''], 511 | [Commands.Version]: [commandNameSerializer], 512 | [Commands.Info]: [commandNameSerializer], 513 | [Commands.InfoChannel]: [splitCommandSerializer, channelSerializer], 514 | [Commands.InfoLayer]: [splitCommandSerializer, channelLayerSerializer], 515 | [Commands.InfoTemplate]: [commandNameSerializer, (_, { template }) => template], 516 | [Commands.InfoConfig]: [commandNameSerializer], 517 | [Commands.InfoPaths]: [commandNameSerializer], 518 | [Commands.InfoSystem]: [commandNameSerializer], 519 | [Commands.InfoServer]: [commandNameSerializer], 520 | [Commands.InfoQueues]: [commandNameSerializer], 521 | [Commands.InfoThreads]: [commandNameSerializer], 522 | [Commands.InfoDelay]: [commandNameSerializer, channelLayerOptSerializer], 523 | [Commands.Diag]: [commandNameSerializer], 524 | [Commands.GlInfo]: [commandNameSerializer], 525 | [Commands.GlGc]: [commandNameSerializer], 526 | [Commands.Bye]: [commandNameSerializer], 527 | [Commands.Kill]: [commandNameSerializer], 528 | [Commands.Restart]: [commandNameSerializer], 529 | [Commands.Ping]: [commandNameSerializer], 530 | [Commands.Begin]: [commandNameSerializer], 531 | [Commands.Commit]: [commandNameSerializer], 532 | [Commands.Discard]: [commandNameSerializer], 533 | 534 | [Commands.Custom]: [customCommandSerializer, customParamsSerializer], 535 | } 536 | 537 | export const serializersV21: Readonly> = { 538 | ...serializers, 539 | [Commands.Loadbg]: [ 540 | commandNameSerializer, 541 | channelLayerSerializer, 542 | clipCommandSerializer, 543 | (_, { auto }) => (auto ? 'AUTO' : ''), 544 | producerV21Serializer, 545 | transitionOptSerializer, 546 | customParamsSerializer, 547 | ], 548 | [Commands.LoadbgDecklink]: [ 549 | splitCommandSerializer, 550 | channelLayerSerializer, 551 | decklinkCommandSerializer, 552 | producerV21Serializer, 553 | transitionOptSerializer, 554 | customParamsSerializer, 555 | ], 556 | [Commands.LoadbgHtml]: [ 557 | splitCommandSerializer, 558 | channelLayerSerializer, 559 | htmlCommandSerializerr, 560 | producerV21Serializer, 561 | transitionOptSerializer, 562 | customParamsSerializer, 563 | ], 564 | [Commands.LoadbgRoute]: [ 565 | splitCommandSerializer, 566 | channelLayerSerializer, 567 | routeCommandSerializer, 568 | producerV21Serializer, 569 | transitionOptSerializer, 570 | customParamsSerializer, 571 | ], 572 | [Commands.Load]: [ 573 | commandNameSerializer, 574 | channelLayerSerializer, 575 | clipCommandSerializer, 576 | producerV21Serializer, 577 | transitionOptSerializer, 578 | customParamsSerializer, 579 | ], 580 | [Commands.Play]: [ 581 | commandNameSerializer, 582 | channelLayerSerializer, 583 | clipCommandSerializer, 584 | producerV21Serializer, 585 | transitionOptSerializer, 586 | customParamsSerializer, 587 | ], 588 | [Commands.PlayDecklink]: [ 589 | splitCommandSerializer, 590 | channelLayerSerializer, 591 | decklinkCommandSerializer, 592 | producerV21Serializer, 593 | transitionOptSerializer, 594 | customParamsSerializer, 595 | ], 596 | [Commands.PlayHtml]: [ 597 | splitCommandSerializer, 598 | channelLayerSerializer, 599 | htmlCommandSerializerr, 600 | producerV21Serializer, 601 | transitionOptSerializer, 602 | customParamsSerializer, 603 | ], 604 | [Commands.PlayRoute]: [ 605 | splitCommandSerializer, 606 | channelLayerSerializer, 607 | routeCommandSerializer, 608 | producerV21Serializer, 609 | transitionOptSerializer, 610 | customParamsSerializer, 611 | ], 612 | } 613 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sofie-automation/code-standard-preset/ts/tsconfig.lib", 3 | "include": [ 4 | "src/**/*.ts" 5 | ], 6 | "exclude": [ 7 | "node_modules/**", 8 | "src/**/*spec.ts", 9 | "src/**/__tests__/*", 10 | "src/**/__mocks__/*" 11 | ], 12 | "compilerOptions": { 13 | "outDir": "./dist", 14 | "baseUrl": "./", 15 | "paths": { 16 | "*": [ 17 | "./node_modules/*" 18 | ], 19 | "casparcg-connection": [ 20 | "./src/index.ts" 21 | ] 22 | }, 23 | "types": [ 24 | "node" 25 | ] 26 | } 27 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "exclude": [ 4 | "node_modules/**" 5 | ], 6 | "compilerOptions": { 7 | "types": [ 8 | "jest", 9 | "node" 10 | ] 11 | } 12 | } --------------------------------------------------------------------------------