├── .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 | [](https://raw.githubusercontent.com/SuperFlyTV/casparcg-connection/master/LICENSE) [](https://www.npmjs.com/package/casparcg-connection)
4 |
5 | [](https://superflytv.github.io/casparcg-connection/) [](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