diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2870103e4..f36482fe5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -81,7 +81,7 @@ jobs: cache-dependency-path: "yarn.lock" - run: yarn install --immutable --inline-builds - - run: yarn test -- --coverage + - run: yarn test - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v4 @@ -119,7 +119,7 @@ jobs: - run: yarn prisma:migrateProd - - run: yarn test:integration -- --coverage + - run: yarn test:integration - name: Upload coverage reports to Codecov if: success() diff --git a/desktop/e2e/complete/desktopE2EUtils.ts b/desktop/e2e/complete/desktopE2EUtils.ts index 811544b5c..1ac1d84b8 100644 --- a/desktop/e2e/complete/desktopE2EUtils.ts +++ b/desktop/e2e/complete/desktopE2EUtils.ts @@ -45,7 +45,7 @@ export const test = base.extend<{ if (!testSettings.media) { testSettings.media = {}; } - testSettings.media.mediaPath ||= testMediaPath; + testSettings.media.mediaPath = testMediaPath; for (const [key, value] of Object.entries(testSettings)) { env[`__TEST_SETTINGS_${key.toUpperCase()}`] = JSON.stringify(value); } diff --git a/desktop/e2e/complete/vmix.spec.ts b/desktop/e2e/complete/vmix.spec.ts index 31d4f5561..6eda86880 100644 --- a/desktop/e2e/complete/vmix.spec.ts +++ b/desktop/e2e/complete/vmix.spec.ts @@ -7,7 +7,7 @@ import { server, } from "./serverAPI"; import type { CompleteShowType } from "../../src/common/types"; -import type VMixConnection from "../../src/main/vmix/vmix"; +import type VMixConnection from "@ystv/vmix"; import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; let testShow: CompleteShowType; diff --git a/desktop/e2e/standalone/vmix.spec.ts b/desktop/e2e/standalone/vmix.spec.ts new file mode 100644 index 000000000..5f597ac11 --- /dev/null +++ b/desktop/e2e/standalone/vmix.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from "./base"; + +test.use({ + enabledIntegrations: ["obs", "ontime", "vmix"], +}); + +test("load VTs into vMix", async ({ app: [app, page], testMediaPath }) => { + await page.getByRole("button", { name: "Select" }).click(); + + await page.getByText("Continuity", { exact: true }).click(); + await page.getByRole("menuitem", { name: "Test Rundown" }).click(); + + await page.getByRole("button", { name: "Download", exact: true }).click(); + + await app.evaluate((_, testMediaPath) => { + // TODO(BDGR-215): Replace with Mock VMix API test + globalThis.__MOCK_VMIX((when, vmix, It) => { + when(() => vmix.getFullState()) + .thenResolve({ + version: "26", + edition: "4k", + inputs: [], + }) + .once(); + when(() => vmix.addInput("VideoList", It.isString())).thenResolve("123"); + when(() => vmix.renameInput("123", It.isString())).thenResolve(); + when(() => vmix.clearList("123")).thenResolve(); + when(() => + vmix.getPartialState(`vmix/inputs/input[@shortTitle="VTs"]`), + ).thenResolve({ ["@_state"]: "Paused" }); + when(() => vmix.addInputToList("123", It.isString())).thenResolve(); + when(() => vmix.getFullState()).thenResolve({ + version: "26", + edition: "4k", + inputs: [ + { + key: "123", + number: 1, + type: "VideoList", + title: "VTs - smpte_bars_15s.mp4", + shortTitle: "VTs", + state: "Paused", + position: 0, + duration: 0, + loop: false, + selectedIndex: 1, + items: [ + { + source: testMediaPath, + selected: true, + }, + ], + }, + ], + }); + }); + }, `${testMediaPath}/smpte_bars_15s (#1).mp4`); + + await expect(page.getByText("Ready for load", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Load All VTs" }).click(); + + await expect(page.getByText("Good to go!")).toBeVisible(); +}); diff --git a/desktop/electron.vite.config.mjs b/desktop/electron.vite.config.mjs index 1bde2c2ad..5570a88bf 100644 --- a/desktop/electron.vite.config.mjs +++ b/desktop/electron.vite.config.mjs @@ -25,13 +25,14 @@ const visualizeBundle = process.argv.includes("--visualize-bundle"); * can import (and forbid importing @badger/prisma/client). * However, zod-prisma-types still needs to import the actual Prisma client in one place, * transformJsonNull.ts, so that it can access Prisma.JsonNull/Prisma.DbNull. - * + * * To fix this, we stub out this one import, which thereby ensures the Prisma client runtime * never gets bundled in. This is safe to do, because we will never need to interact with * Prisma.{Db,Json}Null in Desktop. */ -const jsonNullStub = "export const transformJsonNull = v => v; export default transformJsonNull;" -const jsonNullStubPlaceholder = "\0ignore_prisma_placeholder" +const jsonNullStub = + "export const transformJsonNull = v => v; export default transformJsonNull;"; +const jsonNullStubPlaceholder = "\0ignore_prisma_placeholder"; /** @type {import("vite").Plugin} */ const IgnorePrismaJsonNullPlugin = { name: "ignorePrismaJsonNull", @@ -42,12 +43,14 @@ const IgnorePrismaJsonNullPlugin = { return null; }, load(name) { - return name === jsonNullStubPlaceholder ? { - code: jsonNullStub, - moduleSideEffects: false, - } : null; + return name === jsonNullStubPlaceholder + ? { + code: jsonNullStub, + moduleSideEffects: false, + } + : null; }, - enforce: "pre" + enforce: "pre", }; const base = defineConfig({ @@ -107,49 +110,58 @@ const base = defineConfig({ * @type {import('electron-vite').UserConfig} */ const config = { - main: mergeConfig(base, defineConfig({ - plugins: [ - IgnorePrismaJsonNullPlugin, - commonjs(), - visualizeBundle && - visualizer({ - filename: "bundle-main.html", - }), - ].filter(Boolean), - resolve: { - conditions: ["node"], - browserField: false, - }, - build: { - sourcemap: true, - } - })), - renderer: mergeConfig(base, defineConfig({ - plugins: [ - visualizeBundle && - visualizer({ - filename: "bundle-renderer.html", - }), - ].filter(Boolean), - build: { - rollupOptions: { - input: "./src/renderer/index.html", + main: mergeConfig( + base, + defineConfig({ + plugins: [ + IgnorePrismaJsonNullPlugin, + commonjs(), + visualizeBundle && + visualizer({ + filename: "bundle-main.html", + }), + ].filter(Boolean), + resolve: { + conditions: ["node", "badger-internal"], + browserField: false, }, - }, - })), - preload: mergeConfig(base, defineConfig({ - plugins: [ - visualizeBundle && - visualizer({ - filename: "bundle-preload.html", - }), - ].filter(Boolean), - build: { - lib: { - entry: "./src/common/preload.ts", + build: { + sourcemap: true, }, - }, - })), + }), + ), + renderer: mergeConfig( + base, + defineConfig({ + plugins: [ + visualizeBundle && + visualizer({ + filename: "bundle-renderer.html", + }), + ].filter(Boolean), + build: { + rollupOptions: { + input: "./src/renderer/index.html", + }, + }, + }), + ), + preload: mergeConfig( + base, + defineConfig({ + plugins: [ + visualizeBundle && + visualizer({ + filename: "bundle-preload.html", + }), + ].filter(Boolean), + build: { + lib: { + entry: "./src/common/preload.ts", + }, + }, + }), + ), }; export default config; diff --git a/desktop/package.json b/desktop/package.json index c0f024398..e526e7c91 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -45,6 +45,7 @@ "@types/react-dom": "^18.2.14", "@types/uuid": "^9.0.2", "@types/which": "^3.0.0", + "@ystv/vmix": "workspace:*", "badger-server": "workspace:*", "bufferutil": "^4.0.7", "classnames": "^2.3.2", diff --git a/desktop/src/main/vmix/vMix.mock.ts b/desktop/src/main/vmix/vMix.mock.ts index 65f2a29d3..0875444c7 100644 --- a/desktop/src/main/vmix/vMix.mock.ts +++ b/desktop/src/main/vmix/vMix.mock.ts @@ -1,6 +1,6 @@ import { getLogger } from "loglevel"; import { mock, when, It, verify, reset } from "strong-mock"; -import VMixConnection from "./vmix"; +import VMixConnection from "@ystv/vmix"; const logger = getLogger("vMix.mock"); diff --git a/desktop/src/main/vmix/vmix.test.integration.ts b/desktop/src/main/vmix/vmix.test.integration.ts deleted file mode 100644 index 6d02e18f7..000000000 --- a/desktop/src/main/vmix/vmix.test.integration.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { beforeAll, expect, test } from "vitest"; -import VMixConnection from "./vmix"; -import { integrate } from "@badger/testing"; - -integrate("VMixConnection integration", () => { - let vmix: VMixConnection; - beforeAll(async () => { - vmix = await VMixConnection.connect(); - // eslint-disable-next-line no-console - console.log("Setup done"); - }); - test("getFullState", async () => { - expect.assertions(1); - // The current state of the vMix instance is unpredictable, so all we can do is test that it doesn't throw - // (i.e. it can successfully parse the state, whatever it may be). - // Doing this await rather than expect().resolves.toHaveProperty() means we get better error messages. - const r = await vmix.getFullState(); - expect(r).toHaveProperty("edition"); - }); -}); diff --git a/desktop/src/main/vmix/vmix.ts b/desktop/src/main/vmix/vmix.ts index 40047be14..d6ba9c69a 100644 --- a/desktop/src/main/vmix/vmix.ts +++ b/desktop/src/main/vmix/vmix.ts @@ -1,401 +1,9 @@ -import { Socket, connect } from "node:net"; -import invariant from "../../common/invariant"; -import { XMLParser } from "fast-xml-parser"; -import { - AudioFileInput, - BaseInput, - InputObject, - ListInput, - InputType, - VMixState, - VideoInput, -} from "./vmixTypes"; -import { - AudioFileObject, - VMixRawXMLSchema, - VideoListObject, - VideoObject, -} from "./vmixTypesRaw"; -import { z } from "zod"; -import * as qs from "qs"; -import { v4 as uuidV4 } from "uuid"; -import { getLogger } from "../base/logging"; +import { VMixConnection } from "@ystv/vmix"; import { getMockVMix } from "./vMix.mock"; +import { getLogger } from "../base/logging"; const logger = getLogger("vmix"); -type VMixCommand = - | "TALLY" - | "FUNCTION" - | "ACTS" - | "XML" - | "XMLTEXT" - | "SUBSCRIBE" - | "UNSUBSCRIBE" - | "QUIT"; - -interface ReqQueueItem { - command: VMixCommand; - args: string[]; - resolve: (msgAndData: [string, string]) => void; - reject: (err: Error) => void; -} - -/** - * A connection to a vMix instance using the TCP API. - * - * @example - * const vmix = await VMixConnection.connect(); - */ -export default class VMixConnection { - private sock!: Socket; - - // See the comment on doNextRequest for an explanation of this. - private replyAwaiting: Map< - VMixCommand, - { - resolve: (msgAndData: [string, string]) => void; - reject: (err: Error) => void; - } - >; - private requestQueue: Array = []; - - private buffer: string = ""; - private xmlParser = new XMLParser({ - ignoreAttributes: false, - attributeNamePrefix: "@_", - allowBooleanAttributes: true, - }); - private constructor( - // This is only used for display purposes, the actual connection is established in `connect()`, - // before this constructor is called. - public readonly host: string, - public readonly port: number, - ) { - this.replyAwaiting = new Map(); - } - - public static async connect(host: string = "localhost", port: number = 8099) { - const sock = connect({ - host, - port, - }); - sock.setEncoding("utf-8"); - await new Promise((resolve, reject) => { - sock.once("connect", () => { - sock.off("error", reject); - resolve(); - }); - sock.on("error", reject); - }); - const vmix = new VMixConnection(host, port); - vmix.sock = sock; - sock.on("data", vmix.onData.bind(vmix)); - sock.on("close", vmix.onClose.bind(vmix)); - sock.on("error", vmix.onError.bind(vmix)); - return vmix; - } - - public async addInput(type: InputType, filePath: string) { - // vMix doesn't return the input key, but you can pass it one to use. - const id = uuidV4(); - await this.doFunction("AddInput", { - Value: type + "|" + filePath, - Input: id, - }); - // This may error if the input is already added or the GUID collides (very unlikely) - return id; - } - - public async renameInput(inputKey: string, newName: string) { - await this.doFunction("SetInputName", { Input: inputKey, Value: newName }); - } - - public async addInputToList(listSource: string, path: string) { - await this.doFunction("ListAdd", { Input: listSource, Value: path }); - } - - /** - * - * @param listSource the name, index, or ID of the list source - * @param index the index of the item to remove - NB: this is 1-based! - */ - public async removeItemFromList(listSource: string, index: number) { - await this.doFunction("ListRemove", { - Input: listSource, - Value: index.toString(), - }); - } - - public async clearList(listSource: string) { - await this.doFunction("ListRemoveAll", { Input: listSource }); - } - - public async setListAutoNext(listSource: string, autoNext: boolean) { - if (autoNext) { - await this.doFunction("AutoPlayNextOn", { Input: listSource }); - } else { - await this.doFunction("AutoPlayNextOff", { Input: listSource }); - } - } - - // Function reference: https://www.vmix.com/help26/ShortcutFunctionReference.html - private async doFunction(fn: string, params: Record) { - return this.send("FUNCTION", fn, qs.stringify(params)); - } - - public async getPartialState( - xpath: string, - ): Promise> { - const [_, result] = await this.send("XMLTEXT", xpath); - return this.xmlParser.parse(result); - } - - public async getFullStateRaw(): Promise { - const [_, result] = await this.send("XML"); - return this.xmlParser.parse(result); - } - - public async getFullState(): Promise { - const data = await this.getFullStateRaw(); - const rawParseRes = VMixRawXMLSchema.safeParse(data); - let raw: z.infer; - if (rawParseRes.success) { - raw = rawParseRes.data; - } else if (import.meta.env.MODE === "test") { - // In tests we want this to fail immediately so that we notice the changes - logger.error(rawParseRes.error); - throw rawParseRes.error; - } else { - // But in production we want to keep trying if we can, as it's possible the - // changes are minor enough to allow this to continue working. - // TODO: track divergences centrally - logger.warn( - "Parsing raw vMix schema failed. Possibly the vMix is a version we don't know. Will try to proceed, but things may break!", - ); - logger.trace("Raw data:", JSON.stringify(data)); - // DIRTY HACK - assume that the data matches the schema to extract what we can - raw = data as z.infer; - } - const res: VMixState = { - version: raw.vmix.version, - edition: raw.vmix.edition, - preset: raw.vmix.preset, - inputs: [], - }; - for (const input of raw.vmix.inputs.input) { - // eslint is wrong - // eslint-disable-next-line prefer-const - let v: BaseInput = { - key: input["@_key"], - number: input["@_number"], - type: input["@_type"] as InputType, - title: input["@_title"], - shortTitle: input["@_shortTitle"], - loop: input["@_loop"] === "True", - state: input["@_state"], - duration: input["@_duration"], - position: input["@_position"], - }; - switch (input["@_type"]) { - case "Colour": - case "Mix": - case "Image": - case "Blank": - break; - case "Video": - case "AudioFile": { - const r = input as VideoObject | AudioFileObject; - (v as unknown as VideoInput | AudioFileInput) = { - ...v, - type: r["@_type"] as "Video" | "AudioFile", - volume: r["@_volume"], - balance: r["@_balance"], - solo: r["@_solo"] === "True", - muted: r["@_muted"] === "True", - audioBusses: r["@_audiobusses"], - }; - break; - } - case "VideoList": { - const r = input as VideoListObject; - (v as unknown as ListInput) = { - ...v, - type: r["@_type"], - selectedIndex: r["@_selectedIndex"] - 1 /* 1-based index */, - items: [], - }; - if (Array.isArray(r.list?.item)) { - (v as ListInput).items = r.list!.item.map((item) => { - if (typeof item === "string") { - return { - source: item, - selected: false, - }; - } - return { - source: item["#text"], - selected: item["@_selected"] === "true", - }; - }); - } else if (r.list) { - (v as ListInput).items.push({ - source: r.list.item["#text"], - selected: r.list.item["@_selected"] === "true", - }); - } - break; - } - default: - logger.warn(`Unrecognised input type '${input["@_type"]}'`); - if (import.meta.env.MODE === "test") { - logger.debug(input); - throw new Error(`Unrecognised input type ${input["@_type"]}`); - } - continue; - } - res.inputs.push(v as InputObject); - } - return res; - } - - private async send(command: VMixCommand, ...args: string[]) { - const req: ReqQueueItem = { - command, - args, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - resolve: null as any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - reject: null as any, - }; - const reply = new Promise<[string, string]>((resolve, reject) => { - req.resolve = resolve; - req.reject = reject; - }); - this.requestQueue.push(req); - logger.debug( - `Queued request ${command}; queue is now ${this.requestQueue.length} deep`, - ); - await this.doNextRequest(); - return reply; - } - - // When replying to a TCP request, vMix includes the name of the command - // in its response, but nothing else that would allow us to identify the sender - // (unlike e.g. the OBS WebSocket API, where requests can have an ID). - // Therefore, we only allow one request per command type to be in flight at - // a time. - // - // The replyAwaiting map tracks whether we have sent a request for a given command, - // and therefore we can't send another until we've received a response to the first. - // If a request of type X is added to the queue when there is already one in flight, - // doNextRequest will skip processing it. Then, once a compelte response is received - // by onData(), it will call doNextRequest again to process the next request. - // - // This implementation is a bit simplistic - if the first request in the queue - // is blocked we won't process any others, even if they would not be blocked. - // However, this is unlikely to be a problem in practice. - // - // TODO: With that in mind, this could be simplified even further - instead of a - // replyAwaiting map, we could just have a single boolean flag indicating whether - // a request is in flight. - private async doNextRequest() { - const req = this.requestQueue[0]; - if (!req) { - return; - } - if (this.replyAwaiting.has(req.command)) { - // Wait until the next run - logger.debug(`Request ${req.command} is blocked`); - return; - } - this.requestQueue.shift(); - this.replyAwaiting.set(req!.command, req!); - await new Promise((resolve, reject) => { - this.sock.write( - req.command + - (req.args.length > 0 ? " " + req.args.join(" ") : "") + - "\r\n", - "utf-8", - (err) => (err ? reject(err) : resolve()), - ); - }); - logger.debug(`Sent request ${req.command}`); - } - - private onData(data: Buffer) { - this.buffer += data.toString(); - // Replies will be in one of the following forms: - // - //MYCOMMAND OK This is the response to MYCOMMAND\r\n - // - //MYCOMMAND ER This is an error message in response to MYCOMMAND\r\n - // - //MYCOMMAND 28\r\n - //This is optional binary data - // - //MYCOMMAND 28 This is a message in addition to the binary data\r\n - //This is optional binary data - // - // The 28 in the last two examples is the length of the binary data. - // NB: binary data doesn't necessarily end with \r\n! - - if (!this.buffer.includes("\r\n")) { - return; - } - const firstLine = this.buffer.slice(0, this.buffer.indexOf("\r\n")); - const [command, status, ...rest] = firstLine.split(" "); - logger.debug(`Received response to ${command}`); - const reply = this.replyAwaiting.get(command as VMixCommand); - if (status === "OK") { - reply?.resolve([rest.join(" "), ""]); - this.replyAwaiting.delete(command as VMixCommand); - this.buffer = ""; - process.nextTick(this.doNextRequest.bind(this)); - return; - } - if (status === "ER") { - reply?.reject(new Error(rest.join(" "))); - this.replyAwaiting.delete(command as VMixCommand); - this.buffer = ""; - process.nextTick(this.doNextRequest.bind(this)); - return; - } - // This is a binary response and "status" is actually its length - invariant(status.match(/^\d+$/), "Invalid status: " + status); - const payloadLength = parseInt(status, 10); - // +2 for the \r\n - if (this.buffer.length < payloadLength + firstLine.length + 2) { - // still need more data - logger.debug( - `Expecting ${payloadLength} bytes of binary data but only got ${this.buffer.length - firstLine.length - 2}`, - ); - return; - } - const payload = this.buffer.slice( - firstLine.length + 2, - payloadLength + firstLine.length + 2, - ); - reply?.resolve([rest.join(" "), payload.trim()]); - this.replyAwaiting.delete(command as VMixCommand); - process.nextTick(this.doNextRequest.bind(this)); - this.buffer = ""; - } - - private onClose() { - logger.warn("VMix connection closed"); - this.replyAwaiting.forEach((req) => req.reject(new Error("Socket closed"))); - this.replyAwaiting.clear(); - this.requestQueue.forEach((req) => req.reject(new Error("Socket closed"))); - this.requestQueue = []; - this.onError(new Error("Socket closed")); - } - - private onError(err: Error) { - logger.error("VMix connection error", err); - } -} - export let conn: VMixConnection | null; export async function tryCreateVMixConnection( diff --git a/desktop/src/main/vmix/vmixHelpers.ts b/desktop/src/main/vmix/vmixHelpers.ts index b665d375a..db6c4c3bd 100644 --- a/desktop/src/main/vmix/vmixHelpers.ts +++ b/desktop/src/main/vmix/vmixHelpers.ts @@ -2,7 +2,7 @@ import invariant from "../../common/invariant"; import { getLogger } from "../base/logging"; import { getLocalMedia } from "../media/mediaManagement"; import { getVMixConnection } from "./vmix"; -import { InputType, ListInput, ListItem } from "./vmixTypes"; +import { InputType, ListInput, ListItem } from "@ystv/vmix"; import type { Asset, Media } from "@badger/prisma/types"; const logger = getLogger("vmixHelpers"); diff --git a/desktop/src/main/vmix/vmixTypesRaw.ts b/desktop/src/main/vmix/vmixTypesRaw.ts deleted file mode 100644 index c6cbfdd12..000000000 --- a/desktop/src/main/vmix/vmixTypesRaw.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { z } from "zod"; - -/* - * This file is an implementation detail of VMixConnection.getFullState(). - * Unless you are parsing the vMix XML, you probably want vmixTypes.ts instead. - */ - -const InputType = z - .enum([ - "Mix", - "Colour", - "VideoList", - "Video", - "Image", - "AudioFile", - "Blank", - ] as const) - .or(z.string()); - -/** - * vMix backslash-escapes its strings, even though backslash is valid in XML. This removes that layer of escaping. - */ -const deEscapedString = z.string().transform((v) => v.replace(/\\\\/g, "\\")); - -export const VideoObject = z.object({ - "#text": z.string(), - "@_key": z.string(), - "@_number": z.coerce.number(), - "@_type": z.literal("Video"), - "@_title": z.string(), - "@_shortTitle": z.string(), - "@_state": z.string(), - "@_position": z.coerce.number(), - "@_duration": z.coerce.number(), - "@_loop": z.string(), - "@_muted": z.string(), - "@_volume": z.coerce.number(), - "@_balance": z.coerce.number(), - "@_solo": z.string(), - "@_audiobusses": z.string(), - "@_meterF1": z.string(), - "@_meterF2": z.string(), -}); -export type VideoObject = z.infer; - -const ListItemObj = z.object({ - "#text": deEscapedString, - "@_selected": z.string(), -}); -const ListItemType = z.union([ListItemObj, deEscapedString]); - -export const VideoListObject = z.object({ - list: z - .object({ - item: ListItemObj.or(z.array(ListItemType)), - }) - .optional(), - "#text": z.string(), - "@_key": z.string(), - "@_number": z.coerce.number(), - "@_type": z.literal("VideoList"), - "@_title": z.string(), - "@_shortTitle": z.string(), - "@_state": z.string(), - "@_position": z.coerce.number(), - "@_duration": z.coerce.number(), - "@_loop": z.string(), - "@_muted": z.string(), - "@_volume": z.string(), - "@_balance": z.string(), - "@_solo": z.string(), - "@_audiobusses": z.string(), - "@_meterF1": z.string(), - "@_meterF2": z.string(), - "@_gainDb": z.coerce.number(), - "@_selectedIndex": z.coerce.number(), -}); -export type VideoListObject = z.infer; - -export const AudioFileObject = z.object({ - "#text": z.string(), - "@_key": z.string(), - "@_number": z.coerce.number(), - "@_type": z.literal("AudioFile"), - "@_title": z.string(), - "@_shortTitle": z.string(), - "@_state": z.string(), - "@_position": z.coerce.number(), - "@_duration": z.coerce.number(), - "@_loop": z.string(), - "@_muted": z.string(), - "@_volume": z.coerce.number(), - "@_balance": z.coerce.number(), - "@_solo": z.string(), - "@_audiobusses": z.string(), - "@_meterF1": z.string(), - "@_meterF2": z.string(), - "@_gainDb": z.coerce.number(), -}); -export type AudioFileObject = z.infer; - -export const BlankObject = z.object({ - "#text": z.string(), - "@_key": z.string(), - "@_number": z.coerce.number(), - "@_type": z.literal("Blank"), - "@_title": z.string(), - "@_shortTitle": z.string(), - "@_state": z.string(), - "@_position": z.coerce.number(), - "@_duration": z.coerce.number(), - "@_loop": z.string(), -}); -export type BlankObject = z.infer; - -export const VMixRawXMLSchema = z - .object({ - vmix: z.object({ - version: z.string(), - edition: z.string(), - preset: deEscapedString.optional(), - inputs: z.object({ - input: z.array( - z.union([ - VideoObject, - VideoListObject, - AudioFileObject, - BlankObject, - // This one needs to be last, so that the more specific types match first (based on the z.literal on the @_type) - z.object({ - "#text": z.string(), - "@_key": z.string(), - "@_number": z.coerce.number(), - "@_type": InputType, - "@_title": z.string(), - "@_shortTitle": z.string(), - "@_state": z.string(), - "@_position": z.coerce.number(), - "@_duration": z.coerce.number(), - "@_loop": z.string(), - }), - ]), - ), - }), - overlays: z.object({ - overlay: z.array(z.object({ "@_number": z.coerce.number() })), - }), - preview: z.number(), - active: z.number(), - fadeToBlack: z.string(), - transitions: z.object({ - transition: z.array( - z.object({ - "@_number": z.coerce.number(), - "@_effect": z.string(), - "@_duration": z.coerce.number(), - }), - ), - }), - recording: z.string(), - external: z.string(), - streaming: z.string(), - playList: z.string(), - multiCorder: z.string(), - fullscreen: z.string(), - mix: z - .object({ - preview: z.number(), - active: z.number(), - "@_number": z.coerce.number(), - }) - .optional(), - audio: z.object({ - master: z.object({ - "@_volume": z.string(), - "@_muted": z.string(), - "@_meterF1": z.string(), - "@_meterF2": z.string(), - "@_headphonesVolume": z.string(), - }), - }), - dynamic: z.object({ - input1: z.string(), - input2: z.string(), - input3: z.string(), - input4: z.string(), - value1: z.string(), - value2: z.string(), - value3: z.string(), - value4: z.string(), - }), - }), - }) - .passthrough(); diff --git a/desktop/src/renderer/screens/vMix.tsx b/desktop/src/renderer/screens/vMix.tsx index b86ef7156..fcdab2788 100644 --- a/desktop/src/renderer/screens/vMix.tsx +++ b/desktop/src/renderer/screens/vMix.tsx @@ -9,7 +9,7 @@ import { CompleteRundownModel, } from "@badger/prisma/utilityTypes"; import { z } from "zod"; -import { ListInput } from "../../main/vmix/vmixTypes"; +import { ListInput } from "@ystv/vmix"; import invariant from "../../common/invariant"; import { Alert } from "@badger/components/alert"; import { Progress } from "@badger/components/progress"; diff --git a/desktop/tsconfig.json b/desktop/tsconfig.json index 8e5c9a225..7c6cad3b9 100644 --- a/desktop/tsconfig.json +++ b/desktop/tsconfig.json @@ -5,7 +5,9 @@ "allowImportingTsExtensions": true, "downlevelIteration": true, "paths": { - "@/*": ["../server/*"], + "@/*": ["../server/*"] }, - }, + // https://colinhacks.com/essays/live-types-typescript-monorepo + "customConditions": ["badger-internal"] + } } diff --git a/package.json b/package.json index 2eacb2792..bf7089e1b 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,8 @@ "scripts": { "lint": "yarn workspaces foreach -Atp --exclude badger-root-workspace run lint", "prettify": "yarn workspaces foreach -Atp --exclude badger-root-workspace run prettify", - "test": "yarn workspaces foreach -Atp --exclude badger-root-workspace run test", - "test:integration": "yarn workspaces foreach -At --exclude badger-root-workspace run test:integration", + "test": "yarn workspaces foreach -Atp --exclude badger-root-workspace run test --coverage", + "test:integration": "yarn workspaces foreach -At --exclude badger-root-workspace run test:integration --coverage", "typecheck": "yarn workspaces foreach -Atp --include 'badger-{desktop,jobrunner,server}' run tsc --noEmit", "prepare": "husky" }, diff --git a/utility/vmix/.gitignore b/utility/vmix/.gitignore new file mode 100644 index 000000000..1eae0cf67 --- /dev/null +++ b/utility/vmix/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/utility/vmix/README.md b/utility/vmix/README.md new file mode 100644 index 000000000..de0cd8bb2 --- /dev/null +++ b/utility/vmix/README.md @@ -0,0 +1 @@ +# vmix diff --git a/utility/vmix/jest.config.js b/utility/vmix/jest.config.js new file mode 100644 index 000000000..d9fb32dea --- /dev/null +++ b/utility/vmix/jest.config.js @@ -0,0 +1,8 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} **/ +module.exports = { + testEnvironment: "node", + transform: { + "^.+.tsx?$": ["ts-jest", {}], + }, + coverageProvider: "v8", +}; diff --git a/utility/vmix/package.json b/utility/vmix/package.json new file mode 100644 index 000000000..85352f0b8 --- /dev/null +++ b/utility/vmix/package.json @@ -0,0 +1,54 @@ +{ + "name": "@ystv/vmix", + "description": "Strongly typed vMix API client and testing utilities", + "author": "Marks Polakovs ", + "packageManager": "yarn@4.3.1", + "exports": { + "import": { + "badger-internal": "./src/index.ts", + "default": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "require": { + "badger-internal": "./src/index.js", + "default": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "test": "jest", + "typecheck": "tsc --noEmit", + "build": "rollup -c rollup.config.mjs" + }, + "dependencies": { + "qs": "^6.13.0", + "uuid": "^10.0.0", + "xml-js": "^1.6.11", + "zod": "^3.23.8" + }, + "devDependencies": { + "@jest/globals": "^29.7.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@tsconfig/node-lts": "^20.1.3", + "@types/jest": "^29.5.12", + "@types/node": "^22.1.0", + "@xmldom/xmldom": "^0.8.10", + "esbuild": "^0.23.1", + "jest": "^29.7.0", + "rollup": "^4.21.0", + "rollup-plugin-esbuild": "^6.1.1", + "rollup-plugin-node-externals": "^7.1.3", + "ts-jest": "^29.2.4", + "typescript": "^5.5.4", + "xpath": "^0.0.34" + }, + "peerDependencies": { + "immer": "*" + }, + "peerDependenciesMeta": { + "immer": { + "optional": true + } + } +} diff --git a/utility/vmix/rollup.config.mjs b/utility/vmix/rollup.config.mjs new file mode 100644 index 000000000..67d1f82f6 --- /dev/null +++ b/utility/vmix/rollup.config.mjs @@ -0,0 +1,13 @@ +import typescript from "@rollup/plugin-typescript"; +import nodeResolve from "@rollup/plugin-node-resolve"; +import nodeExternals from "rollup-plugin-node-externals"; +import { minify } from "rollup-plugin-esbuild"; + +export default { + input: "src/index.ts", + output: { + dir: "dist", + format: "esm", + }, + plugins: [typescript(), nodeResolve(), nodeExternals(), minify()], +}; diff --git a/utility/vmix/src/MockVMixAPI.test.ts b/utility/vmix/src/MockVMixAPI.test.ts new file mode 100644 index 000000000..4a886fc45 --- /dev/null +++ b/utility/vmix/src/MockVMixAPI.test.ts @@ -0,0 +1,42 @@ +import MockVMixAPI from "./MockVMixAPI"; +import VMixConnection from "./vmix"; + +describe("VMixConnection w/ MockVMixAPI", () => { + test("it works", async () => { + const mvx = await MockVMixAPI.create(); + const vmix = await VMixConnection.connect(mvx.host, mvx.port); + await mvx.waitForConnection; + + mvx.ctx.handleFunction("AddInput", (params, respond) => { + const { Value, Input } = params as { Value: string; Input: string }; + const [type, path] = Value.split("|"); + mvx.ctx.setState((draft) => { + draft.vmix.inputs.input.push({ + _text: path, + _attributes: { + key: Input, + type, + audiobusses: "", + balance: 0, + muted: "False", + solo: "False", + duration: 0, + loop: "False", + meterF1: "0", + meterF2: "0", + number: 1, + position: 0, + shortTitle: path, + state: "Paused", + title: path, + volume: 100, + }, + }); + }); + respond({ message: Input }); + }); + await vmix.addInput("Video", "test"); + await vmix.close(); + await mvx.close(); + }, 10_000); +}); diff --git a/utility/vmix/src/MockVMixAPI.ts b/utility/vmix/src/MockVMixAPI.ts new file mode 100644 index 000000000..367b3cc8b --- /dev/null +++ b/utility/vmix/src/MockVMixAPI.ts @@ -0,0 +1,289 @@ +import { createServer, Server, Socket } from "net"; +import { promisify } from "node:util"; +import invariant from "./invariant"; +import { z } from "zod"; +import { VMixRawXMLSchema } from "./vmixTypesRaw"; +import { MutableVMixState, VMixState } from "./vmixState"; +import xpath from "xpath"; +import DOM from "@xmldom/xmldom"; +import { ParsedQs, parse as parseQS } from "qs"; +import { Draft } from "immer"; + +type RawState = z.infer; + +const text = (t: string) => ({ _text: t }); + +const defaultState: RawState = { + vmix: { + version: text("26"), + edition: text("4K"), + inputs: { + input: [], + }, + active: text(""), + audio: { + master: { + _attributes: { + muted: "False", + meterF1: "0.000000", + meterF2: "0.000000", + headphonesVolume: "0.000000", + volume: "0.000000", + }, + }, + }, + dynamic: { + input1: {}, + input2: {}, + input3: {}, + input4: {}, + value1: {}, + value2: {}, + value3: {}, + value4: {}, + }, + external: text(""), + fadeToBlack: text("False"), + fullscreen: text("False"), + multiCorder: text("False"), + overlays: { overlay: [] }, + playList: text(""), + preview: text(""), + recording: text("False"), + streaming: text("False"), + transitions: { transition: [] }, + }, +}; + +type VMixFnHandler = ( + args: ParsedQs, + respond: (res: { + message?: string; + binary?: string | Buffer; + error?: boolean; + }) => void, +) => void; + +class MockVMixContext { + constructor( + private readonly socket: Socket, + initialState?: Partial, + ) { + this.state = new MutableVMixState( + initialState + ? { vmix: { ...initialState.vmix, ...defaultState.vmix } } + : defaultState, + ); + } + + private state: MutableVMixState; + private functionHandlers = new Map>(); + private fallbackHandlers = new Map(); + private functionCallbacks = new Map void>>(); + private closeCallbacks = new Set<(reason: Error) => void>(); + + public setState(writer: (draft: Draft) => void) { + this.state.update(writer); + } + + public handleFunction(fn: string, handler: VMixFnHandler) { + const handlers = this.functionHandlers.get(fn); + if (handlers) { + handlers.push(handler); + } else { + this.functionHandlers.set(fn, [handler]); + } + } + + public functionFallback(fn: string, handler: VMixFnHandler) { + this.fallbackHandlers.set(fn, handler); + } + + public waitForFunction(fn: string): Promise { + return new Promise((resolve, reject) => { + const handler = () => { + this.closeCallbacks.delete(reject); + resolve(); + }; + if (this.functionCallbacks.has(fn)) { + this.functionCallbacks.get(fn)!.push(handler); + } else { + this.functionCallbacks.set(fn, [handler]); + } + this.closeCallbacks.add(reject); + }); + } + + /** This method is an implementation detail and should not be used outside MockVMixAPI. */ + public async _handleRequest(buffer: string) { + const reqNameIdx = buffer.indexOf(" "); + const request = buffer.slice(0, reqNameIdx); + switch (request) { + case "XML": { + const xml = this.state.xml; + this.socket.write(`XML ${xml.length}\r\n${xml}`); + return; + } + case "XMLTEXT": { + const xml = this.state.xml; + const dom = new DOM.DOMParser().parseFromString(xml); + const nodes = xpath.select(buffer.slice(reqNameIdx + 1), dom); + if (!nodes) { + this.socket.write(`XMLTEXT ER No nodes found\r\n`); + return; + } + if (Array.isArray(nodes)) { + this.socket.write( + "XMLTEXT ER More than one result (not supported by MockVMixAPI)\r\n", + ); + return; + } + this.socket.write(`XMLTEXT OK ${nodes.toString()}\r\n`); + return; + } + case "FUNCTION": { + const fnAndArgs = buffer.slice(reqNameIdx + 1); + const fn = fnAndArgs.slice(0, fnAndArgs.indexOf(" ")); + const args = parseQS(fnAndArgs.slice(fn.length + 1)); + const handlers = this.functionHandlers.get(fn); + if (handlers) { + const handler = handlers.shift(); + if (handler) { + this._doFnCall(fn, args, handler); + return; + } + } + const fallback = this.fallbackHandlers.get(fn); + if (fallback) { + this._doFnCall(fn, args, fallback); + return; + } + + this.socket.write(`FUNCTION ER No handler for ${fn}\r\n`); + return; + } + default: + this.socket.write(`${request} ER Unknown command\r\n`); + } + } + + private _doFnCall(fn: string, args: ParsedQs, handler: VMixFnHandler) { + handler(args, (res) => { + if (res.error) { + this.socket.write(`FUNCTION ER ${res.message}\r\n`); + return; + } + if (res.binary) { + if (res.message) { + this.socket.write(`FUNCTION ${res.binary.length} ${res.message}\r\n`); + this.socket.write(res.binary); + } else { + this.socket.write(`FUNCTION ${res.binary.length}\r\n`); + this.socket.write(res.binary); + } + return; + } + this.socket.write(`FUNCTION OK ${res.message}\r\n`); + }); + } + + public _handleClose() { + for (const cb of this.closeCallbacks) { + cb(new Error("VMix connection closed")); + } + } +} + +export default class MockVMixAPI { + private constructor( + private readonly _server: Server, + public readonly host: string, + public readonly port: number, + ) {} + + private _ctx: MockVMixContext | null = null; + public get ctx(): MockVMixContext { + invariant(this._ctx, "MockVMixAPI not initialized"); + return this._ctx; + } + + private _ready!: () => void; + public readonly waitForConnection = new Promise((resolve) => { + this._ready = resolve; + }); + + private _sockets = new Set(); + + static async create( + initialState?: Partial, + executor?: (ctx: MockVMixContext) => Promise, + ) { + let instance: MockVMixAPI; + const server = createServer(async (socket) => { + // https://www.vmix.com/help27/TCPAPI.html + socket.setEncoding("utf-8"); + instance._sockets.add(socket); + + const context = new MockVMixContext(socket, initialState); + + let buffer = ""; + socket.on("data", (data: string) => { + const eod = data.indexOf("\r\n"); + if (eod === -1) { + buffer += data; + return; + } + buffer += data.slice(0, eod); + context._handleRequest(buffer); + }); + + socket.on("close", () => { + context._handleClose(); + instance._sockets.delete(socket); + }); + + socket.on("error", (e) => { + console.error("VMix socket error", e); + instance._sockets.delete(socket); + }); + + if (executor) { + executor(context); + } + if (!executor) { + invariant(!instance._ctx, "MockVMixAPI already initialized"); + } + instance._ctx = context; + instance._ready(); + }); + let host: string; + let port: number; + await new Promise((resolve, reject) => { + try { + server.listen(() => { + const addr = server.address(); + console.log(`MVX addr ${JSON.stringify(addr)}`); + invariant( + addr && typeof addr === "object", + "Expected address to be an object", + ); + host = addr.address; + port = addr.port; + resolve(); + }); + } catch (e) { + reject(e); + } + }); + instance = new MockVMixAPI(server, host!, port!); + return instance; + } + + public async close() { + for (const sock of this._sockets) { + sock.end(); + } + this._sockets.clear(); + this._server.close(); + } +} diff --git a/utility/vmix/src/__snapshots__/vmix.test.ts.snap b/utility/vmix/src/__snapshots__/vmix.test.ts.snap new file mode 100644 index 000000000..c4798121a --- /dev/null +++ b/utility/vmix/src/__snapshots__/vmix.test.ts.snap @@ -0,0 +1,410 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`VMixConnection getFullState 1`] = ` +{ + "vmix": { + "active": { + "_text": "3", + }, + "audio": { + "master": { + "_attributes": { + "headphonesVolume": "100", + "meterF1": "0", + "meterF2": "0", + "muted": "False", + "volume": "100", + }, + }, + }, + "dynamic": { + "input1": {}, + "input2": {}, + "input3": {}, + "input4": {}, + "value1": {}, + "value2": {}, + "value3": {}, + "value4": {}, + }, + "edition": { + "_text": "4K", + }, + "external": { + "_text": "False", + }, + "fadeToBlack": { + "_text": "False", + }, + "fullscreen": { + "_text": "False", + }, + "inputs": { + "input": [ + { + "_attributes": { + "duration": 0, + "key": "418f6d70-ec7e-464a-8abc-92280776f5a0", + "loop": "False", + "number": 1, + "position": 0, + "shortTitle": "TV Mix", + "state": "Paused", + "title": "TV Mix", + "type": "Mix", + }, + "_text": "TV Mix", + }, + { + "_attributes": { + "duration": 0, + "key": "1c077daf-caf6-4371-be56-ca11736d12dd", + "loop": "False", + "number": 2, + "position": 0, + "shortTitle": "Transparent", + "state": "Paused", + "title": "Transparent", + "type": "Colour", + }, + "_text": "Transparent", + }, + { + "_attributes": { + "audiobusses": "M", + "balance": 0, + "duration": 279573, + "key": "705aa6bc-632e-4714-8759-0966703c154f", + "loop": "False", + "meterF1": "0", + "meterF2": "0", + "muted": "False", + "number": 3, + "position": 0, + "shortTitle": "VTs", + "solo": "False", + "state": "Paused", + "title": "VTs - Test 0.mp4", + "type": "VideoList", + "volume": 100, + }, + "_text": "VTs - Test 0.mp4", + }, + { + "_attributes": { + "audiobusses": "M", + "balance": 0, + "duration": 5000, + "key": "410bc785-3d4b-4d0d-96a0-58e0ed78edde", + "loop": "False", + "meterF1": "0", + "meterF2": "0", + "muted": "True", + "number": 4, + "position": 0, + "shortTitle": "YY_Transition_1.mov", + "solo": "False", + "state": "Paused", + "title": "YY_Transition_1.mov", + "type": "Video", + "volume": 100, + }, + "_text": "YY_Transition_1.mov", + }, + { + "_attributes": { + "duration": 0, + "key": "254bf3d2-6b74-4dc3-a46f-bac16b5c4fee", + "loop": "False", + "number": 5, + "position": 0, + "shortTitle": "Colour", + "state": "Paused", + "title": "Colour", + "type": "Colour", + }, + "_text": "Colour", + }, + { + "_attributes": { + "audiobusses": "M", + "balance": 0, + "duration": 0, + "key": "be25c595-60e1-49b1-873b-1c1f7fde77b4", + "loop": "False", + "meterF1": "0", + "meterF2": "0", + "muted": "True", + "number": 6, + "position": 0, + "shortTitle": "Stills", + "solo": "False", + "state": "Paused", + "title": "Stills - Test.png", + "type": "VideoList", + "volume": 100, + }, + "_text": "Stills + - Test.png", + }, + { + "_attributes": { + "duration": 0, + "key": "ba632fc4-90b6-42db-afd0-fbc73487fa23", + "loop": "False", + "number": 7, + "position": 0, + "shortTitle": "Test.png", + "state": "Paused", + "title": "Test.png", + "type": "Image", + }, + "_text": "Test.png", + }, + { + "_attributes": { + "audiobusses": "M", + "balance": 0, + "duration": 60899, + "key": "b5076d09-361c-4185-8086-9c45c9de75de", + "loop": "False", + "meterF1": "0", + "meterF2": "0", + "muted": "True", + "number": 8, + "position": 0, + "shortTitle": "ES_Young Mystery Detectives - Trailer Worx.wav", + "solo": "False", + "state": "Paused", + "title": "ES_Young Mystery Detectives - Trailer Worx.wav", + "type": "AudioFile", + "volume": 100, + }, + "_text": "ES_Young Mystery Detectives - Trailer + Worx.wav", + }, + ], + }, + "mix": { + "_attributes": { + "number": "2", + }, + "active": { + "_text": "6", + }, + "preview": { + "_text": "0", + }, + }, + "multiCorder": { + "_text": "False", + }, + "overlays": { + "overlay": [ + { + "_attributes": { + "number": 1, + }, + }, + { + "_attributes": { + "number": 2, + }, + }, + { + "_attributes": { + "number": 3, + }, + }, + { + "_attributes": { + "number": 4, + }, + }, + { + "_attributes": { + "number": 5, + }, + }, + { + "_attributes": { + "number": 6, + }, + }, + { + "_attributes": { + "number": 7, + }, + }, + { + "_attributes": { + "number": 8, + }, + }, + ], + }, + "playList": { + "_text": "False", + }, + "preset": { + "_text": "C:\\Test.vmix", + }, + "preview": { + "_text": "3", + }, + "recording": { + "_text": "False", + }, + "streaming": { + "_text": "False", + }, + "transitions": { + "transition": [ + { + "_attributes": { + "duration": 500, + "effect": "Fade", + "number": 1, + }, + }, + { + "_attributes": { + "duration": 1000, + "effect": "Merge", + "number": 2, + }, + }, + { + "_attributes": { + "duration": 1000, + "effect": "Wipe", + "number": 3, + }, + }, + { + "_attributes": { + "duration": 1000, + "effect": "CubeZoom", + "number": 4, + }, + }, + ], + }, + "version": { + "_text": "26.0.0.44", + }, + }, +} +`; + +exports[`VMixConnection getFullState 2`] = ` +{ + "edition": "4K", + "inputs": [ + { + "duration": 0, + "key": "418f6d70-ec7e-464a-8abc-92280776f5a0", + "loop": false, + "number": 1, + "position": 0, + "shortTitle": "TV Mix", + "state": "Paused", + "title": "TV Mix", + "type": "Mix", + }, + { + "duration": 0, + "key": "1c077daf-caf6-4371-be56-ca11736d12dd", + "loop": false, + "number": 2, + "position": 0, + "shortTitle": "Transparent", + "state": "Paused", + "title": "Transparent", + "type": "Colour", + }, + { + "duration": 279573, + "items": [], + "key": "705aa6bc-632e-4714-8759-0966703c154f", + "loop": false, + "number": 3, + "position": 0, + "selectedIndex": NaN, + "shortTitle": "VTs", + "state": "Paused", + "title": "VTs - Test 0.mp4", + "type": "VideoList", + }, + { + "audioBusses": "M", + "balance": 0, + "duration": 5000, + "key": "410bc785-3d4b-4d0d-96a0-58e0ed78edde", + "loop": false, + "muted": true, + "number": 4, + "position": 0, + "shortTitle": "YY_Transition_1.mov", + "solo": false, + "state": "Paused", + "title": "YY_Transition_1.mov", + "type": "Video", + "volume": 100, + }, + { + "duration": 0, + "key": "254bf3d2-6b74-4dc3-a46f-bac16b5c4fee", + "loop": false, + "number": 5, + "position": 0, + "shortTitle": "Colour", + "state": "Paused", + "title": "Colour", + "type": "Colour", + }, + { + "duration": 0, + "items": [], + "key": "be25c595-60e1-49b1-873b-1c1f7fde77b4", + "loop": false, + "number": 6, + "position": 0, + "selectedIndex": NaN, + "shortTitle": "Stills", + "state": "Paused", + "title": "Stills - Test.png", + "type": "VideoList", + }, + { + "duration": 0, + "key": "ba632fc4-90b6-42db-afd0-fbc73487fa23", + "loop": false, + "number": 7, + "position": 0, + "shortTitle": "Test.png", + "state": "Paused", + "title": "Test.png", + "type": "Image", + }, + { + "audioBusses": "M", + "balance": 0, + "duration": 60899, + "key": "b5076d09-361c-4185-8086-9c45c9de75de", + "loop": false, + "muted": true, + "number": 8, + "position": 0, + "shortTitle": "ES_Young Mystery Detectives - Trailer Worx.wav", + "solo": false, + "state": "Paused", + "title": "ES_Young Mystery Detectives - Trailer Worx.wav", + "type": "AudioFile", + "volume": 100, + }, + ], + "preset": "C:\\Test.vmix", + "version": "26.0.0.44", +} +`; diff --git a/utility/vmix/src/__testdata__/vmix.xml b/utility/vmix/src/__testdata__/vmix.xml new file mode 100644 index 000000000..f2e64858c --- /dev/null +++ b/utility/vmix/src/__testdata__/vmix.xml @@ -0,0 +1,87 @@ + + 26.0.0.44 + 4K + C:\\Test.vmix + + TV Mix + Transparent + VTs - Test 0.mp4 + C:\\media\\Test 0.mp4 + C:\\media\\Test 1.mp4 + C:\\media\\Test 22.mp4 + C:\\media\\Test 3.mov + C:\\media\\Test 4.mp4 + + YY_Transition_1.mov + Colour + Stills + - Test.png + C:\\media\\Test.png + + Test.png + ES_Young Mystery Detectives - Trailer + Worx.wav + + + + + + + + + + + + 3 + 3 + False + + + + + + + False + False + False + False + False + False + + 0 + 6 + + + + + + + + + + + + + \ No newline at end of file diff --git a/utility/vmix/src/index.ts b/utility/vmix/src/index.ts new file mode 100644 index 000000000..a99f21754 --- /dev/null +++ b/utility/vmix/src/index.ts @@ -0,0 +1,8 @@ +import VMixConnection from "./vmix"; + +import { VMixState } from "./vmixState"; + +export * from "./vmixTypes"; + +export default VMixConnection; +export { VMixConnection, VMixState }; diff --git a/utility/vmix/src/invariant.ts b/utility/vmix/src/invariant.ts new file mode 100644 index 000000000..87d9510bb --- /dev/null +++ b/utility/vmix/src/invariant.ts @@ -0,0 +1,19 @@ +/** + * An invariant is something that should *always* be true. If it's not, it's a bug. Use this function to check, + * e.g., that you have a non-null value, or that a value is within a certain range. It means you get more useful error + * messages if, for whatever reason, it's not true, rather than a nondescript TypeError. It's particularly useful + * together with nullable types when you *know* that the value is non-null, but you (or TypeScript) want to be sure. + * @example + * const item = localMedia.find((x) => x.mediaID === info.id); + * invariant( + * item !== undefined, + * "no item (should never happen)", + * ); + * // now typescript knows that `item` is not undefined, and you get a better error message if (for whatever reason) it is + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export default function invariant(cond: any, msg: string): asserts cond { + if (!cond) { + throw new Error(`Invariant violation: ${msg}`); + } +} diff --git a/desktop/src/main/vmix/vmix.test.ts b/utility/vmix/src/vmix.test.ts similarity index 88% rename from desktop/src/main/vmix/vmix.test.ts rename to utility/vmix/src/vmix.test.ts index 4674bce43..88a021f75 100644 --- a/desktop/src/main/vmix/vmix.test.ts +++ b/utility/vmix/src/vmix.test.ts @@ -1,18 +1,19 @@ -import { beforeEach, describe, test, vi, expect } from "vitest"; +import { beforeEach, describe, test, jest, expect } from "@jest/globals"; import { EventEmitter } from "node:events"; import * as fs from "node:fs/promises"; import * as path from "path"; import VMixConnection from "./vmix"; +import { VMixState } from "./vmixState"; class MockSocket extends EventEmitter { - write = vi.fn( - (_payload: unknown, _encoding: string, cb: (err?: Error) => void) => + write = jest.fn( + (payload: unknown, encoding: string, cb: (err?: Error) => void) => cb(undefined), ); - setEncoding = vi.fn(); + setEncoding = jest.fn(); } -vi.mock("net", () => ({ +jest.mock("node:net", () => ({ connect: () => { const sock = new MockSocket(); process.nextTick(() => { @@ -28,6 +29,7 @@ describe("VMixConnection", () => { let vmix: VMixConnection; let sock: MockSocket; beforeEach(async () => { + debugger; vmix = await VMixConnection.connect(); sock = vmix["sock"] as unknown as MockSocket; }); @@ -112,13 +114,15 @@ describe("VMixConnection", () => { path.join(__dirname, "__testdata__", "vmix.xml"), { encoding: "utf-8" }, ); - const res = vmix.getFullState(); + const res = vmix.getFullStateRaw(); expect(sock.write).toHaveBeenCalledWith( "XML\r\n", "utf-8", expect.any(Function), ); sock.emit("data", `XML ${testXML.length}\r\n${testXML}`); - expect(res).resolves.toMatchSnapshot(); + const raw = await res; + expect(VMixState.fromXML(raw).raw).toMatchSnapshot(); + expect(VMixState.fromXML(raw).state).toMatchSnapshot(); }); }); diff --git a/utility/vmix/src/vmix.ts b/utility/vmix/src/vmix.ts new file mode 100644 index 000000000..f8e7be23f --- /dev/null +++ b/utility/vmix/src/vmix.ts @@ -0,0 +1,293 @@ +import { Socket, connect } from "node:net"; +import invariant from "./invariant"; +import { InputType } from "./vmixTypes"; +import * as qs from "qs"; +import { v4 as uuidV4 } from "uuid"; +import { VMixState } from "./vmixState"; +import { VMixState as VMixStateCleanedType } from "./vmixTypes"; +import { ElementCompact, xml2js } from "xml-js"; + +type VMixCommand = + | "TALLY" + | "FUNCTION" + | "ACTS" + | "XML" + | "XMLTEXT" + | "SUBSCRIBE" + | "UNSUBSCRIBE" + | "QUIT"; + +interface ReqQueueItem { + command: VMixCommand; + args: string[]; + resolve: (msgAndData: [string, string]) => void; + reject: (err: Error) => void; +} + +/** + * A connection to a vMix instance using the TCP API. + * + * @example + * const vmix = await VMixConnection.connect(); + */ +export default class VMixConnection { + private sock!: Socket; + + // See the comment on doNextRequest for an explanation of this. + private replyAwaiting: Map< + VMixCommand, + { + resolve: (msgAndData: [string, string]) => void; + reject: (err: Error) => void; + } + >; + private requestQueue: Array = []; + + private buffer: string = ""; + private constructor( + // This is only used for display purposes, the actual connection is established in `connect()`, + // before this constructor is called. + public readonly host: string, + public readonly port: number, + ) { + this.replyAwaiting = new Map(); + } + + public static async connect(host: string = "localhost", port: number = 8099) { + const sock = connect({ + host, + port, + }); + sock.setEncoding("utf-8"); + await new Promise((resolve, reject) => { + sock.once("connect", () => { + console.log("connected"); + sock.off("error", reject); + resolve(); + }); + sock.on("error", (e) => { + console.error("VMix connection failed", e); + reject(e); + }); + }); + const vmix = new VMixConnection(host, port); + vmix.sock = sock; + sock.on("data", vmix.onData.bind(vmix)); + sock.on("close", vmix.onClose.bind(vmix)); + sock.on("error", vmix.onError.bind(vmix)); + return vmix; + } + + private _closeHandlers = new Set<() => void>(); + + public async close() { + this.sock.end(); + await new Promise((resolve) => { + this._closeHandlers.add(resolve); + }); + } + + public async addInput(type: InputType, filePath: string) { + // vMix doesn't return the input key, but you can pass it one to use. + const id = uuidV4(); + await this.doFunction("AddInput", { + Value: type + "|" + filePath, + Input: id, + }); + // This may error if the input is already added or the GUID collides (very unlikely) + return id; + } + + public async renameInput(inputKey: string, newName: string) { + await this.doFunction("SetInputName", { Input: inputKey, Value: newName }); + } + + public async addInputToList(listSource: string, path: string) { + await this.doFunction("ListAdd", { Input: listSource, Value: path }); + } + + /** + * + * @param listSource the name, index, or ID of the list source + * @param index the index of the item to remove - NB: this is 1-based! + */ + public async removeItemFromList(listSource: string, index: number) { + await this.doFunction("ListRemove", { + Input: listSource, + Value: index.toString(), + }); + } + + public async clearList(listSource: string) { + await this.doFunction("ListRemoveAll", { Input: listSource }); + } + + public async setListAutoNext(listSource: string, autoNext: boolean) { + if (autoNext) { + await this.doFunction("AutoPlayNextOn", { Input: listSource }); + } else { + await this.doFunction("AutoPlayNextOff", { Input: listSource }); + } + } + + // Function reference: https://www.vmix.com/help26/ShortcutFunctionReference.html + private async doFunction(fn: string, params: Record) { + return await this.send("FUNCTION", fn, qs.stringify(params)); + } + + public async getPartialState(xpath: string): Promise { + const [_, result] = await this.send("XMLTEXT", xpath); + return xml2js(result, { compact: true }); + } + + public async getFullStateRaw(): Promise { + const [_, result] = await this.send("XML"); + return result; + } + + public async getFullState(): Promise { + const data = await this.getFullStateRaw(); + return VMixState.fromXML(data).state; + } + + private async send(command: VMixCommand, ...args: string[]) { + const req: ReqQueueItem = { + command, + args, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + resolve: null as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + reject: null as any, + }; + const reply = new Promise<[string, string]>((resolve, reject) => { + req.resolve = resolve; + req.reject = reject; + }); + this.requestQueue.push(req); + console.debug( + `Queued request ${command}; queue is now ${this.requestQueue.length} deep`, + ); + await this.doNextRequest(); + return reply; + } + + // When replying to a TCP request, vMix includes the name of the command + // in its response, but nothing else that would allow us to identify the sender + // (unlike e.g. the OBS WebSocket API, where requests can have an ID). + // Therefore, we only allow one request per command type to be in flight at + // a time. + // + // The replyAwaiting map tracks whether we have sent a request for a given command, + // and therefore we can't send another until we've received a response to the first. + // If a request of type X is added to the queue when there is already one in flight, + // doNextRequest will skip processing it. Then, once a compelte response is received + // by onData(), it will call doNextRequest again to process the next request. + // + // This implementation is a bit simplistic - if the first request in the queue + // is blocked we won't process any others, even if they would not be blocked. + // However, this is unlikely to be a problem in practice. + // + // With that in mind, this could be simplified even further - instead of a + // replyAwaiting map, we could just have a single boolean flag indicating whether + // a request is in flight. + private async doNextRequest() { + const req = this.requestQueue[0]; + if (!req) { + return; + } + if (this.replyAwaiting.has(req.command)) { + // Wait until the next run + console.debug(`Request ${req.command} is blocked`); + return; + } + this.requestQueue.shift(); + this.replyAwaiting.set(req!.command, req!); + await new Promise((resolve, reject) => { + this.sock.write( + req.command + + (req.args.length > 0 ? " " + req.args.join(" ") : "") + + "\r\n", + "utf-8", + (err) => (err ? reject(err) : resolve()), + ); + }); + console.debug(`Sent request ${req.command}`); + } + + private onData(data: Buffer) { + this.buffer += data.toString(); + // Replies will be in one of the following forms: + // + //MYCOMMAND OK This is the response to MYCOMMAND\r\n + // + //MYCOMMAND ER This is an error message in response to MYCOMMAND\r\n + // + //MYCOMMAND 28\r\n + //This is optional binary data + // + //MYCOMMAND 28 This is a message in addition to the binary data\r\n + //This is optional binary data + // + // The 28 in the last two examples is the length of the binary data. + // NB: binary data doesn't necessarily end with \r\n! + + if (!this.buffer.includes("\r\n")) { + return; + } + const firstLine = this.buffer.slice(0, this.buffer.indexOf("\r\n")); + const [command, status, ...rest] = firstLine.split(" "); + console.debug(`Received response to ${command}: ${status}`); + const reply = this.replyAwaiting.get(command as VMixCommand); + if (status === "OK") { + reply?.resolve([rest.join(" "), ""]); + this.replyAwaiting.delete(command as VMixCommand); + this.buffer = ""; + process.nextTick(this.doNextRequest.bind(this)); + return; + } + if (status === "ER") { + reply?.reject(new Error(rest.join(" "))); + this.replyAwaiting.delete(command as VMixCommand); + this.buffer = ""; + process.nextTick(this.doNextRequest.bind(this)); + return; + } + // This is a binary response and "status" is actually its length + invariant(status.match(/^\d+$/), "Invalid status: " + status); + const payloadLength = parseInt(status, 10); + // +2 for the \r\n + if (this.buffer.length < payloadLength + firstLine.length + 2) { + // still need more data + console.debug( + `Expecting ${payloadLength} bytes of binary data but only got ${this.buffer.length - firstLine.length - 2}`, + ); + return; + } + const payload = this.buffer.slice( + firstLine.length + 2, + payloadLength + firstLine.length + 2, + ); + reply?.resolve([rest.join(" "), payload.trim()]); + this.replyAwaiting.delete(command as VMixCommand); + process.nextTick(this.doNextRequest.bind(this)); + this.buffer = ""; + } + + private onClose() { + console.warn("VMix connection closed"); + this.replyAwaiting.forEach((req) => req.reject(new Error("Socket closed"))); + this.replyAwaiting.clear(); + this.requestQueue.forEach((req) => req.reject(new Error("Socket closed"))); + this.requestQueue = []; + this.onError(new Error("Socket closed")); + for (const cb of this._closeHandlers) { + cb(); + } + } + + private onError(err: Error) { + if (err.message !== "Socket closed") { + console.error("VMix connection error", err); + } + } +} diff --git a/utility/vmix/src/vmixState.ts b/utility/vmix/src/vmixState.ts new file mode 100644 index 000000000..dcdd8bb38 --- /dev/null +++ b/utility/vmix/src/vmixState.ts @@ -0,0 +1,171 @@ +import type { Draft } from "immer"; +import { + AudioFileObject, + VideoListObject, + VideoObject, + VMixRawXMLSchema, +} from "./vmixTypesRaw"; +import { + AudioFileInput, + BaseInput, + InputObject, + InputType, + ListInput, + VideoInput, + VMixState as VMixStateCleanedType, +} from "./vmixTypes"; +import { z } from "zod"; +import { js2xml, xml2js } from "xml-js"; + +type RawType = z.infer; + +export class VMixState { + protected _raw: RawType; + protected _cleaned: VMixStateCleanedType | null = null; + + constructor( + data: RawType | string, + failOnTypeMismatch = process.env.NODE_ENV === "test", + ) { + const rawParseRes = VMixRawXMLSchema.safeParse(data); + let raw: z.infer; + if (rawParseRes.success) { + raw = rawParseRes.data; + } else if (failOnTypeMismatch) { + // In tests we want this to fail immediately so that we notice the changes + throw rawParseRes.error; + } else { + // But in production we want to keep trying if we can, as it's possible the + // changes are minor enough to allow this to continue working. + console.warn( + "Parsing raw vMix schema failed. Possibly the vMix is a version we don't know. Will try to proceed, but things may break!", + ); + console.trace("Raw data:", JSON.stringify(data)); + // DIRTY HACK - assume that the data matches the schema to extract what we can + raw = data as z.infer; + } + this._raw = raw; + } + + static fromXML( + xml: string, + failOnTypeMismatch = process.env.NODE_ENV === "test", + ) { + return new this( + xml2js(xml, { compact: true }) as RawType, + failOnTypeMismatch, + ); + } + + get raw() { + return this._raw; + } + + get state(): VMixStateCleanedType { + if (this._cleaned !== null) { + return this._cleaned; + } + const raw = this._raw; + const res: VMixStateCleanedType = { + version: raw.vmix.version._text, + edition: raw.vmix.edition._text, + preset: raw.vmix.preset?._text, + inputs: [], + }; + for (const input of raw.vmix.inputs.input) { + // eslint is wrong + // eslint-disable-next-line prefer-const + let v: BaseInput = { + key: input._attributes.key, + number: input._attributes.number, + type: input._attributes.type as InputType, + title: input._attributes.title, + shortTitle: input._attributes.shortTitle, + loop: input._attributes.loop === "True", + state: input._attributes.state, + duration: input._attributes.duration, + position: input._attributes.position, + }; + switch (input._attributes.type) { + case "Colour": + case "Mix": + case "Image": + case "Blank": + break; + case "Video": + case "AudioFile": { + const r = input as VideoObject | AudioFileObject; + (v as unknown as VideoInput | AudioFileInput) = { + ...v, + type: r._attributes.type as "Video" | "AudioFile", + volume: r._attributes.volume, + balance: r._attributes.balance, + solo: r._attributes.solo === "True", + muted: r._attributes.muted === "True", + audioBusses: r._attributes.audiobusses, + }; + break; + } + case "VideoList": { + const r = input as VideoListObject; + (v as unknown as ListInput) = { + ...v, + type: r._attributes.type, + selectedIndex: r._attributes.selectedIndex - 1 /* 1-based index */, + items: [], + }; + if (Array.isArray(r.list?.item)) { + (v as ListInput).items = r.list!.item.map((item) => { + if (typeof item === "string") { + return { + source: item, + selected: false, + }; + } + return { + source: item._text, + selected: item._attributes?.selected === "true", + }; + }); + } else if (r.list) { + (v as ListInput).items.push({ + source: r.list.item._text, + selected: r.list.item._attributes?.selected === "true", + }); + } + break; + } + default: + console.warn(`Unrecognised input type '${input._attributes.type}'`); + if (process.env.NODE_ENV === "test") { + console.debug(input); + throw new Error( + `Unrecognised input type ${input._attributes.type}`, + ); + } + continue; + } + res.inputs.push(v as InputObject); + } + this._cleaned = res; + return res; + } +} + +export class MutableVMixState extends VMixState { + async update(fn: (draft: Draft) => void) { + const { produce } = await import("immer"); + const resultRaw = produce(this._raw, fn); + // Run the new value through the Zod validation to ensure + // it's still valid + const result = VMixRawXMLSchema.parse(resultRaw); + this._raw = result; + this._cleaned = null; // invalidate cache + } + + get xml() { + return js2xml(this._raw, { + compact: true, + }); + } +} diff --git a/desktop/src/main/vmix/vmixTypes.ts b/utility/vmix/src/vmixTypes.ts similarity index 100% rename from desktop/src/main/vmix/vmixTypes.ts rename to utility/vmix/src/vmixTypes.ts diff --git a/utility/vmix/src/vmixTypesRaw.ts b/utility/vmix/src/vmixTypesRaw.ts new file mode 100644 index 000000000..35ef27d45 --- /dev/null +++ b/utility/vmix/src/vmixTypesRaw.ts @@ -0,0 +1,209 @@ +import { z } from "zod"; + +/* + * This file is an implementation detail of VMixConnection.getFullState(). + * Unless you are parsing the vMix XML, you probably want vmixTypes.ts instead. + */ + +const InputType = z + .enum([ + "Mix", + "Colour", + "VideoList", + "Video", + "Image", + "AudioFile", + "Blank", + ] as const) + .or(z.string()); + +/** + * vMix backslash-escapes its strings, even though backslash is valid in XML. This removes that layer of escaping. + */ +const deEscapedString = z.string().transform((v) => v.replace(/\\\\/g, "\\")); + +export const VideoObject = z.object({ + _attributes: z.object({ + key: z.string(), + number: z.coerce.number(), + type: z.string(), + title: z.string(), + shortTitle: z.string(), + state: z.string(), + position: z.coerce.number(), + duration: z.coerce.number(), + loop: z.string(), + muted: z.string(), + volume: z.coerce.number(), + balance: z.coerce.number(), + solo: z.string(), + audiobusses: z.string(), + meterF1: z.string(), + meterF2: z.string(), + }), + _text: z.string(), +}); +export type VideoObject = z.infer; + +const ListItemObj = z.object({ + _attributes: z.object({ selected: z.string() }).optional(), + _text: deEscapedString, +}); +export const ListItemType = z.union([ListItemObj, deEscapedString]); + +export const VideoListObject = z.object({ + _attributes: z.object({ + key: z.string(), + number: z.coerce.number(), + type: z.literal("VideoList"), + title: z.string(), + shortTitle: z.string(), + state: z.string(), + position: z.coerce.number(), + duration: z.coerce.number(), + loop: z.string(), + muted: z.string(), + volume: z.string(), + balance: z.string(), + solo: z.string(), + audiobusses: z.string(), + meterF1: z.string(), + meterF2: z.string(), + gainDb: z.coerce.number(), + selectedIndex: z.coerce.number(), + }), + _text: z.string(), + list: z.object({ + item: z.array(ListItemObj).or(ListItemObj), + }), +}); +export type VideoListObject = z.infer; + +export const AudioFileObject = z.object({ + _attributes: z.object({ + key: z.string(), + number: z.coerce.number(), + type: z.literal("AudioFile"), + title: z.string(), + shortTitle: z.string(), + state: z.string(), + position: z.coerce.number(), + duration: z.coerce.number(), + loop: z.string(), + muted: z.string(), + volume: z.coerce.number(), + balance: z.coerce.number(), + solo: z.string(), + audiobusses: z.string(), + meterF1: z.string(), + meterF2: z.string(), + gainDb: z.coerce.number(), + }), + _text: z.string(), +}); +export type AudioFileObject = z.infer; + +export const BlankObject = z.object({ + _text: z.string(), + _attributes: z.object({ + key: z.string(), + number: z.coerce.number(), + type: z.literal("Blank"), + title: z.string(), + shortTitle: z.string(), + state: z.string(), + position: z.coerce.number(), + duration: z.coerce.number(), + loop: z.string(), + }), +}); +export type BlankObject = z.infer; + +export const VMixRawXMLSchema = z + .object({ + vmix: z.object({ + version: z.object({ _text: z.string() }), + edition: z.object({ _text: z.string() }), + preset: z.object({ _text: deEscapedString }).optional(), + inputs: z.object({ + input: z.array( + z.union([ + VideoObject, + VideoListObject, + AudioFileObject, + BlankObject, + z.object({ + _attributes: z.object({ + key: z.string(), + number: z.coerce.number(), + type: InputType.or(z.string()), + title: z.string(), + shortTitle: z.string(), + state: z.string(), + position: z.coerce.number(), + duration: z.coerce.number(), + loop: z.string(), + }), + _text: z.string(), + }), + ]), + ), + }), + overlays: z.object({ + overlay: z.array( + z.object({ _attributes: z.object({ number: z.coerce.number() }) }), + ), + }), + preview: z.object({ _text: z.string() }), + active: z.object({ _text: z.string() }), + fadeToBlack: z.object({ _text: z.string() }), + transitions: z.object({ + transition: z.array( + z.object({ + _attributes: z.object({ + number: z.coerce.number(), + effect: z.string(), + duration: z.coerce.number(), + }), + }), + ), + }), + recording: z.object({ _text: z.string() }), + external: z.object({ _text: z.string() }), + streaming: z.object({ _text: z.string() }), + playList: z.object({ _text: z.string() }), + multiCorder: z.object({ _text: z.string() }), + fullscreen: z.object({ _text: z.string() }), + mix: z + .object({ + _attributes: z.object({ number: z.string() }), + preview: z.object({ _text: z.string() }), + active: z.object({ _text: z.string() }), + }) + .optional(), + audio: z.object({ + master: z.object({ + _attributes: z.object({ + volume: z.string(), + muted: z.string(), + meterF1: z.string(), + meterF2: z.string(), + headphonesVolume: z.string(), + }), + }), + }), + dynamic: z + .object({ + input1: z.object({}), + input2: z.object({}), + input3: z.object({}), + input4: z.object({}), + value1: z.object({}), + value2: z.object({}), + value3: z.object({}), + value4: z.object({}), + }) + .optional(), + }), + }) + .passthrough(); diff --git a/utility/vmix/tsconfig.json b/utility/vmix/tsconfig.json new file mode 100644 index 000000000..b0273ab9a --- /dev/null +++ b/utility/vmix/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@tsconfig/node-lts/tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "declaration": true, + "declarationMap": true, + "composite": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/yarn.lock b/yarn.lock index fc5b567c8..91b9edeea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4995,7 +4995,45 @@ __metadata: languageName: node linkType: hard -"@rollup/pluginutils@npm:^5.0.1": +"@rollup/plugin-node-resolve@npm:^15.2.3": + version: 15.2.3 + resolution: "@rollup/plugin-node-resolve@npm:15.2.3" + dependencies: + "@rollup/pluginutils": "npm:^5.0.1" + "@types/resolve": "npm:1.20.2" + deepmerge: "npm:^4.2.2" + is-builtin-module: "npm:^3.2.1" + is-module: "npm:^1.0.0" + resolve: "npm:^1.22.1" + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + checksum: 10/d36a6792fbe9d8673d3a7c7dc88920be669ac54fba02ac0093d3c00fc9463fce2e87da1906a2651016742709c3d202b367fb49a62acd0d98f18409343f27b8b4 + languageName: node + linkType: hard + +"@rollup/plugin-typescript@npm:^11.1.6": + version: 11.1.6 + resolution: "@rollup/plugin-typescript@npm:11.1.6" + dependencies: + "@rollup/pluginutils": "npm:^5.1.0" + resolve: "npm:^1.22.1" + peerDependencies: + rollup: ^2.14.0||^3.0.0||^4.0.0 + tslib: "*" + typescript: ">=3.7.0" + peerDependenciesMeta: + rollup: + optional: true + tslib: + optional: true + checksum: 10/4ae4d6cfc929393171288df2f18b5eb837fa53d8689118d9661b3064567341f6f6cf8389af55f1d5f015e3682abf30a64ab609fdf75ecb5a84224505e407eb69 + languageName: node + linkType: hard + +"@rollup/pluginutils@npm:^5.0.1, @rollup/pluginutils@npm:^5.0.5, @rollup/pluginutils@npm:^5.1.0": version: 5.1.0 resolution: "@rollup/pluginutils@npm:5.1.0" dependencies: @@ -6512,6 +6550,13 @@ __metadata: languageName: node linkType: hard +"@tsconfig/node-lts@npm:^20.1.3": + version: 20.1.3 + resolution: "@tsconfig/node-lts@npm:20.1.3" + checksum: 10/c6d62e2dbc32764414e9b865103348a04facbe14a1d9439013d01e6b1220265e3d1cfdaafedba0d5ab9adba8e6fb190fb4ce9a8195253e8ed97b427d943e5dab + languageName: node + linkType: hard + "@tsconfig/node10@npm:^1.0.7": version: 1.0.9 resolution: "@tsconfig/node10@npm:1.0.9" @@ -6825,7 +6870,7 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:^29.5.9": +"@types/jest@npm:^29.5.12, @types/jest@npm:^29.5.9": version: 29.5.13 resolution: "@types/jest@npm:29.5.13" dependencies: @@ -7090,6 +7135,13 @@ __metadata: languageName: node linkType: hard +"@types/resolve@npm:1.20.2": + version: 1.20.2 + resolution: "@types/resolve@npm:1.20.2" + checksum: 10/1bff0d3875e7e1557b6c030c465beca9bf3b1173ebc6937cac547654b0af3bb3ff0f16470e9c4d7c5dc308ad9ac8627c38dbff24ef698b66673ff5bd4ead7f7e + languageName: node + linkType: hard + "@types/responselike@npm:^1.0.0": version: 1.0.3 resolution: "@types/responselike@npm:1.0.3" @@ -7754,7 +7806,7 @@ __metadata: languageName: node linkType: hard -"@xmldom/xmldom@npm:^0.8.8": +"@xmldom/xmldom@npm:^0.8.10, @xmldom/xmldom@npm:^0.8.8": version: 0.8.10 resolution: "@xmldom/xmldom@npm:0.8.10" checksum: 10/62400bc5e0e75b90650e33a5ceeb8d94829dd11f9b260962b71a784cd014ddccec3e603fe788af9c1e839fa4648d8c521ebd80d8b752878d3a40edabc9ce7ccf @@ -7775,6 +7827,37 @@ __metadata: languageName: node linkType: hard +"@ystv/vmix@workspace:*, @ystv/vmix@workspace:utility/vmix": + version: 0.0.0-use.local + resolution: "@ystv/vmix@workspace:utility/vmix" + dependencies: + "@jest/globals": "npm:^29.7.0" + "@rollup/plugin-node-resolve": "npm:^15.2.3" + "@rollup/plugin-typescript": "npm:^11.1.6" + "@tsconfig/node-lts": "npm:^20.1.3" + "@types/jest": "npm:^29.5.12" + "@types/node": "npm:^22.1.0" + "@xmldom/xmldom": "npm:^0.8.10" + esbuild: "npm:^0.23.1" + jest: "npm:^29.7.0" + qs: "npm:^6.13.0" + rollup: "npm:^4.21.0" + rollup-plugin-esbuild: "npm:^6.1.1" + rollup-plugin-node-externals: "npm:^7.1.3" + ts-jest: "npm:^29.2.4" + typescript: "npm:^5.5.4" + uuid: "npm:^10.0.0" + xml-js: "npm:^1.6.11" + xpath: "npm:^0.0.34" + zod: "npm:^3.23.8" + peerDependencies: + immer: "*" + peerDependenciesMeta: + immer: + optional: true + languageName: unknown + linkType: soft + "abbrev@npm:^1.0.0": version: 1.1.1 resolution: "abbrev@npm:1.1.1" @@ -8581,6 +8664,7 @@ __metadata: "@typescript-eslint/eslint-plugin": "npm:^7.0.0" "@typescript-eslint/parser": "npm:^7.0.0" "@vitest/coverage-v8": "npm:^2.0.0" + "@ystv/vmix": "workspace:*" autoprefixer: "npm:^10.4.14" badger-server: "workspace:*" bufferutil: "npm:^4.0.7" @@ -8930,6 +9014,15 @@ __metadata: languageName: node linkType: hard +"bs-logger@npm:0.x": + version: 0.2.6 + resolution: "bs-logger@npm:0.2.6" + dependencies: + fast-json-stable-stringify: "npm:2.x" + checksum: 10/e6d3ff82698bb3f20ce64fb85355c5716a3cf267f3977abe93bf9c32a2e46186b253f48a028ae5b96ab42bacd2c826766d9ae8cf6892f9b944656be9113cf212 + languageName: node + linkType: hard + "bser@npm:2.1.1": version: 2.1.1 resolution: "bser@npm:2.1.1" @@ -9075,6 +9168,13 @@ __metadata: languageName: node linkType: hard +"builtin-modules@npm:^3.3.0": + version: 3.3.0 + resolution: "builtin-modules@npm:3.3.0" + checksum: 10/62e063ab40c0c1efccbfa9ffa31873e4f9d57408cb396a2649981a0ecbce56aabc93c28feaccbc5658c95aab2703ad1d11980e62ec2e5e72637404e1eb60f39e + languageName: node + linkType: hard + "busboy@npm:1.6.0": version: 1.6.0 resolution: "busboy@npm:1.6.0" @@ -10455,7 +10555,7 @@ __metadata: languageName: node linkType: hard -"ejs@npm:^3.1.8": +"ejs@npm:^3.1.10, ejs@npm:^3.1.8": version: 3.1.10 resolution: "ejs@npm:3.1.10" dependencies: @@ -10826,10 +10926,10 @@ __metadata: languageName: node linkType: hard -"es-module-lexer@npm:^1.2.1": - version: 1.4.1 - resolution: "es-module-lexer@npm:1.4.1" - checksum: 10/cf453613468c417af6e189b03d9521804033fdd5a229a36fedec28d37ea929fccf6822d42abff1126eb01ba1d2aa2845a48d5d1772c0724f8204464d9d3855f6 +"es-module-lexer@npm:^1.2.1, es-module-lexer@npm:^1.3.1": + version: 1.5.4 + resolution: "es-module-lexer@npm:1.5.4" + checksum: 10/f29c7c97a58eb17640dcbd71bd6ef754ad4f58f95c3073894573d29dae2cad43ecd2060d97ed5b866dfb7804d5590fb7de1d2c5339a5fceae8bd60b580387fc5 languageName: node linkType: hard @@ -10960,7 +11060,7 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:^0.23.0, esbuild@npm:~0.23.0": +"esbuild@npm:^0.23.0, esbuild@npm:^0.23.1, esbuild@npm:~0.23.0": version: 0.23.1 resolution: "esbuild@npm:0.23.1" dependencies: @@ -11617,7 +11717,7 @@ __metadata: languageName: node linkType: hard -"fast-json-stable-stringify@npm:^2.0.0, fast-json-stable-stringify@npm:^2.1.0": +"fast-json-stable-stringify@npm:2.x, fast-json-stable-stringify@npm:^2.0.0, fast-json-stable-stringify@npm:^2.1.0": version: 2.1.0 resolution: "fast-json-stable-stringify@npm:2.1.0" checksum: 10/2c20055c1fa43c922428f16ca8bb29f2807de63e5c851f665f7ac9790176c01c3b40335257736b299764a8d383388dabc73c8083b8e1bc3d99f0a941444ec60e @@ -12108,12 +12208,12 @@ __metadata: languageName: node linkType: hard -"get-tsconfig@npm:^4.5.0, get-tsconfig@npm:^4.7.5": - version: 4.7.5 - resolution: "get-tsconfig@npm:4.7.5" +"get-tsconfig@npm:^4.5.0, get-tsconfig@npm:^4.7.2, get-tsconfig@npm:^4.7.5": + version: 4.7.6 + resolution: "get-tsconfig@npm:4.7.6" dependencies: resolve-pkg-maps: "npm:^1.0.0" - checksum: 10/de7de5e4978354e8e6d9985baf40ea32f908a13560f793bc989930c229cc8d5c3f7b6b2896d8e43eb1a9b4e9e30018ef4b506752fd2a4b4d0dfee4af6841b119 + checksum: 10/32da95a89f3ddbabd2a2e36f2a4add51a5e3c2b28f32e3c81494fcdbd43b7d9b42baea77784e62d10f87bb564c5ee908416aabf4c5ca9cdbb2950aa3c247f124 languageName: node linkType: hard @@ -12922,6 +13022,15 @@ __metadata: languageName: node linkType: hard +"is-builtin-module@npm:^3.2.1": + version: 3.2.1 + resolution: "is-builtin-module@npm:3.2.1" + dependencies: + builtin-modules: "npm:^3.3.0" + checksum: 10/e8f0ffc19a98240bda9c7ada84d846486365af88d14616e737d280d378695c8c448a621dcafc8332dbf0fcd0a17b0763b845400709963fa9151ddffece90ae88 + languageName: node + linkType: hard + "is-callable@npm:^1.1.3, is-callable@npm:^1.1.4, is-callable@npm:^1.2.7": version: 1.2.7 resolution: "is-callable@npm:1.2.7" @@ -13068,6 +13177,13 @@ __metadata: languageName: node linkType: hard +"is-module@npm:^1.0.0": + version: 1.0.0 + resolution: "is-module@npm:1.0.0" + checksum: 10/8cd5390730c7976fb4e8546dd0b38865ee6f7bacfa08dfbb2cc07219606755f0b01709d9361e01f13009bbbd8099fa2927a8ed665118a6105d66e40f1b838c3f + languageName: node + linkType: hard + "is-negative-zero@npm:^2.0.3": version: 2.0.3 resolution: "is-negative-zero@npm:2.0.3" @@ -13822,7 +13938,7 @@ __metadata: languageName: node linkType: hard -"jest-util@npm:^29.7.0": +"jest-util@npm:^29.0.0, jest-util@npm:^29.7.0": version: 29.7.0 resolution: "jest-util@npm:29.7.0" dependencies: @@ -14478,7 +14594,7 @@ __metadata: languageName: node linkType: hard -"lodash.memoize@npm:^4.1.2": +"lodash.memoize@npm:4.x, lodash.memoize@npm:^4.1.2": version: 4.1.2 resolution: "lodash.memoize@npm:4.1.2" checksum: 10/192b2168f310c86f303580b53acf81ab029761b9bd9caa9506a019ffea5f3363ea98d7e39e7e11e6b9917066c9d36a09a11f6fe16f812326390d8f3a54a1a6da @@ -14687,7 +14803,7 @@ __metadata: languageName: node linkType: hard -"make-error@npm:^1.1.1": +"make-error@npm:1.x, make-error@npm:^1.1.1": version: 1.3.6 resolution: "make-error@npm:1.3.6" checksum: 10/b86e5e0e25f7f777b77fabd8e2cbf15737972869d852a22b7e73c17623928fccb826d8e46b9951501d3f20e51ad74ba8c59ed584f610526a48f8ccf88aaec402 @@ -16638,12 +16754,12 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.11.1, qs@npm:^6.11.2, qs@npm:^6.7.0": - version: 6.12.3 - resolution: "qs@npm:6.12.3" +"qs@npm:^6.11.1, qs@npm:^6.11.2, qs@npm:^6.13.0, qs@npm:^6.7.0": + version: 6.13.0 + resolution: "qs@npm:6.13.0" dependencies: side-channel: "npm:^1.0.6" - checksum: 10/486d80cfa5e12886de6fe15a5aa2b3c7023bf4461f949a742022c3ae608499dbaebcb57b1f15c1f59d86356772969028768b33c1a7c01e76d99f149239e63d59 + checksum: 10/f548b376e685553d12e461409f0d6e5c59ec7c7d76f308e2a888fd9db3e0c5e89902bedd0754db3a9038eda5f27da2331a6f019c8517dc5e0a16b3c9a6e9cef8 languageName: node linkType: hard @@ -17325,6 +17441,21 @@ __metadata: languageName: node linkType: hard +"rollup-plugin-esbuild@npm:^6.1.1": + version: 6.1.1 + resolution: "rollup-plugin-esbuild@npm:6.1.1" + dependencies: + "@rollup/pluginutils": "npm:^5.0.5" + debug: "npm:^4.3.4" + es-module-lexer: "npm:^1.3.1" + get-tsconfig: "npm:^4.7.2" + peerDependencies: + esbuild: ">=0.18.0" + rollup: ^1.20.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 + checksum: 10/bba2d1dfb92a193823ac9dd1cdd44a8fd8cd9f25868e9a22ca077e1b7445feb4eaaf6df051148e367fc902d7d59c9f50efab49086c24c367972f05c86f3a656d + languageName: node + linkType: hard + "rollup-plugin-ignore@npm:^1.0.10": version: 1.0.10 resolution: "rollup-plugin-ignore@npm:1.0.10" @@ -17332,6 +17463,15 @@ __metadata: languageName: node linkType: hard +"rollup-plugin-node-externals@npm:^7.1.3": + version: 7.1.3 + resolution: "rollup-plugin-node-externals@npm:7.1.3" + peerDependencies: + rollup: ^3.0.0 || ^4.0.0 + checksum: 10/4e8d38ebc3c8a29cb72d11a73f97ba810a4560b5b0f0be445ba32180875073ce4752a9ea4d0b658717ddae642d314738047677e5c818b1893f3d715d18fe413c + languageName: node + linkType: hard + "rollup-plugin-visualizer@npm:^5.12.0": version: 5.12.0 resolution: "rollup-plugin-visualizer@npm:5.12.0" @@ -17365,7 +17505,7 @@ __metadata: languageName: node linkType: hard -"rollup@npm:^4.20.0": +"rollup@npm:^4.20.0, rollup@npm:^4.21.0": version: 4.21.0 resolution: "rollup@npm:4.21.0" dependencies: @@ -18651,6 +18791,43 @@ __metadata: languageName: node linkType: hard +"ts-jest@npm:^29.2.4": + version: 29.2.4 + resolution: "ts-jest@npm:29.2.4" + dependencies: + bs-logger: "npm:0.x" + ejs: "npm:^3.1.10" + fast-json-stable-stringify: "npm:2.x" + jest-util: "npm:^29.0.0" + json5: "npm:^2.2.3" + lodash.memoize: "npm:4.x" + make-error: "npm:1.x" + semver: "npm:^7.5.3" + yargs-parser: "npm:^21.0.1" + peerDependencies: + "@babel/core": ">=7.0.0-beta.0 <8" + "@jest/transform": ^29.0.0 + "@jest/types": ^29.0.0 + babel-jest: ^29.0.0 + jest: ^29.0.0 + typescript: ">=4.3 <6" + peerDependenciesMeta: + "@babel/core": + optional: true + "@jest/transform": + optional: true + "@jest/types": + optional: true + babel-jest: + optional: true + esbuild: + optional: true + bin: + ts-jest: cli.js + checksum: 10/69db25e06b93f4ea4e454a54afc4e49c59b71f7efdef94fe728f4d62b8c475364d0fed7253212c5394669dcd143516ab6f630f4b139b2f9c37119245cf5a963c + languageName: node + linkType: hard + "ts-node@npm:^10.9.1": version: 10.9.2 resolution: "ts-node@npm:10.9.2" @@ -18875,7 +19052,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:5.5.4, typescript@npm:^5.1.6, typescript@npm:^5.3.3, typescript@npm:^5.4.3, typescript@npm:^5.4.5": +"typescript@npm:5.5.4, typescript@npm:^5.1.6, typescript@npm:^5.3.3, typescript@npm:^5.4.3, typescript@npm:^5.4.5, typescript@npm:^5.5.4": version: 5.5.4 resolution: "typescript@npm:5.5.4" bin: @@ -18885,7 +19062,7 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A5.5.4#optional!builtin, typescript@patch:typescript@npm%3A^5.1.6#optional!builtin, typescript@patch:typescript@npm%3A^5.3.3#optional!builtin, typescript@patch:typescript@npm%3A^5.4.3#optional!builtin, typescript@patch:typescript@npm%3A^5.4.5#optional!builtin": +"typescript@patch:typescript@npm%3A5.5.4#optional!builtin, typescript@patch:typescript@npm%3A^5.1.6#optional!builtin, typescript@patch:typescript@npm%3A^5.3.3#optional!builtin, typescript@patch:typescript@npm%3A^5.4.3#optional!builtin, typescript@patch:typescript@npm%3A^5.4.5#optional!builtin, typescript@patch:typescript@npm%3A^5.5.4#optional!builtin": version: 5.5.4 resolution: "typescript@patch:typescript@npm%3A5.5.4#optional!builtin::version=5.5.4&hash=379a07" bin: @@ -19138,6 +19315,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^10.0.0": + version: 10.0.0 + resolution: "uuid@npm:10.0.0" + bin: + uuid: dist/bin/uuid + checksum: 10/35aa60614811a201ff90f8ca5e9ecb7076a75c3821e17f0f5ff72d44e36c2d35fcbc2ceee9c4ac7317f4cc41895da30e74f3885e30313bee48fda6338f250538 + languageName: node + linkType: hard + "uuid@npm:^9.0.0, uuid@npm:^9.0.1": version: 9.0.1 resolution: "uuid@npm:9.0.1" @@ -19683,6 +19869,17 @@ __metadata: languageName: node linkType: hard +"xml-js@npm:^1.6.11": + version: 1.6.11 + resolution: "xml-js@npm:1.6.11" + dependencies: + sax: "npm:^1.2.4" + bin: + xml-js: ./bin/cli.js + checksum: 10/55ce342a47bf14a138a3fcea0c9e325b81484cfc1a8aac78df13b4d6ca01f20e32820572bc3e927cd9b61b9da9cdee4657cb2f304e460343d8d85d6a3659d749 + languageName: node + linkType: hard + "xml-name-validator@npm:^5.0.0": version: 5.0.0 resolution: "xml-name-validator@npm:5.0.0" @@ -19704,6 +19901,13 @@ __metadata: languageName: node linkType: hard +"xpath@npm:^0.0.34": + version: 0.0.34 + resolution: "xpath@npm:0.0.34" + checksum: 10/77ce03c4494dab97b70fa443761c35a6bd484538a449714b981387a532a6eb22e245b29164f5d8a4a82f4f3cfd71d27ba71d09ed2b6fe933654585c6e46c0a25 + languageName: node + linkType: hard + "xtend@npm:^4.0.0, xtend@npm:^4.0.2, xtend@npm:~4.0.1": version: 4.0.2 resolution: "xtend@npm:4.0.2" @@ -19741,7 +19945,7 @@ __metadata: languageName: node linkType: hard -"yargs-parser@npm:^21.1.1": +"yargs-parser@npm:^21.0.1, yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" checksum: 10/9dc2c217ea3bf8d858041252d43e074f7166b53f3d010a8c711275e09cd3d62a002969a39858b92bbda2a6a63a585c7127014534a560b9c69ed2d923d113406e