From ac2d6f25856b9914b50c08a302335d2a684c3542 Mon Sep 17 00:00:00 2001 From: ianshade Date: Tue, 12 Dec 2023 10:43:27 +0100 Subject: [PATCH 1/6] refactor(vMix): extract to multiple classes not perfect in terms of separation of concerns, but it works --- .../integrations/vmix/__tests__/vmix.spec.ts | 3 +- .../src/integrations/vmix/connection.ts | 221 +--- .../src/integrations/vmix/index.ts | 1065 +---------------- .../src/integrations/vmix/vMixCommands.ts | 229 ++++ .../src/integrations/vmix/vMixStateDiffer.ts | 846 +++++++++++++ .../vmix/vMixTimelineStateConverter.ts | 206 ++++ 6 files changed, 1312 insertions(+), 1258 deletions(-) create mode 100644 packages/timeline-state-resolver/src/integrations/vmix/vMixCommands.ts create mode 100644 packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts create mode 100644 packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vmix.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vmix.spec.ts index 64fbaea04..02f6fcc81 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vmix.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vmix.spec.ts @@ -24,9 +24,10 @@ import { MappingVmixProgram, } from 'timeline-state-resolver-types' import { ThreadedClass } from 'threadedclass' -import { VMixDevice, CommandContext } from '..' +import { VMixDevice } from '..' import { MockTime } from '../../../__tests__/mockTime' import '../../../__tests__/lib' +import { CommandContext } from '../vMixCommands' const orgSetTimeout = setTimeout diff --git a/packages/timeline-state-resolver/src/integrations/vmix/connection.ts b/packages/timeline-state-resolver/src/integrations/vmix/connection.ts index c7ebbf45d..e23fbbfd8 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/connection.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/connection.ts @@ -2,8 +2,9 @@ import { EventEmitter } from 'eventemitter3' import { Socket } from 'net' import * as xml from 'xml-js' import { VMixCommand, VMixTransitionType } from 'timeline-state-resolver-types' -import { VMixState, VMixInput, VMixMix } from './index' import * as _ from 'underscore' +import { VMixInput, VMixMix, VMixState } from './vMixStateDiffer' +import { VMixStateCommand } from './vMixCommands' const VMIX_DEFAULT_TCP_PORT = 8099 const RESPONSE_REGEX = /^(?\w+)\s+(?OK|ER|\d+)(\s+(?.*))?/i @@ -597,221 +598,3 @@ export class VMix extends BaseConnection { return this.sendCommandFunction(`Restart`, { input }) } } - -export interface VMixStateCommandBase { - command: VMixCommand -} -export interface VMixStateCommandPreviewInput extends VMixStateCommandBase { - command: VMixCommand.PREVIEW_INPUT - input: number | string - mix: number -} -export interface VMixStateCommandTransition extends VMixStateCommandBase { - command: VMixCommand.TRANSITION - input: number | string - effect: string - duration: number - mix: number -} -export interface VMixStateCommandAudio extends VMixStateCommandBase { - command: VMixCommand.AUDIO_VOLUME - input: number | string - value: number - fade?: number -} -export interface VMixStateCommandAudioBalance extends VMixStateCommandBase { - command: VMixCommand.AUDIO_BALANCE - input: number | string - value: number -} -export interface VMixStateCommandAudioOn extends VMixStateCommandBase { - command: VMixCommand.AUDIO_ON - input: number | string -} -export interface VMixStateCommandAudioOff extends VMixStateCommandBase { - command: VMixCommand.AUDIO_OFF - input: number | string -} -export interface VMixStateCommandAudioAutoOn extends VMixStateCommandBase { - command: VMixCommand.AUDIO_AUTO_ON - input: number | string -} -export interface VMixStateCommandAudioAutoOff extends VMixStateCommandBase { - command: VMixCommand.AUDIO_AUTO_OFF - input: number | string -} -export interface VMixStateCommandAudioBusOn extends VMixStateCommandBase { - command: VMixCommand.AUDIO_BUS_ON - input: number | string - value: string -} -export interface VMixStateCommandAudioBusOff extends VMixStateCommandBase { - command: VMixCommand.AUDIO_BUS_OFF - input: number | string - value: string -} -export interface VMixStateCommandFader extends VMixStateCommandBase { - command: VMixCommand.FADER - value: number -} -export interface VMixStateCommandSetPanX extends VMixStateCommandBase { - command: VMixCommand.SET_PAN_X - input: number | string - value: number -} -export interface VMixStateCommandSetPanY extends VMixStateCommandBase { - command: VMixCommand.SET_PAN_Y - input: number | string - value: number -} -export interface VMixStateCommandSetZoom extends VMixStateCommandBase { - command: VMixCommand.SET_ZOOM - input: number | string - value: number -} -export interface VMixStateCommandSetAlpha extends VMixStateCommandBase { - command: VMixCommand.SET_ALPHA - input: number | string - value: number -} -export interface VMixStateCommandStartStreaming extends VMixStateCommandBase { - command: VMixCommand.START_STREAMING -} -export interface VMixStateCommandStopStreaming extends VMixStateCommandBase { - command: VMixCommand.STOP_STREAMING -} -export interface VMixStateCommandStartRecording extends VMixStateCommandBase { - command: VMixCommand.START_RECORDING -} -export interface VMixStateCommandStopRecording extends VMixStateCommandBase { - command: VMixCommand.STOP_RECORDING -} -export interface VMixStateCommandFadeToBlack extends VMixStateCommandBase { - command: VMixCommand.FADE_TO_BLACK -} -export interface VMixStateCommandAddInput extends VMixStateCommandBase { - command: VMixCommand.ADD_INPUT - value: string -} -export interface VMixStateCommandRemoveInput extends VMixStateCommandBase { - command: VMixCommand.REMOVE_INPUT - input: string -} -export interface VMixStateCommandPlayInput extends VMixStateCommandBase { - command: VMixCommand.PLAY_INPUT - input: number | string -} -export interface VMixStateCommandPauseInput extends VMixStateCommandBase { - command: VMixCommand.PAUSE_INPUT - input: number | string -} -export interface VMixStateCommandSetPosition extends VMixStateCommandBase { - command: VMixCommand.SET_POSITION - input: number | string - value: number -} -export interface VMixStateCommandLoopOn extends VMixStateCommandBase { - command: VMixCommand.LOOP_ON - input: number | string -} -export interface VMixStateCommandLoopOff extends VMixStateCommandBase { - command: VMixCommand.LOOP_OFF - input: number | string -} -export interface VMixStateCommandSetInputName extends VMixStateCommandBase { - command: VMixCommand.SET_INPUT_NAME - input: number | string - value: string -} -export interface VMixStateCommandSetOuput extends VMixStateCommandBase { - command: VMixCommand.SET_OUPUT - name: string - value: string - input?: number | string -} -export interface VMixStateCommandStartExternal extends VMixStateCommandBase { - command: VMixCommand.START_EXTERNAL -} -export interface VMixStateCommandStopExternal extends VMixStateCommandBase { - command: VMixCommand.STOP_EXTERNAL -} -export interface VMixStateCommandOverlayInputIn extends VMixStateCommandBase { - command: VMixCommand.OVERLAY_INPUT_IN - value: number - input: string | number -} -export interface VMixStateCommandOverlayInputOut extends VMixStateCommandBase { - command: VMixCommand.OVERLAY_INPUT_OUT - value: number -} -export interface VMixStateCommandSetInputOverlay extends VMixStateCommandBase { - command: VMixCommand.SET_INPUT_OVERLAY - input: string | number - index: number - value: string | number -} -export interface VMixStateCommandScriptStart extends VMixStateCommandBase { - command: VMixCommand.SCRIPT_START - value: string -} -export interface VMixStateCommandScriptStop extends VMixStateCommandBase { - command: VMixCommand.SCRIPT_STOP - value: string -} -export interface VMixStateCommandScriptStopAll extends VMixStateCommandBase { - command: VMixCommand.SCRIPT_STOP_ALL -} -export interface VMixStateCommandListAdd extends VMixStateCommandBase { - command: VMixCommand.LIST_ADD - input: string | number - value: string -} -export interface VMixStateCommandListRemoveAll extends VMixStateCommandBase { - command: VMixCommand.LIST_REMOVE_ALL - input: string | number -} -export interface VMixStateCommandRestart extends VMixStateCommandBase { - command: VMixCommand.RESTART_INPUT - input: string | number -} -export type VMixStateCommand = - | VMixStateCommandPreviewInput - | VMixStateCommandTransition - | VMixStateCommandAudio - | VMixStateCommandAudioBalance - | VMixStateCommandAudioOn - | VMixStateCommandAudioOff - | VMixStateCommandAudioAutoOn - | VMixStateCommandAudioAutoOff - | VMixStateCommandAudioBusOn - | VMixStateCommandAudioBusOff - | VMixStateCommandFader - | VMixStateCommandSetZoom - | VMixStateCommandSetPanX - | VMixStateCommandSetPanY - | VMixStateCommandSetAlpha - | VMixStateCommandStartStreaming - | VMixStateCommandStopStreaming - | VMixStateCommandStartRecording - | VMixStateCommandStopRecording - | VMixStateCommandFadeToBlack - | VMixStateCommandAddInput - | VMixStateCommandRemoveInput - | VMixStateCommandPlayInput - | VMixStateCommandPauseInput - | VMixStateCommandSetPosition - | VMixStateCommandLoopOn - | VMixStateCommandLoopOff - | VMixStateCommandSetInputName - | VMixStateCommandSetOuput - | VMixStateCommandStartExternal - | VMixStateCommandStopExternal - | VMixStateCommandOverlayInputIn - | VMixStateCommandOverlayInputOut - | VMixStateCommandSetInputOverlay - | VMixStateCommandScriptStart - | VMixStateCommandScriptStop - | VMixStateCommandScriptStopAll - | VMixStateCommandListAdd - | VMixStateCommandListRemoveAll - | VMixStateCommandRestart diff --git a/packages/timeline-state-resolver/src/integrations/vmix/index.ts b/packages/timeline-state-resolver/src/integrations/vmix/index.ts index 172dbba28..44e78bfb3 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/index.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/index.ts @@ -1,27 +1,18 @@ import * as _ from 'underscore' -import * as path from 'path' import * as deepMerge from 'deepmerge' import { DeviceWithState, CommandWithContext, DeviceStatus, StatusCode } from './../../devices/device' import { DoOnTime, SendMode } from '../../devices/doOnTime' -import { VMix, VMixStateCommand } from './connection' +import { VMix } from './connection' import { DeviceType, DeviceOptionsVMix, VMixOptions, Mappings, - TimelineContentTypeVMix, - VMixCommand, - VMixTransition, - VMixTransitionType, - VMixInputType, VMixTransform, VMixInputOverlays, - MappingVmixType, - SomeMappingVmix, Timeline, TSRTimelineContent, - Mapping, ActionExecutionResult, ActionExecutionResultCode, OpenPresetPayload, @@ -29,6 +20,9 @@ import { VmixActions, } from 'timeline-state-resolver-types' import { t } from '../../lib' +import { VMixInput, VMixState, VMixStateDiffer, VMixStateExtended } from './vMixStateDiffer' +import { CommandContext, VMixStateCommandWithContext } from './vMixCommands' +import { VMixTimelineStateConverter } from './vMixTimelineStateConverter' /** * Default time, in milliseconds, for when we should poll vMix to query its actual state. @@ -56,33 +50,9 @@ export type CommandReceiver = ( timelineObjId: string layer: string }*/ -export enum CommandContext { - None = 'none', - Retry = 'retry', -} -export interface VMixStateCommandWithContext { - command: VMixStateCommand - context: CommandContext - timelineId: string -} export type EnforceableVMixInputStateKeys = 'duration' | 'loop' | 'transform' | 'overlays' | 'listFilePaths' -const mappingPriority: { [k in MappingVmixType]: number } = { - [MappingVmixType.Program]: 0, - [MappingVmixType.Preview]: 1, - [MappingVmixType.Input]: 2, // order of Input and AudioChannel matters because of the way layers are sorted - [MappingVmixType.AudioChannel]: 3, - [MappingVmixType.Output]: 4, - [MappingVmixType.Overlay]: 5, - [MappingVmixType.Recording]: 6, - [MappingVmixType.Streaming]: 7, - [MappingVmixType.External]: 8, - [MappingVmixType.FadeToBlack]: 9, - [MappingVmixType.Fader]: 10, - [MappingVmixType.Script]: 11, -} - /** * This is a VMixDevice, it sends commands when it feels like it */ @@ -95,13 +65,17 @@ export class VMixDevice extends DeviceWithState Promise) { super(deviceId, deviceOptions, getCurrentTime) + if (deviceOptions.options) { if (deviceOptions.commandReceiver) this._commandReceiver = deviceOptions.commandReceiver else this._commandReceiver = this._defaultCommandReceiver.bind(this) } + this._doOnTime = new DoOnTime( () => { return this.getCurrentTime() @@ -113,7 +87,18 @@ export class VMixDevice extends DeviceWithState this.emit('slowCommand', this.deviceName + ': ' + msg)) this._doOnTime.on('slowSentCommand', (info) => this.emit('slowSentCommand', info)) this._doOnTime.on('slowFulfilledCommand', (info) => this.emit('slowFulfilledCommand', info)) + + this._stateDiffer = new VMixStateDiffer( + () => this._vmix.state.fixedInputsCount, + (commands: VMixStateCommandWithContext[]) => this._addToQueue(commands, this.getCurrentTime()) + ) + + this._timelineStateConverter = new VMixTimelineStateConverter( + () => this._stateDiffer.getDefaultState(), + (num: number) => this._stateDiffer.getDefaultInputState(num) + ) } + async init(options: VMixOptions): Promise { this._vmix = new VMix(options.host, options.port, false) this._vmix.on('connected', () => { @@ -171,7 +156,9 @@ export class VMixDevice extends DeviceWithState { - return { - number: num, - input: undefined, - } - }), - mixes: _.map([1, 2, 3, 4], (num) => { - return { - number: num, - program: undefined, - preview: undefined, - transition: { effect: VMixTransitionType.Cut, duration: 0 }, - } - }), - fadeToBlack: false, - faderPosition: 0, - recording: false, - external: false, - streaming: false, - playlist: false, - multiCorder: false, - fullscreen: false, - audio: [], - }, - outputs: { - '2': { source: 'Program' }, - '3': { source: 'Program' }, - '4': { source: 'Program' }, - External2: { source: 'Program' }, - Fullscreen: { source: 'Program' }, - Fullscreen2: { source: 'Program' }, - }, - inputLayers: {}, - runningScripts: [], - } - } - /** Called by the Conductor a bit before a .handleState is called */ prepareForHandleState(newStateTime: number) { // clear any queued commands later than this time: @@ -294,12 +207,13 @@ export class VMixDevice extends DeviceWithState, mappings: Mappings): VMixStateExtended { - if (!this._initialized) throw Error('convertStateToVMix cannot be used before inititialized') - - const deviceState = this._getDefaultState() - - // Sort layer based on Mapping type (to make sure audio is after inputs) and Layer name - const sortedLayers = _.sortBy( - _.map(state.layers, (tlObject, layerName) => ({ - layerName, - tlObject, - mapping: mappings[layerName] as Mapping, - })).sort((a, b) => a.layerName.localeCompare(b.layerName)), - (o) => mappingPriority[o.mapping.options.mappingType] ?? Number.POSITIVE_INFINITY - ) - - _.each(sortedLayers, ({ tlObject, layerName, mapping }) => { - const content = tlObject.content - - if (mapping && content.deviceType === DeviceType.VMIX) { - switch (mapping.options.mappingType) { - case MappingVmixType.Program: - if (content.type === TimelineContentTypeVMix.PROGRAM) { - const mixProgram = (mapping.options.index || 1) - 1 - if (content.input !== undefined) { - this.switchToInput(content.input, deviceState, mixProgram, content.transition) - } else if (content.inputLayer) { - this.switchToInput(content.inputLayer, deviceState, mixProgram, content.transition, true) - } - } - break - case MappingVmixType.Preview: - if (content.type === TimelineContentTypeVMix.PREVIEW) { - const mixPreview = (mapping.options.index || 1) - 1 - if (content.input) deviceState.reportedState.mixes[mixPreview].preview = content.input - } - break - case MappingVmixType.AudioChannel: - if (content.type === TimelineContentTypeVMix.AUDIO) { - const vmixTlAudioPicked = _.pick(content, 'volume', 'balance', 'audioAuto', 'audioBuses', 'muted', 'fade') - if (mapping.options.index) { - deviceState.reportedState.inputs = this.modifyInput(deviceState, vmixTlAudioPicked, { - key: mapping.options.index, - }) - } else if (mapping.options.inputLayer) { - deviceState.reportedState.inputs = this.modifyInput(deviceState, vmixTlAudioPicked, { - layer: mapping.options.inputLayer, - }) - } - } - break - case MappingVmixType.Fader: - if (content.type === TimelineContentTypeVMix.FADER) { - deviceState.reportedState.faderPosition = content.position - } - break - case MappingVmixType.Recording: - if (content.type === TimelineContentTypeVMix.RECORDING) { - deviceState.reportedState.recording = content.on - } - break - case MappingVmixType.Streaming: - if (content.type === TimelineContentTypeVMix.STREAMING) { - deviceState.reportedState.streaming = content.on - } - break - case MappingVmixType.External: - if (content.type === TimelineContentTypeVMix.EXTERNAL) { - deviceState.reportedState.external = content.on - } - break - case MappingVmixType.FadeToBlack: - if (content.type === TimelineContentTypeVMix.FADE_TO_BLACK) { - deviceState.reportedState.fadeToBlack = content.on - } - break - case MappingVmixType.Input: - if (content.type === TimelineContentTypeVMix.INPUT) { - deviceState.reportedState.inputs = this.modifyInput( - deviceState, - { - type: content.inputType, - playing: content.playing, - loop: content.loop, - position: content.seek, - transform: content.transform, - overlays: content.overlays, - listFilePaths: content.listFilePaths, - restart: content.restart, - }, - - { key: mapping.options.index || content.filePath }, - layerName - ) - } - break - case MappingVmixType.Output: - if (content.type === TimelineContentTypeVMix.OUTPUT) { - deviceState.outputs[mapping.options.index] = { - source: content.source, - input: content.input, - } - } - break - case MappingVmixType.Overlay: - if (content.type === TimelineContentTypeVMix.OVERLAY) { - const overlayIndex = mapping.options.index - 1 - deviceState.reportedState.overlays[overlayIndex].input = content.input - } - break - case MappingVmixType.Script: - if (content.type === TimelineContentTypeVMix.SCRIPT) { - deviceState.runningScripts.push(content.name) - } - break - } - } - }) - return deviceState - } - - getFilename(filePath: string) { - return path.basename(filePath) - } - - modifyInput( - deviceState: VMixStateExtended, - newInput: VMixInput, - input: { key?: string | number; layer?: string }, - layerName?: string - ): { [key: string]: VMixInput } { - const inputs = deviceState.reportedState.inputs - const newInputPicked = _.pick(newInput, (x) => !_.isUndefined(x)) - let inputKey: string | number | undefined - if (input.layer) { - inputKey = deviceState.inputLayers[input.layer] - } else { - inputKey = input.key! - } - if (inputKey) { - if (inputKey in inputs) { - inputs[inputKey] = deepMerge(inputs[inputKey], newInputPicked) - } else { - const inputState = this._getDefaultInputState(0) - inputs[inputKey] = deepMerge(inputState, newInputPicked) - } - if (layerName) { - deviceState.inputLayers[layerName] = inputKey as string - } - } - return inputs - } - - switchToInput( - input: number | string, - deviceState: VMixStateExtended, - mix: number, - transition?: VMixTransition, - layerToProgram = false - ) { - const mixState = deviceState.reportedState.mixes[mix] - if ( - mixState.program === undefined || - mixState.program !== input // mixing numeric and string input names can be dangerous - ) { - mixState.preview = mixState.program - mixState.program = input - - mixState.transition = transition || { effect: VMixTransitionType.Cut, duration: 0 } - mixState.layerToProgram = layerToProgram - } - } - get deviceType() { return DeviceType.VMIX } @@ -629,627 +371,6 @@ export class VMixDevice extends DeviceWithState { - const commands: Array = [] - for (let i = 0; i < 4; i++) { - /** - * It is *not* guaranteed to have all 4 mixes present in the vMix state, for reasons unknown. - */ - const oldMixState = oldVMixState.reportedState.mixes[i] as VMixMix | undefined - const newMixState = newVMixState.reportedState.mixes[i] as VMixMix | undefined - if (newMixState?.program !== undefined) { - let nextInput = newMixState.program - let changeOnLayer = false - if (newMixState.layerToProgram) { - nextInput = newVMixState.inputLayers[newMixState.program] - changeOnLayer = - newVMixState.inputLayers[newMixState.program] !== oldVMixState.inputLayers[newMixState.program] - } - if (oldMixState?.program !== newMixState.program || changeOnLayer) { - commands.push({ - command: { - command: VMixCommand.TRANSITION, - effect: changeOnLayer ? VMixTransitionType.Cut : newMixState.transition.effect, - input: nextInput, - duration: changeOnLayer ? 0 : newMixState.transition.duration, - mix: i, - }, - context: CommandContext.None, - timelineId: '', - }) - } - } - - if ( - oldMixState?.program === newMixState?.program && // if we're not switching what is on program, because it could break a transition - newMixState?.preview !== undefined && - newMixState.preview !== oldMixState?.preview - ) { - commands.push({ - command: { - command: VMixCommand.PREVIEW_INPUT, - input: newMixState.preview, - mix: i, - }, - context: CommandContext.None, - timelineId: '', - }) - } - } - // Only set fader bar position if no other transitions are happening - if (oldVMixState.reportedState.mixes[0].program === newVMixState.reportedState.mixes[0].program) { - if (newVMixState.reportedState.faderPosition !== oldVMixState.reportedState.faderPosition) { - commands.push({ - command: { - command: VMixCommand.FADER, - value: newVMixState.reportedState.faderPosition || 0, - }, - context: CommandContext.None, - timelineId: '', - }) - // newVMixState.reportedState.program = undefined - // newVMixState.reportedState.preview = undefined - newVMixState.reportedState.fadeToBlack = false - } - } - if (oldVMixState.reportedState.fadeToBlack !== newVMixState.reportedState.fadeToBlack) { - // Danger: Fade to black is toggled, we can't explicitly say that we want it on or off - commands.push({ - command: { - command: VMixCommand.FADE_TO_BLACK, - }, - context: CommandContext.None, - timelineId: '', - }) - } - return commands - } - - private _resolveInputsState( - oldVMixState: VMixStateExtended, - newVMixState: VMixStateExtended - ): { - preTransitionCommands: Array - postTransitionCommands: Array - } { - const preTransitionCommands: Array = [] - const postTransitionCommands: Array = [] - _.each(newVMixState.reportedState.inputs, (input, key) => { - if (input.name === undefined) { - input.name = key - } - if (!_.has(oldVMixState.reportedState.inputs, key) && input.type !== undefined) { - const addCommands: Array = [] - addCommands.push({ - command: { - command: VMixCommand.ADD_INPUT, - value: `${input.type}|${input.name}`, - }, - context: CommandContext.None, - timelineId: '', - }) - addCommands.push({ - command: { - command: VMixCommand.SET_INPUT_NAME, - input: this.getFilename(input.name), - value: input.name, - }, - context: CommandContext.None, - timelineId: '', - }) - this._addToQueue(addCommands, this.getCurrentTime()) - } - const oldInput = oldVMixState.reportedState.inputs[key] || this._getDefaultInputState(0) // or {} but we assume that a new input has all parameters default - - /** - * If an input is currently on air, then we delay changes to it until after the transition has began. - * Note the word "began", instead of "completed". - * - * This mostly helps in the case of CUT transitions, where in theory everything happens - * on the same frame but, in reality, thanks to how vMix processes API commands, - * things take place over the course of a few frames. - */ - const commands = this._isInUse(oldVMixState, oldInput) ? postTransitionCommands : preTransitionCommands - - // It is important that the operations on listFilePaths happen before most other operations. - // Consider the case where we want to change the contents of a List input AND set it to playing. - // If we set it to playing first, it will automatically be forced to stop playing when - // we dispatch LIST_REMOVE_ALL. - // So, order of operations matters here. - if (!_.isEqual(oldInput.listFilePaths, input.listFilePaths)) { - // vMix has a quirk that we are working around here: - // When a List input has no items, its Play/Pause button becomes inactive and - // clicking it does nothing. However, if the List was playing when it was emptied, - // it'll remain in a playing state. This means that as soon as new content is - // added to the playlist, it will immediately begin playing. This feels like a - // bug/mistake/otherwise unwanted behavior in every scenario. To work around this, - // we automatically dispatch a PAUSE_INPUT command before emptying the playlist, - // but only if there's no new content being added afterward. - if (!input.listFilePaths || (Array.isArray(input.listFilePaths) && input.listFilePaths.length <= 0)) { - commands.push({ - command: { - command: VMixCommand.PAUSE_INPUT, - input: input.name, - }, - context: CommandContext.None, - timelineId: '', - }) - } - commands.push({ - command: { - command: VMixCommand.LIST_REMOVE_ALL, - input: input.name, - }, - context: CommandContext.None, - timelineId: '', - }) - if (Array.isArray(input.listFilePaths)) { - for (const filePath of input.listFilePaths) { - commands.push({ - command: { - command: VMixCommand.LIST_ADD, - input: input.name, - value: filePath, - }, - context: CommandContext.None, - timelineId: '', - }) - } - } - } - if (input.playing !== undefined && oldInput.playing !== input.playing && !input.playing) { - commands.push({ - command: { - command: VMixCommand.PAUSE_INPUT, - input: input.name, - }, - context: CommandContext.None, - timelineId: '', - }) - } - if (oldInput.position !== input.position) { - commands.push({ - command: { - command: VMixCommand.SET_POSITION, - input: key, - value: input.position ? input.position : 0, - }, - context: CommandContext.None, - timelineId: '', - }) - } - if (input.restart !== undefined && oldInput.restart !== input.restart && input.restart) { - commands.push({ - command: { - command: VMixCommand.RESTART_INPUT, - input: key, - }, - context: CommandContext.None, - timelineId: '', - }) - } - if (input.loop !== undefined && oldInput.loop !== input.loop) { - if (input.loop) { - commands.push({ - command: { - command: VMixCommand.LOOP_ON, - input: input.name, - }, - context: CommandContext.None, - timelineId: '', - }) - } else { - commands.push({ - command: { - command: VMixCommand.LOOP_OFF, - input: input.name, - }, - context: CommandContext.None, - timelineId: '', - }) - } - } - if (input.muted !== undefined && oldInput.muted !== input.muted && input.muted) { - commands.push({ - command: { - command: VMixCommand.AUDIO_OFF, - input: key, - }, - context: CommandContext.None, - timelineId: '', - }) - } - if (oldInput.volume !== input.volume && input.volume !== undefined) { - commands.push({ - command: { - command: VMixCommand.AUDIO_VOLUME, - input: key, - value: input.volume, - fade: input.fade, - }, - context: CommandContext.None, - timelineId: '', - }) - } - if (oldInput.balance !== input.balance && input.balance !== undefined) { - commands.push({ - command: { - command: VMixCommand.AUDIO_BALANCE, - input: key, - value: input.balance, - }, - context: CommandContext.None, - timelineId: '', - }) - } - if (input.audioAuto !== undefined && oldInput.audioAuto !== input.audioAuto) { - if (!input.audioAuto) { - commands.push({ - command: { - command: VMixCommand.AUDIO_AUTO_OFF, - input: key, - }, - context: CommandContext.None, - timelineId: '', - }) - } else { - commands.push({ - command: { - command: VMixCommand.AUDIO_AUTO_ON, - input: key, - }, - context: CommandContext.None, - timelineId: '', - }) - } - } - if (input.audioBuses !== undefined && oldInput.audioBuses !== input.audioBuses) { - const oldBuses = (oldInput.audioBuses || '').split(',').filter((x) => x) - const newBuses = input.audioBuses.split(',').filter((x) => x) - _.difference(newBuses, oldBuses).forEach((bus) => { - commands.push({ - command: { - command: VMixCommand.AUDIO_BUS_ON, - input: key, - value: bus, - }, - context: CommandContext.None, - timelineId: '', - }) - }) - _.difference(oldBuses, newBuses).forEach((bus) => { - commands.push({ - command: { - command: VMixCommand.AUDIO_BUS_OFF, - input: key, - value: bus, - }, - context: CommandContext.None, - timelineId: '', - }) - }) - } - if (input.muted !== undefined && oldInput.muted !== input.muted && !input.muted) { - commands.push({ - command: { - command: VMixCommand.AUDIO_ON, - input: key, - }, - context: CommandContext.None, - timelineId: '', - }) - } - if (input.transform !== undefined && !_.isEqual(oldInput.transform, input.transform)) { - if (oldInput.transform === undefined || input.transform.zoom !== oldInput.transform.zoom) { - commands.push({ - command: { - command: VMixCommand.SET_ZOOM, - input: key, - value: input.transform.zoom, - }, - context: CommandContext.None, - timelineId: '', - }) - } - if (oldInput.transform === undefined || input.transform.alpha !== oldInput.transform.alpha) { - commands.push({ - command: { - command: VMixCommand.SET_ALPHA, - input: key, - value: input.transform.alpha, - }, - context: CommandContext.None, - timelineId: '', - }) - } - if (oldInput.transform === undefined || input.transform.panX !== oldInput.transform.panX) { - commands.push({ - command: { - command: VMixCommand.SET_PAN_X, - input: key, - value: input.transform.panX, - }, - context: CommandContext.None, - timelineId: '', - }) - } - if (oldInput.transform === undefined || input.transform.panY !== oldInput.transform.panY) { - commands.push({ - command: { - command: VMixCommand.SET_PAN_Y, - input: key, - value: input.transform.panY, - }, - context: CommandContext.None, - timelineId: '', - }) - } - } - if (input.overlays !== undefined && !_.isEqual(oldInput.overlays, input.overlays)) { - Object.keys(input.overlays).forEach((index) => { - if (input.overlays !== oldInput.overlays?.[index]) { - commands.push({ - command: { - command: VMixCommand.SET_INPUT_OVERLAY, - input: key, - value: input.overlays![Number(index)], - index: Number(index), - }, - context: CommandContext.None, - timelineId: '', - }) - } - }) - Object.keys(oldInput?.overlays || {}).forEach((index) => { - if (!input.overlays?.[index]) { - commands.push({ - command: { - command: VMixCommand.SET_INPUT_OVERLAY, - input: key, - value: '', - index: Number(index), - }, - context: CommandContext.None, - timelineId: '', - }) - } - }) - } - if (input.playing !== undefined && oldInput.playing !== input.playing && input.playing) { - commands.push({ - command: { - command: VMixCommand.PLAY_INPUT, - input: input.name, - }, - context: CommandContext.None, - timelineId: '', - }) - } - }) - return { preTransitionCommands, postTransitionCommands } - } - - private _resolveInputsRemovalState( - oldVMixState: VMixStateExtended, - newVMixState: VMixStateExtended - ): Array { - const commands: Array = [] - _.difference( - Object.keys(oldVMixState.reportedState.inputs), - Object.keys(newVMixState.reportedState.inputs) - ).forEach((input) => { - if (oldVMixState.reportedState.inputs[input].type !== undefined) { - // TODO: either schedule this command for later or make the timeline object long enough to prevent removing while transitioning - commands.push({ - command: { - command: VMixCommand.REMOVE_INPUT, - input: oldVMixState.reportedState.inputs[input].name || input, - }, - context: CommandContext.None, - timelineId: '', - }) - } - }) - return commands - } - - private _resolveOverlaysState( - oldVMixState: VMixStateExtended, - newVMixState: VMixStateExtended - ): Array { - const commands: Array = [] - _.each(newVMixState.reportedState.overlays, (overlay, index) => { - const oldOverlay = oldVMixState.reportedState.overlays[index] - if (oldOverlay.input !== overlay.input) { - if (overlay.input === undefined) { - commands.push({ - command: { - command: VMixCommand.OVERLAY_INPUT_OUT, - value: overlay.number, - }, - context: CommandContext.None, - timelineId: '', - }) - } else { - commands.push({ - command: { - command: VMixCommand.OVERLAY_INPUT_IN, - input: overlay.input, - value: overlay.number, - }, - context: CommandContext.None, - timelineId: '', - }) - } - } - }) - return commands - } - - private _resolveRecordingState( - oldVMixState: VMixStateExtended, - newVMixState: VMixStateExtended - ): Array { - const commands: Array = [] - if (oldVMixState.reportedState.recording !== newVMixState.reportedState.recording) { - if (newVMixState.reportedState.recording) { - commands.push({ - command: { - command: VMixCommand.START_RECORDING, - }, - context: CommandContext.None, - timelineId: '', - }) - } else { - commands.push({ - command: { - command: VMixCommand.STOP_RECORDING, - }, - context: CommandContext.None, - timelineId: '', - }) - } - } - return commands - } - - private _resolveStreamingState( - oldVMixState: VMixStateExtended, - newVMixState: VMixStateExtended - ): Array { - const commands: Array = [] - if (oldVMixState.reportedState.streaming !== newVMixState.reportedState.streaming) { - if (newVMixState.reportedState.streaming) { - commands.push({ - command: { - command: VMixCommand.START_STREAMING, - }, - context: CommandContext.None, - timelineId: '', - }) - } else { - commands.push({ - command: { - command: VMixCommand.STOP_STREAMING, - }, - context: CommandContext.None, - timelineId: '', - }) - } - } - return commands - } - - private _resolveExternalState( - oldVMixState: VMixStateExtended, - newVMixState: VMixStateExtended - ): Array { - const commands: Array = [] - if (oldVMixState.reportedState.external !== newVMixState.reportedState.external) { - if (newVMixState.reportedState.external) { - commands.push({ - command: { - command: VMixCommand.START_EXTERNAL, - }, - context: CommandContext.None, - timelineId: '', - }) - } else { - commands.push({ - command: { - command: VMixCommand.STOP_EXTERNAL, - }, - context: CommandContext.None, - timelineId: '', - }) - } - } - return commands - } - - private _resolveOutputsState( - oldVMixState: VMixStateExtended, - newVMixState: VMixStateExtended - ): Array { - const commands: Array = [] - _.map(newVMixState.outputs, (output, name) => { - const nameKey = name as keyof VMixStateExtended['outputs'] - const oldOutput = nameKey in oldVMixState.outputs ? oldVMixState.outputs[nameKey] : undefined - if (!_.isEqual(output, oldOutput)) { - const value = output.source === 'Program' ? 'Output' : output.source - commands.push({ - command: { - command: VMixCommand.SET_OUPUT, - value, - input: output.input, - name, - }, - context: CommandContext.None, - timelineId: '', - }) - } - }) - return commands - } - - private _resolveScriptsState( - oldVMixState: VMixStateExtended, - newVMixState: VMixStateExtended - ): Array { - const commands: Array = [] - _.map(newVMixState.runningScripts, (name) => { - const alreadyRunning = oldVMixState.runningScripts.includes(name) - if (!alreadyRunning) { - commands.push({ - command: { - command: VMixCommand.SCRIPT_START, - value: name, - }, - context: CommandContext.None, - timelineId: '', - }) - } - }) - _.map(oldVMixState.runningScripts, (name) => { - const noLongerDesired = !newVMixState.runningScripts.includes(name) - if (noLongerDesired) { - commands.push({ - command: { - command: VMixCommand.SCRIPT_STOP, - value: name, - }, - context: CommandContext.None, - timelineId: '', - }) - } - }) - return commands - } - - private _diffStates( - oldVMixState: VMixStateExtended, - newVMixState: VMixStateExtended - ): Array { - let commands: Array = [] - - const inputCommands = this._resolveInputsState(oldVMixState, newVMixState) - commands = commands.concat(inputCommands.preTransitionCommands) - commands = commands.concat(this._resolveMixState(oldVMixState, newVMixState)) - commands = commands.concat(this._resolveOverlaysState(oldVMixState, newVMixState)) - commands = commands.concat(inputCommands.postTransitionCommands) - commands = commands.concat(this._resolveRecordingState(oldVMixState, newVMixState)) - commands = commands.concat(this._resolveStreamingState(oldVMixState, newVMixState)) - commands = commands.concat(this._resolveExternalState(oldVMixState, newVMixState)) - commands = commands.concat(this._resolveOutputsState(oldVMixState, newVMixState)) - commands = commands.concat(this._resolveInputsRemovalState(oldVMixState, newVMixState)) - commands = commands.concat(this._resolveScriptsState(oldVMixState, newVMixState)) - - return commands - } - private async _defaultCommandReceiver( _time: number, cmd: VMixStateCommandWithContext, @@ -1277,51 +398,6 @@ export class VMixDevice extends DeviceWithState(state.outputs)) { - if (output.input === input.name || output.input === input.number) { - // Input might not technically be in PGM, but it's being used by an output, - // so stop the search and return true. - return true - } - } - - return false - } - /** * Polls vMix's XML status endpoint, which will change our tracked state based on the response. */ @@ -1333,90 +409,3 @@ export class VMixDevice extends DeviceWithState this.emit('error', 'VMix poll', e)) } } - -interface VMixOutput { - source: 'Preview' | 'Program' | 'MultiView' | 'Input' - input?: number | string -} - -export interface VMixStateExtended { - /** - * The state of vMix (as far as we know) as reported by vMix **+ - * our expectations based on the commands we've set**. - */ - reportedState: VMixState - outputs: { - External2: VMixOutput - - '2': VMixOutput - '3': VMixOutput - '4': VMixOutput - - Fullscreen: VMixOutput - Fullscreen2: VMixOutput - } - inputLayers: { [key: string]: string } - runningScripts: string[] -} - -export interface VMixState { - version: string - edition: string // TODO: Enuum, need list of available editions: Trial - fixedInputsCount: number - inputs: { [key: string]: VMixInput } - overlays: VMixOverlay[] - mixes: VMixMix[] - fadeToBlack: boolean - faderPosition?: number - recording: boolean - external: boolean - streaming: boolean - playlist: boolean - multiCorder: boolean - fullscreen: boolean - audio: VMixAudioChannel[] -} - -export interface VMixMix { - number: number - program: string | number | undefined - preview: string | number | undefined - transition: VMixTransition - layerToProgram?: boolean -} - -export interface VMixInput { - number?: number - type?: VMixInputType | string - name?: string - filePath?: string - state?: 'Paused' | 'Running' | 'Completed' - playing?: boolean - position?: number - duration?: number - loop?: boolean - muted?: boolean - volume?: number - balance?: number - fade?: number - solo?: boolean - audioBuses?: string - audioAuto?: boolean - transform?: VMixTransform - overlays?: VMixInputOverlays - listFilePaths?: string[] - restart?: boolean -} - -export interface VMixOverlay { - number: number - input: string | number | undefined -} - -export interface VMixAudioChannel { - volume: number - muted: boolean - meterF1: number - meterF2: number - headphonesVolume: number -} diff --git a/packages/timeline-state-resolver/src/integrations/vmix/vMixCommands.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixCommands.ts new file mode 100644 index 000000000..83aad4f05 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/vmix/vMixCommands.ts @@ -0,0 +1,229 @@ +import { VMixCommand } from 'timeline-state-resolver-types' + +export interface VMixStateCommandBase { + command: VMixCommand +} +export interface VMixStateCommandPreviewInput extends VMixStateCommandBase { + command: VMixCommand.PREVIEW_INPUT + input: number | string + mix: number +} +export interface VMixStateCommandTransition extends VMixStateCommandBase { + command: VMixCommand.TRANSITION + input: number | string + effect: string + duration: number + mix: number +} +export interface VMixStateCommandAudio extends VMixStateCommandBase { + command: VMixCommand.AUDIO_VOLUME + input: number | string + value: number + fade?: number +} +export interface VMixStateCommandAudioBalance extends VMixStateCommandBase { + command: VMixCommand.AUDIO_BALANCE + input: number | string + value: number +} +export interface VMixStateCommandAudioOn extends VMixStateCommandBase { + command: VMixCommand.AUDIO_ON + input: number | string +} +export interface VMixStateCommandAudioOff extends VMixStateCommandBase { + command: VMixCommand.AUDIO_OFF + input: number | string +} +export interface VMixStateCommandAudioAutoOn extends VMixStateCommandBase { + command: VMixCommand.AUDIO_AUTO_ON + input: number | string +} +export interface VMixStateCommandAudioAutoOff extends VMixStateCommandBase { + command: VMixCommand.AUDIO_AUTO_OFF + input: number | string +} +export interface VMixStateCommandAudioBusOn extends VMixStateCommandBase { + command: VMixCommand.AUDIO_BUS_ON + input: number | string + value: string +} +export interface VMixStateCommandAudioBusOff extends VMixStateCommandBase { + command: VMixCommand.AUDIO_BUS_OFF + input: number | string + value: string +} +export interface VMixStateCommandFader extends VMixStateCommandBase { + command: VMixCommand.FADER + value: number +} +export interface VMixStateCommandSetPanX extends VMixStateCommandBase { + command: VMixCommand.SET_PAN_X + input: number | string + value: number +} +export interface VMixStateCommandSetPanY extends VMixStateCommandBase { + command: VMixCommand.SET_PAN_Y + input: number | string + value: number +} +export interface VMixStateCommandSetZoom extends VMixStateCommandBase { + command: VMixCommand.SET_ZOOM + input: number | string + value: number +} +export interface VMixStateCommandSetAlpha extends VMixStateCommandBase { + command: VMixCommand.SET_ALPHA + input: number | string + value: number +} +export interface VMixStateCommandStartStreaming extends VMixStateCommandBase { + command: VMixCommand.START_STREAMING +} +export interface VMixStateCommandStopStreaming extends VMixStateCommandBase { + command: VMixCommand.STOP_STREAMING +} +export interface VMixStateCommandStartRecording extends VMixStateCommandBase { + command: VMixCommand.START_RECORDING +} +export interface VMixStateCommandStopRecording extends VMixStateCommandBase { + command: VMixCommand.STOP_RECORDING +} +export interface VMixStateCommandFadeToBlack extends VMixStateCommandBase { + command: VMixCommand.FADE_TO_BLACK +} +export interface VMixStateCommandAddInput extends VMixStateCommandBase { + command: VMixCommand.ADD_INPUT + value: string +} +export interface VMixStateCommandRemoveInput extends VMixStateCommandBase { + command: VMixCommand.REMOVE_INPUT + input: string +} +export interface VMixStateCommandPlayInput extends VMixStateCommandBase { + command: VMixCommand.PLAY_INPUT + input: number | string +} +export interface VMixStateCommandPauseInput extends VMixStateCommandBase { + command: VMixCommand.PAUSE_INPUT + input: number | string +} +export interface VMixStateCommandSetPosition extends VMixStateCommandBase { + command: VMixCommand.SET_POSITION + input: number | string + value: number +} +export interface VMixStateCommandLoopOn extends VMixStateCommandBase { + command: VMixCommand.LOOP_ON + input: number | string +} +export interface VMixStateCommandLoopOff extends VMixStateCommandBase { + command: VMixCommand.LOOP_OFF + input: number | string +} +export interface VMixStateCommandSetInputName extends VMixStateCommandBase { + command: VMixCommand.SET_INPUT_NAME + input: number | string + value: string +} +export interface VMixStateCommandSetOuput extends VMixStateCommandBase { + command: VMixCommand.SET_OUPUT + name: string + value: string + input?: number | string +} +export interface VMixStateCommandStartExternal extends VMixStateCommandBase { + command: VMixCommand.START_EXTERNAL +} +export interface VMixStateCommandStopExternal extends VMixStateCommandBase { + command: VMixCommand.STOP_EXTERNAL +} +export interface VMixStateCommandOverlayInputIn extends VMixStateCommandBase { + command: VMixCommand.OVERLAY_INPUT_IN + value: number + input: string | number +} +export interface VMixStateCommandOverlayInputOut extends VMixStateCommandBase { + command: VMixCommand.OVERLAY_INPUT_OUT + value: number +} +export interface VMixStateCommandSetInputOverlay extends VMixStateCommandBase { + command: VMixCommand.SET_INPUT_OVERLAY + input: string | number + index: number + value: string | number +} +export interface VMixStateCommandScriptStart extends VMixStateCommandBase { + command: VMixCommand.SCRIPT_START + value: string +} +export interface VMixStateCommandScriptStop extends VMixStateCommandBase { + command: VMixCommand.SCRIPT_STOP + value: string +} +export interface VMixStateCommandScriptStopAll extends VMixStateCommandBase { + command: VMixCommand.SCRIPT_STOP_ALL +} +export interface VMixStateCommandListAdd extends VMixStateCommandBase { + command: VMixCommand.LIST_ADD + input: string | number + value: string +} +export interface VMixStateCommandListRemoveAll extends VMixStateCommandBase { + command: VMixCommand.LIST_REMOVE_ALL + input: string | number +} +export interface VMixStateCommandRestart extends VMixStateCommandBase { + command: VMixCommand.RESTART_INPUT + input: string | number +} +export type VMixStateCommand = + | VMixStateCommandPreviewInput + | VMixStateCommandTransition + | VMixStateCommandAudio + | VMixStateCommandAudioBalance + | VMixStateCommandAudioOn + | VMixStateCommandAudioOff + | VMixStateCommandAudioAutoOn + | VMixStateCommandAudioAutoOff + | VMixStateCommandAudioBusOn + | VMixStateCommandAudioBusOff + | VMixStateCommandFader + | VMixStateCommandSetZoom + | VMixStateCommandSetPanX + | VMixStateCommandSetPanY + | VMixStateCommandSetAlpha + | VMixStateCommandStartStreaming + | VMixStateCommandStopStreaming + | VMixStateCommandStartRecording + | VMixStateCommandStopRecording + | VMixStateCommandFadeToBlack + | VMixStateCommandAddInput + | VMixStateCommandRemoveInput + | VMixStateCommandPlayInput + | VMixStateCommandPauseInput + | VMixStateCommandSetPosition + | VMixStateCommandLoopOn + | VMixStateCommandLoopOff + | VMixStateCommandSetInputName + | VMixStateCommandSetOuput + | VMixStateCommandStartExternal + | VMixStateCommandStopExternal + | VMixStateCommandOverlayInputIn + | VMixStateCommandOverlayInputOut + | VMixStateCommandSetInputOverlay + | VMixStateCommandScriptStart + | VMixStateCommandScriptStop + | VMixStateCommandScriptStopAll + | VMixStateCommandListAdd + | VMixStateCommandListRemoveAll + | VMixStateCommandRestart + +export enum CommandContext { + None = 'none', + Retry = 'retry', +} +export interface VMixStateCommandWithContext { + command: VMixStateCommand + context: CommandContext + timelineId: string +} diff --git a/packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts new file mode 100644 index 000000000..13a2cf180 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts @@ -0,0 +1,846 @@ +import { + VMixCommand, + VMixInputOverlays, + VMixInputType, + VMixTransform, + VMixTransition, + VMixTransitionType, +} from 'timeline-state-resolver-types' +import { CommandContext, VMixStateCommandWithContext } from './vMixCommands' +import _ = require('underscore') +import path = require('node:path') + +export interface VMixStateExtended { + /** + * The state of vMix (as far as we know) as reported by vMix **+ + * our expectations based on the commands we've set**. + */ + reportedState: VMixState + outputs: { + External2: VMixOutput + + '2': VMixOutput + '3': VMixOutput + '4': VMixOutput + + Fullscreen: VMixOutput + Fullscreen2: VMixOutput + } + inputLayers: { [key: string]: string } + runningScripts: string[] +} + +export interface VMixState { + version: string + edition: string // TODO: Enuum, need list of available editions: Trial + fixedInputsCount: number + inputs: { [key: string]: VMixInput } + overlays: VMixOverlay[] + mixes: VMixMix[] + fadeToBlack: boolean + faderPosition?: number + recording: boolean + external: boolean + streaming: boolean + playlist: boolean + multiCorder: boolean + fullscreen: boolean + audio: VMixAudioChannel[] +} + +export interface VMixMix { + number: number + program: string | number | undefined + preview: string | number | undefined + transition: VMixTransition + layerToProgram?: boolean +} + +export interface VMixInput { + number?: number + type?: VMixInputType | string + name?: string + filePath?: string + state?: 'Paused' | 'Running' | 'Completed' + playing?: boolean + position?: number + duration?: number + loop?: boolean + muted?: boolean + volume?: number + balance?: number + fade?: number + solo?: boolean + audioBuses?: string + audioAuto?: boolean + transform?: VMixTransform + overlays?: VMixInputOverlays + listFilePaths?: string[] + restart?: boolean +} + +export interface VMixOutput { + source: 'Preview' | 'Program' | 'MultiView' | 'Input' + input?: number | string +} + +export interface VMixOverlay { + number: number + input: string | number | undefined +} + +export interface VMixAudioChannel { + volume: number + muted: boolean + meterF1: number + meterF2: number + headphonesVolume: number +} + +export class VMixStateDiffer { + constructor( + private readonly getFixedInputsCount: () => number, + private readonly queueNow: (commands: VMixStateCommandWithContext[]) => void + ) {} + + getCommandsToAchieveState(oldVMixState: VMixStateExtended, newVMixState: VMixStateExtended) { + let commands: Array = [] + + const inputCommands = this._resolveInputsState(oldVMixState, newVMixState) + commands = commands.concat(inputCommands.preTransitionCommands) + commands = commands.concat(this._resolveMixState(oldVMixState, newVMixState)) + commands = commands.concat(this._resolveOverlaysState(oldVMixState, newVMixState)) + commands = commands.concat(inputCommands.postTransitionCommands) + commands = commands.concat(this._resolveRecordingState(oldVMixState, newVMixState)) + commands = commands.concat(this._resolveStreamingState(oldVMixState, newVMixState)) + commands = commands.concat(this._resolveExternalState(oldVMixState, newVMixState)) + commands = commands.concat(this._resolveOutputsState(oldVMixState, newVMixState)) + commands = commands.concat(this._resolveInputsRemovalState(oldVMixState, newVMixState)) + commands = commands.concat(this._resolveScriptsState(oldVMixState, newVMixState)) + + return commands + } + + getDefaultState(): VMixStateExtended { + return { + reportedState: { + version: '', + edition: '', + fixedInputsCount: 0, + inputs: this._getDefaultInputsState(this.getFixedInputsCount()), + overlays: _.map([1, 2, 3, 4, 5, 6], (num) => { + return { + number: num, + input: undefined, + } + }), + mixes: _.map([1, 2, 3, 4], (num) => { + return { + number: num, + program: undefined, + preview: undefined, + transition: { effect: VMixTransitionType.Cut, duration: 0 }, + } + }), + fadeToBlack: false, + faderPosition: 0, + recording: false, + external: false, + streaming: false, + playlist: false, + multiCorder: false, + fullscreen: false, + audio: [], + }, + outputs: { + '2': { source: 'Program' }, + '3': { source: 'Program' }, + '4': { source: 'Program' }, + External2: { source: 'Program' }, + Fullscreen: { source: 'Program' }, + Fullscreen2: { source: 'Program' }, + }, + inputLayers: {}, + runningScripts: [], + } + } + + getDefaultInputState(num: number): VMixInput { + return { + number: num, + position: 0, + muted: true, + loop: false, + playing: false, + volume: 100, + balance: 0, + fade: 0, + audioBuses: 'M', + audioAuto: true, + transform: { + zoom: 1, + panX: 0, + panY: 0, + alpha: 255, + }, + overlays: {}, + } + } + + private _getDefaultInputsState(count: number): { [key: string]: VMixInput } { + const defaultInputs: { [key: string]: VMixInput } = {} + for (let i = 1; i <= count; i++) { + defaultInputs[i] = this.getDefaultInputState(i) + } + return defaultInputs + } + + private _resolveMixState( + oldVMixState: VMixStateExtended, + newVMixState: VMixStateExtended + ): Array { + const commands: Array = [] + for (let i = 0; i < 4; i++) { + /** + * It is *not* guaranteed to have all 4 mixes present in the vMix state, for reasons unknown. + */ + const oldMixState = oldVMixState.reportedState.mixes[i] as VMixMix | undefined + const newMixState = newVMixState.reportedState.mixes[i] as VMixMix | undefined + if (newMixState?.program !== undefined) { + let nextInput = newMixState.program + let changeOnLayer = false + if (newMixState.layerToProgram) { + nextInput = newVMixState.inputLayers[newMixState.program] + changeOnLayer = + newVMixState.inputLayers[newMixState.program] !== oldVMixState.inputLayers[newMixState.program] + } + if (oldMixState?.program !== newMixState.program || changeOnLayer) { + commands.push({ + command: { + command: VMixCommand.TRANSITION, + effect: changeOnLayer ? VMixTransitionType.Cut : newMixState.transition.effect, + input: nextInput, + duration: changeOnLayer ? 0 : newMixState.transition.duration, + mix: i, + }, + context: CommandContext.None, + timelineId: '', + }) + } + } + + if ( + oldMixState?.program === newMixState?.program && // if we're not switching what is on program, because it could break a transition + newMixState?.preview !== undefined && + newMixState.preview !== oldMixState?.preview + ) { + commands.push({ + command: { + command: VMixCommand.PREVIEW_INPUT, + input: newMixState.preview, + mix: i, + }, + context: CommandContext.None, + timelineId: '', + }) + } + } + // Only set fader bar position if no other transitions are happening + if (oldVMixState.reportedState.mixes[0].program === newVMixState.reportedState.mixes[0].program) { + if (newVMixState.reportedState.faderPosition !== oldVMixState.reportedState.faderPosition) { + commands.push({ + command: { + command: VMixCommand.FADER, + value: newVMixState.reportedState.faderPosition || 0, + }, + context: CommandContext.None, + timelineId: '', + }) + // newVMixState.reportedState.program = undefined + // newVMixState.reportedState.preview = undefined + newVMixState.reportedState.fadeToBlack = false + } + } + if (oldVMixState.reportedState.fadeToBlack !== newVMixState.reportedState.fadeToBlack) { + // Danger: Fade to black is toggled, we can't explicitly say that we want it on or off + commands.push({ + command: { + command: VMixCommand.FADE_TO_BLACK, + }, + context: CommandContext.None, + timelineId: '', + }) + } + return commands + } + + private _resolveInputsState( + oldVMixState: VMixStateExtended, + newVMixState: VMixStateExtended + ): { + preTransitionCommands: Array + postTransitionCommands: Array + } { + const preTransitionCommands: Array = [] + const postTransitionCommands: Array = [] + _.each(newVMixState.reportedState.inputs, (input, key) => { + if (input.name === undefined) { + input.name = key + } + if (!_.has(oldVMixState.reportedState.inputs, key) && input.type !== undefined) { + const addCommands: Array = [] + addCommands.push({ + command: { + command: VMixCommand.ADD_INPUT, + value: `${input.type}|${input.name}`, + }, + context: CommandContext.None, + timelineId: '', + }) + addCommands.push({ + command: { + command: VMixCommand.SET_INPUT_NAME, + input: this._getFilename(input.name), + value: input.name, + }, + context: CommandContext.None, + timelineId: '', + }) + this.queueNow(addCommands) + } + const oldInput = oldVMixState.reportedState.inputs[key] || this.getDefaultInputState(0) // or {} but we assume that a new input has all parameters default + + /** + * If an input is currently on air, then we delay changes to it until after the transition has began. + * Note the word "began", instead of "completed". + * + * This mostly helps in the case of CUT transitions, where in theory everything happens + * on the same frame but, in reality, thanks to how vMix processes API commands, + * things take place over the course of a few frames. + */ + const commands = this._isInUse(oldVMixState, oldInput) ? postTransitionCommands : preTransitionCommands + + // It is important that the operations on listFilePaths happen before most other operations. + // Consider the case where we want to change the contents of a List input AND set it to playing. + // If we set it to playing first, it will automatically be forced to stop playing when + // we dispatch LIST_REMOVE_ALL. + // So, order of operations matters here. + if (!_.isEqual(oldInput.listFilePaths, input.listFilePaths)) { + // vMix has a quirk that we are working around here: + // When a List input has no items, its Play/Pause button becomes inactive and + // clicking it does nothing. However, if the List was playing when it was emptied, + // it'll remain in a playing state. This means that as soon as new content is + // added to the playlist, it will immediately begin playing. This feels like a + // bug/mistake/otherwise unwanted behavior in every scenario. To work around this, + // we automatically dispatch a PAUSE_INPUT command before emptying the playlist, + // but only if there's no new content being added afterward. + if (!input.listFilePaths || (Array.isArray(input.listFilePaths) && input.listFilePaths.length <= 0)) { + commands.push({ + command: { + command: VMixCommand.PAUSE_INPUT, + input: input.name, + }, + context: CommandContext.None, + timelineId: '', + }) + } + commands.push({ + command: { + command: VMixCommand.LIST_REMOVE_ALL, + input: input.name, + }, + context: CommandContext.None, + timelineId: '', + }) + if (Array.isArray(input.listFilePaths)) { + for (const filePath of input.listFilePaths) { + commands.push({ + command: { + command: VMixCommand.LIST_ADD, + input: input.name, + value: filePath, + }, + context: CommandContext.None, + timelineId: '', + }) + } + } + } + if (input.playing !== undefined && oldInput.playing !== input.playing && !input.playing) { + commands.push({ + command: { + command: VMixCommand.PAUSE_INPUT, + input: input.name, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if (oldInput.position !== input.position) { + commands.push({ + command: { + command: VMixCommand.SET_POSITION, + input: key, + value: input.position ? input.position : 0, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if (input.restart !== undefined && oldInput.restart !== input.restart && input.restart) { + commands.push({ + command: { + command: VMixCommand.RESTART_INPUT, + input: key, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if (input.loop !== undefined && oldInput.loop !== input.loop) { + if (input.loop) { + commands.push({ + command: { + command: VMixCommand.LOOP_ON, + input: input.name, + }, + context: CommandContext.None, + timelineId: '', + }) + } else { + commands.push({ + command: { + command: VMixCommand.LOOP_OFF, + input: input.name, + }, + context: CommandContext.None, + timelineId: '', + }) + } + } + if (input.muted !== undefined && oldInput.muted !== input.muted && input.muted) { + commands.push({ + command: { + command: VMixCommand.AUDIO_OFF, + input: key, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if (oldInput.volume !== input.volume && input.volume !== undefined) { + commands.push({ + command: { + command: VMixCommand.AUDIO_VOLUME, + input: key, + value: input.volume, + fade: input.fade, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if (oldInput.balance !== input.balance && input.balance !== undefined) { + commands.push({ + command: { + command: VMixCommand.AUDIO_BALANCE, + input: key, + value: input.balance, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if (input.audioAuto !== undefined && oldInput.audioAuto !== input.audioAuto) { + if (!input.audioAuto) { + commands.push({ + command: { + command: VMixCommand.AUDIO_AUTO_OFF, + input: key, + }, + context: CommandContext.None, + timelineId: '', + }) + } else { + commands.push({ + command: { + command: VMixCommand.AUDIO_AUTO_ON, + input: key, + }, + context: CommandContext.None, + timelineId: '', + }) + } + } + if (input.audioBuses !== undefined && oldInput.audioBuses !== input.audioBuses) { + const oldBuses = (oldInput.audioBuses || '').split(',').filter((x) => x) + const newBuses = input.audioBuses.split(',').filter((x) => x) + _.difference(newBuses, oldBuses).forEach((bus) => { + commands.push({ + command: { + command: VMixCommand.AUDIO_BUS_ON, + input: key, + value: bus, + }, + context: CommandContext.None, + timelineId: '', + }) + }) + _.difference(oldBuses, newBuses).forEach((bus) => { + commands.push({ + command: { + command: VMixCommand.AUDIO_BUS_OFF, + input: key, + value: bus, + }, + context: CommandContext.None, + timelineId: '', + }) + }) + } + if (input.muted !== undefined && oldInput.muted !== input.muted && !input.muted) { + commands.push({ + command: { + command: VMixCommand.AUDIO_ON, + input: key, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if (input.transform !== undefined && !_.isEqual(oldInput.transform, input.transform)) { + if (oldInput.transform === undefined || input.transform.zoom !== oldInput.transform.zoom) { + commands.push({ + command: { + command: VMixCommand.SET_ZOOM, + input: key, + value: input.transform.zoom, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if (oldInput.transform === undefined || input.transform.alpha !== oldInput.transform.alpha) { + commands.push({ + command: { + command: VMixCommand.SET_ALPHA, + input: key, + value: input.transform.alpha, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if (oldInput.transform === undefined || input.transform.panX !== oldInput.transform.panX) { + commands.push({ + command: { + command: VMixCommand.SET_PAN_X, + input: key, + value: input.transform.panX, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if (oldInput.transform === undefined || input.transform.panY !== oldInput.transform.panY) { + commands.push({ + command: { + command: VMixCommand.SET_PAN_Y, + input: key, + value: input.transform.panY, + }, + context: CommandContext.None, + timelineId: '', + }) + } + } + if (input.overlays !== undefined && !_.isEqual(oldInput.overlays, input.overlays)) { + Object.keys(input.overlays).forEach((index) => { + if (input.overlays !== oldInput.overlays?.[index]) { + commands.push({ + command: { + command: VMixCommand.SET_INPUT_OVERLAY, + input: key, + value: input.overlays![Number(index)], + index: Number(index), + }, + context: CommandContext.None, + timelineId: '', + }) + } + }) + Object.keys(oldInput?.overlays || {}).forEach((index) => { + if (!input.overlays?.[index]) { + commands.push({ + command: { + command: VMixCommand.SET_INPUT_OVERLAY, + input: key, + value: '', + index: Number(index), + }, + context: CommandContext.None, + timelineId: '', + }) + } + }) + } + if (input.playing !== undefined && oldInput.playing !== input.playing && input.playing) { + commands.push({ + command: { + command: VMixCommand.PLAY_INPUT, + input: input.name, + }, + context: CommandContext.None, + timelineId: '', + }) + } + }) + return { preTransitionCommands, postTransitionCommands } + } + + private _resolveInputsRemovalState( + oldVMixState: VMixStateExtended, + newVMixState: VMixStateExtended + ): Array { + const commands: Array = [] + _.difference( + Object.keys(oldVMixState.reportedState.inputs), + Object.keys(newVMixState.reportedState.inputs) + ).forEach((input) => { + if (oldVMixState.reportedState.inputs[input].type !== undefined) { + // TODO: either schedule this command for later or make the timeline object long enough to prevent removing while transitioning + commands.push({ + command: { + command: VMixCommand.REMOVE_INPUT, + input: oldVMixState.reportedState.inputs[input].name || input, + }, + context: CommandContext.None, + timelineId: '', + }) + } + }) + return commands + } + + private _resolveOverlaysState( + oldVMixState: VMixStateExtended, + newVMixState: VMixStateExtended + ): Array { + const commands: Array = [] + _.each(newVMixState.reportedState.overlays, (overlay, index) => { + const oldOverlay = oldVMixState.reportedState.overlays[index] + if (oldOverlay?.input !== overlay.input) { + if (overlay.input === undefined) { + commands.push({ + command: { + command: VMixCommand.OVERLAY_INPUT_OUT, + value: overlay.number, + }, + context: CommandContext.None, + timelineId: '', + }) + } else { + commands.push({ + command: { + command: VMixCommand.OVERLAY_INPUT_IN, + input: overlay.input, + value: overlay.number, + }, + context: CommandContext.None, + timelineId: '', + }) + } + } + }) + return commands + } + + private _resolveRecordingState( + oldVMixState: VMixStateExtended, + newVMixState: VMixStateExtended + ): Array { + const commands: Array = [] + if (oldVMixState.reportedState.recording !== newVMixState.reportedState.recording) { + if (newVMixState.reportedState.recording) { + commands.push({ + command: { + command: VMixCommand.START_RECORDING, + }, + context: CommandContext.None, + timelineId: '', + }) + } else { + commands.push({ + command: { + command: VMixCommand.STOP_RECORDING, + }, + context: CommandContext.None, + timelineId: '', + }) + } + } + return commands + } + + private _resolveStreamingState( + oldVMixState: VMixStateExtended, + newVMixState: VMixStateExtended + ): Array { + const commands: Array = [] + if (oldVMixState.reportedState.streaming !== newVMixState.reportedState.streaming) { + if (newVMixState.reportedState.streaming) { + commands.push({ + command: { + command: VMixCommand.START_STREAMING, + }, + context: CommandContext.None, + timelineId: '', + }) + } else { + commands.push({ + command: { + command: VMixCommand.STOP_STREAMING, + }, + context: CommandContext.None, + timelineId: '', + }) + } + } + return commands + } + + private _resolveExternalState( + oldVMixState: VMixStateExtended, + newVMixState: VMixStateExtended + ): Array { + const commands: Array = [] + if (oldVMixState.reportedState.external !== newVMixState.reportedState.external) { + if (newVMixState.reportedState.external) { + commands.push({ + command: { + command: VMixCommand.START_EXTERNAL, + }, + context: CommandContext.None, + timelineId: '', + }) + } else { + commands.push({ + command: { + command: VMixCommand.STOP_EXTERNAL, + }, + context: CommandContext.None, + timelineId: '', + }) + } + } + return commands + } + + private _resolveOutputsState( + oldVMixState: VMixStateExtended, + newVMixState: VMixStateExtended + ): Array { + const commands: Array = [] + _.map(newVMixState.outputs, (output, name) => { + const nameKey = name as keyof VMixStateExtended['outputs'] + const oldOutput = nameKey in oldVMixState.outputs ? oldVMixState.outputs[nameKey] : undefined + if (!_.isEqual(output, oldOutput)) { + const value = output.source === 'Program' ? 'Output' : output.source + commands.push({ + command: { + command: VMixCommand.SET_OUPUT, + value, + input: output.input, + name, + }, + context: CommandContext.None, + timelineId: '', + }) + } + }) + return commands + } + + private _resolveScriptsState( + oldVMixState: VMixStateExtended, + newVMixState: VMixStateExtended + ): Array { + const commands: Array = [] + _.map(newVMixState.runningScripts, (name) => { + const alreadyRunning = oldVMixState.runningScripts.includes(name) + if (!alreadyRunning) { + commands.push({ + command: { + command: VMixCommand.SCRIPT_START, + value: name, + }, + context: CommandContext.None, + timelineId: '', + }) + } + }) + _.map(oldVMixState.runningScripts, (name) => { + const noLongerDesired = !newVMixState.runningScripts.includes(name) + if (noLongerDesired) { + commands.push({ + command: { + command: VMixCommand.SCRIPT_STOP, + value: name, + }, + context: CommandContext.None, + timelineId: '', + }) + } + }) + return commands + } + + /** + * Checks if TSR thinks an input is currently in-use. + * Not guaranteed to align with reality. + */ + private _isInUse(state: VMixStateExtended, input: VMixInput): boolean { + for (const mix of state.reportedState.mixes) { + if (mix.program === input.number || mix.program === input.name) { + // The input is in program in some mix, so stop the search and return true. + return true + } + + if (typeof mix.program === 'undefined') continue + + const pgmInput = state.reportedState.inputs[mix.program] as VMixInput | undefined + if (!pgmInput || !pgmInput.overlays) continue + + for (const layer of Object.keys(pgmInput.overlays)) { + const layerInput = pgmInput.overlays[layer as unknown as keyof VMixInputOverlays] + if (layerInput === input.name || layerInput === input.number) { + // Input is in program as a layer of a Multi View of something else that is in program, + // so stop the search and return true. + return true + } + } + } + + for (const overlay of state.reportedState.overlays) { + if (overlay.input === input.name || overlay.input === input.number) { + // Input is in program as an overlay (DSK), + // so stop the search and return true. + return true + } + } + + for (const output of Object.values(state.outputs)) { + if (output.input === input.name || output.input === input.number) { + // Input might not technically be in PGM, but it's being used by an output, + // so stop the search and return true. + return true + } + } + + return false + } + + private _getFilename(filePath: string) { + return path.basename(filePath) + } +} diff --git a/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts new file mode 100644 index 000000000..ae6e13895 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts @@ -0,0 +1,206 @@ +import { + DeviceType, + Mapping, + MappingVmixType, + Mappings, + SomeMappingVmix, + TSRTimelineContent, + Timeline, + TimelineContentTypeVMix, + VMixTransition, + VMixTransitionType, +} from 'timeline-state-resolver-types' +import { VMixInput, VMixStateExtended } from './vMixStateDiffer' +import * as deepMerge from 'deepmerge' +import _ = require('underscore') + +const mappingPriority: { [k in MappingVmixType]: number } = { + [MappingVmixType.Program]: 0, + [MappingVmixType.Preview]: 1, + [MappingVmixType.Input]: 2, // order of Input and AudioChannel matters because of the way layers are sorted + [MappingVmixType.AudioChannel]: 3, + [MappingVmixType.Output]: 4, + [MappingVmixType.Overlay]: 5, + [MappingVmixType.Recording]: 6, + [MappingVmixType.Streaming]: 7, + [MappingVmixType.External]: 8, + [MappingVmixType.FadeToBlack]: 9, + [MappingVmixType.Fader]: 10, + [MappingVmixType.Script]: 11, +} + +export class VMixTimelineStateConverter { + constructor( + private readonly getDefaultState: () => VMixStateExtended, + private readonly getDefaultInputState: (num: number) => VMixInput + ) {} + + getVMixStateFromTimelineState( + state: Timeline.TimelineState, + mappings: Mappings + ): VMixStateExtended { + const deviceState = this.getDefaultState() + + // Sort layer based on Mapping type (to make sure audio is after inputs) and Layer name + const sortedLayers = _.sortBy( + _.map(state.layers, (tlObject, layerName) => ({ + layerName, + tlObject, + mapping: mappings[layerName] as Mapping, + })).sort((a, b) => a.layerName.localeCompare(b.layerName)), + (o) => mappingPriority[o.mapping.options.mappingType] ?? Number.POSITIVE_INFINITY + ) + + _.each(sortedLayers, ({ tlObject, layerName, mapping }) => { + const content = tlObject.content + + if (mapping && content.deviceType === DeviceType.VMIX) { + switch (mapping.options.mappingType) { + case MappingVmixType.Program: + if (content.type === TimelineContentTypeVMix.PROGRAM) { + const mixProgram = (mapping.options.index || 1) - 1 + if (content.input !== undefined) { + this.switchToInput(content.input, deviceState, mixProgram, content.transition) + } else if (content.inputLayer) { + this.switchToInput(content.inputLayer, deviceState, mixProgram, content.transition, true) + } + } + break + case MappingVmixType.Preview: + if (content.type === TimelineContentTypeVMix.PREVIEW) { + const mixPreview = (mapping.options.index || 1) - 1 + if (content.input) deviceState.reportedState.mixes[mixPreview].preview = content.input + } + break + case MappingVmixType.AudioChannel: + if (content.type === TimelineContentTypeVMix.AUDIO) { + const vmixTlAudioPicked = _.pick(content, 'volume', 'balance', 'audioAuto', 'audioBuses', 'muted', 'fade') + if (mapping.options.index) { + deviceState.reportedState.inputs = this.modifyInput(deviceState, vmixTlAudioPicked, { + key: mapping.options.index, + }) + } else if (mapping.options.inputLayer) { + deviceState.reportedState.inputs = this.modifyInput(deviceState, vmixTlAudioPicked, { + layer: mapping.options.inputLayer, + }) + } + } + break + case MappingVmixType.Fader: + if (content.type === TimelineContentTypeVMix.FADER) { + deviceState.reportedState.faderPosition = content.position + } + break + case MappingVmixType.Recording: + if (content.type === TimelineContentTypeVMix.RECORDING) { + deviceState.reportedState.recording = content.on + } + break + case MappingVmixType.Streaming: + if (content.type === TimelineContentTypeVMix.STREAMING) { + deviceState.reportedState.streaming = content.on + } + break + case MappingVmixType.External: + if (content.type === TimelineContentTypeVMix.EXTERNAL) { + deviceState.reportedState.external = content.on + } + break + case MappingVmixType.FadeToBlack: + if (content.type === TimelineContentTypeVMix.FADE_TO_BLACK) { + deviceState.reportedState.fadeToBlack = content.on + } + break + case MappingVmixType.Input: + if (content.type === TimelineContentTypeVMix.INPUT) { + deviceState.reportedState.inputs = this.modifyInput( + deviceState, + { + type: content.inputType, + playing: content.playing, + loop: content.loop, + position: content.seek, + transform: content.transform, + overlays: content.overlays, + listFilePaths: content.listFilePaths, + restart: content.restart, + }, + + { key: mapping.options.index || content.filePath }, + layerName + ) + } + break + case MappingVmixType.Output: + if (content.type === TimelineContentTypeVMix.OUTPUT) { + deviceState.outputs[mapping.options.index] = { + source: content.source, + input: content.input, + } + } + break + case MappingVmixType.Overlay: + if (content.type === TimelineContentTypeVMix.OVERLAY) { + const overlayIndex = mapping.options.index - 1 + deviceState.reportedState.overlays[overlayIndex].input = content.input + } + break + case MappingVmixType.Script: + if (content.type === TimelineContentTypeVMix.SCRIPT) { + deviceState.runningScripts.push(content.name) + } + break + } + } + }) + return deviceState + } + + modifyInput( + deviceState: VMixStateExtended, + newInput: VMixInput, + input: { key?: string | number; layer?: string }, + layerName?: string + ): { [key: string]: VMixInput } { + const inputs = deviceState.reportedState.inputs + const newInputPicked = _.pick(newInput, (x) => !_.isUndefined(x)) + let inputKey: string | number | undefined + if (input.layer) { + inputKey = deviceState.inputLayers[input.layer] + } else { + inputKey = input.key! + } + if (inputKey) { + if (inputKey in inputs) { + inputs[inputKey] = deepMerge(inputs[inputKey], newInputPicked) + } else { + const inputState = this.getDefaultInputState(0) + inputs[inputKey] = deepMerge(inputState, newInputPicked) + } + if (layerName) { + deviceState.inputLayers[layerName] = inputKey as string + } + } + return inputs + } + + switchToInput( + input: number | string, + deviceState: VMixStateExtended, + mix: number, + transition?: VMixTransition, + layerToProgram = false + ) { + const mixState = deviceState.reportedState.mixes[mix] + if ( + mixState.program === undefined || + mixState.program !== input // mixing numeric and string input names can be dangerous + ) { + mixState.preview = mixState.program + mixState.program = input + + mixState.transition = transition || { effect: VMixTransitionType.Cut, duration: 0 } + mixState.layerToProgram = layerToProgram + } + } +} From 3d25f2637b725d544fd3a981d6500c7daeb3c42f Mon Sep 17 00:00:00 2001 From: ianshade Date: Fri, 15 Dec 2023 19:15:39 +0100 Subject: [PATCH 2/6] refactor(vMix): extract more and fix how inputs added by TSR are handled --- .../timeline-state-resolver-types/src/vmix.ts | 2 +- .../src/integrations/vmix/VMixPollingTimer.ts | 48 ++ .../vmix/VMixStateSynchronizer.ts | 63 ++ .../integrations/vmix/VMixXmlStateParser.ts | 121 ++++ .../vmix/__tests__/VMixPollingTimer.spec.ts | 114 ++++ .../__tests__/VMixStateSynchronizer.spec.ts | 234 +++++++ .../vmix/__tests__/VMixXmlStateParser.spec.ts | 164 +++++ .../integrations/vmix/__tests__/vmix.spec.ts | 103 +-- .../vmix/__tests__/vmixAPI.spec.ts | 112 +--- .../integrations/vmix/__tests__/vmixMock.ts | 11 +- .../src/integrations/vmix/connection.ts | 120 +--- .../src/integrations/vmix/index.ts | 180 ++--- .../src/integrations/vmix/vMixStateDiffer.ts | 629 +++++++++--------- .../vmix/vMixTimelineStateConverter.ts | 79 ++- 14 files changed, 1297 insertions(+), 683 deletions(-) create mode 100644 packages/timeline-state-resolver/src/integrations/vmix/VMixPollingTimer.ts create mode 100644 packages/timeline-state-resolver/src/integrations/vmix/VMixStateSynchronizer.ts create mode 100644 packages/timeline-state-resolver/src/integrations/vmix/VMixXmlStateParser.ts create mode 100644 packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixPollingTimer.spec.ts create mode 100644 packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixStateSynchronizer.spec.ts create mode 100644 packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixXmlStateParser.spec.ts diff --git a/packages/timeline-state-resolver-types/src/vmix.ts b/packages/timeline-state-resolver-types/src/vmix.ts index 2e982fe8e..0c54b0698 100644 --- a/packages/timeline-state-resolver-types/src/vmix.ts +++ b/packages/timeline-state-resolver-types/src/vmix.ts @@ -150,7 +150,7 @@ export interface TimelineContentVMixInput extends TimelineContentVMixBase { type: TimelineContentTypeVMix.INPUT /** Media file path */ - filePath?: number | string + filePath?: string /** Set only when dealing with media. If provided, TSR will attempt to automatically create **and potentially remove** the input. */ inputType?: VMixInputType diff --git a/packages/timeline-state-resolver/src/integrations/vmix/VMixPollingTimer.ts b/packages/timeline-state-resolver/src/integrations/vmix/VMixPollingTimer.ts new file mode 100644 index 000000000..68130a6a3 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/vmix/VMixPollingTimer.ts @@ -0,0 +1,48 @@ +import { EventEmitter } from 'eventemitter3' + +export type TimerEvents = { + tick: [] +} + +/** + * A timer that once started, ticks in intevals + * Allows the next tick to be postponed + */ +export class VMixPollingTimer extends EventEmitter { + private pollTimeout: NodeJS.Timeout | null = null + + constructor(private readonly pollIntervalMs: number) { + super() + if (pollIntervalMs <= 0) throw Error('Poll interval needs to be > 0') + } + + start() { + this.clearTimeout() + this.pollTimeout = setTimeout(() => this.tick(), this.pollIntervalMs) + } + + /** + * Pauses ticking until `temporaryTimeoutMs` passes + * @param temporaryTimeoutMs Time the next tick will execute after + */ + postponeNextTick(temporaryTimeoutMs: number) { + this.clearTimeout() + this.pollTimeout = setTimeout(() => this.tick(), temporaryTimeoutMs) + } + + stop() { + this.clearTimeout() + } + + private clearTimeout() { + if (this.pollTimeout) { + clearTimeout(this.pollTimeout) + this.pollTimeout = null + } + } + + private tick() { + this.emit('tick') + this.start() + } +} diff --git a/packages/timeline-state-resolver/src/integrations/vmix/VMixStateSynchronizer.ts b/packages/timeline-state-resolver/src/integrations/vmix/VMixStateSynchronizer.ts new file mode 100644 index 000000000..00823998d --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/vmix/VMixStateSynchronizer.ts @@ -0,0 +1,63 @@ +import { VMixInput, VMixState, VMixStateExtended } from './VMixStateDiffer' +import { EnforceableVMixInputStateKeys } from '.' +import { VMixInputOverlays, VMixTransform } from 'timeline-state-resolver-types' + +/** + * Applies selected properties from the real state to allow retrying to achieve the state + */ +export class VMixStateSynchronizer { + applyRealState(expectedState: VMixStateExtended, realState: VMixState): VMixStateExtended { + this.applyInputsState(expectedState.reportedState.existingInputs, realState.existingInputs) + + this.applyInputsState(expectedState.reportedState.inputsAddedByUs, realState.inputsAddedByUs) + + // If inputs that we were supposed to add are not in vMix, delete them from state, so that they are re-added. + // Hopefully next time they will be available. + // This is potentially dangerous if for some reason inputs failed to rename due to undiscovered bugs + // It might be better to use responses to AddInput + // this.removeMissingInputs(expectedState.reportedState.inputsAddedByUs, realState.inputsAddedByUs) + + return expectedState + } + + private applyInputsState(expectedInputs: Record, realInputs: Record) { + // This is where "enforcement" of expected state occurs. + // There is only a small number of properties which are safe to enforce. + // Enforcing others can lead to issues such as clips replaying, seeking back to the start, + // or even outright preventing Sisyfos from working. + for (const inputKey of Object.keys(realInputs)) { + if (expectedInputs[inputKey] == null) continue + const cherryPickedRealState: Pick = { + duration: realInputs[inputKey].duration, + loop: realInputs[inputKey].loop, + transform: + realInputs[inputKey].transform && expectedInputs[inputKey].transform + ? { + ...realInputs[inputKey].transform!, + alpha: expectedInputs[inputKey].transform!.alpha, // we don't know the value of alpha - we have to assume it hasn't changed, otherwise we will be sending commands for it all the time + } + : realInputs[inputKey].transform, + overlays: realInputs[inputKey].overlays, + + // This particular key is what enables the ability to re-load failed/missing media in a List Input. + listFilePaths: realInputs[inputKey].listFilePaths, + } + + // Shallow merging is sufficient. + for (const [key, value] of Object.entries( + cherryPickedRealState + )) { + expectedInputs[inputKey][key] = value + } + } + } + + // TODO: enable this in release51 where VMixInputHandler is available + // private removeMissingInputs(expectedInputs: Record, realInputs: Record) { + // for (const inputKey of Object.keys(expectedInputs)) { + // if (realInputs[inputKey] == null) { + // delete expectedInputs[inputKey] + // } + // } + // } +} diff --git a/packages/timeline-state-resolver/src/integrations/vmix/VMixXmlStateParser.ts b/packages/timeline-state-resolver/src/integrations/vmix/VMixXmlStateParser.ts new file mode 100644 index 000000000..f2f7d6c46 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/vmix/VMixXmlStateParser.ts @@ -0,0 +1,121 @@ +import * as xml from 'xml-js' +import { TSR_INPUT_PREFIX, VMixInput, VMixMix, VMixState } from './VMixStateDiffer' +import { InferredPartialInputStateKeys } from './connection' +import { VMixTransitionType } from 'timeline-state-resolver-types' +import _ = require('underscore') + +/** + * Parses the state incoming from vMix + */ +export class VMixXmlStateParser { + parseVMixState(responseBody: string): VMixState { + const preParsed = xml.xml2json(responseBody, { compact: true, spaces: 4 }) + const xmlState = JSON.parse(preParsed) + let mixes = xmlState['vmix']['mix'] + mixes = Array.isArray(mixes) ? mixes : mixes ? [mixes] : [] + + const fixedInputs: VMixInput[] = [] + const inputsAddedByUs: VMixInput[] = [] + + for (const input of xmlState['vmix']['inputs']['input'] as Omit[]) { + const title = input['_attributes']['title'] as string + const isAddedByUs = title.startsWith(TSR_INPUT_PREFIX) + + let fixedListFilePaths: VMixInput['listFilePaths'] = undefined + if (input['_attributes']['type'] === 'VideoList') { + if (Array.isArray(input['list']['item'])) { + // Handles the case where there is more than one item in the list. + fixedListFilePaths = input['list']['item'].map((item) => item['_text']) + } else if (input['list']['item']) { + // Handles the case where there is exactly one item in the list. + fixedListFilePaths = [input['list']['item']['_text']] + } + } + + let fixedOverlays: VMixInput['overlays'] = undefined + if (Array.isArray(input['overlay'])) { + // Handles the case where there is more than one item in the list. + fixedOverlays = input['overlay'].map((item) => parseInt(item['_attributes']['index'], 10)) + } else if (input['overlay']) { + // Handles the case where there is exactly one item in the list. + fixedOverlays = [parseInt(input['overlay']['_attributes']['index'], 10)] + } + + const result: VMixInput = { + number: Number(input['_attributes']['number']), + type: input['_attributes']['type'], + name: isAddedByUs ? title : undefined, + state: input['_attributes']['state'], + playing: input['_attributes']['state'] === 'Running', + position: Number(input['_attributes']['position']) || 0, + duration: Number(input['_attributes']['duration']) || 0, + loop: input['_attributes']['loop'] !== 'False', + muted: input['_attributes']['muted'] !== 'False', + volume: Number(input['_attributes']['volume'] || 100), + balance: Number(input['_attributes']['balance'] || 0), + solo: input['_attributes']['loop'] !== 'False', + audioBuses: input['_attributes']['audiobusses'], + transform: { + panX: Number(input['position'] ? input['position']['_attributes']['panX'] || 0 : 0), + panY: Number(input['position'] ? input['position']['_attributes']['panY'] || 0 : 0), + alpha: -1, // unavailable + zoom: Number(input['position'] ? input['position']['_attributes']['zoomX'] || 1 : 1), // assume that zoomX==zoomY + }, + overlays: fixedOverlays!, + listFilePaths: fixedListFilePaths!, + } + + if (isAddedByUs) { + inputsAddedByUs.push(result) + } else { + fixedInputs.push(result) + } + } + + // For what lies ahead I apologise - Tom + return { + version: xmlState['vmix']['version']['_text'], + edition: xmlState['vmix']['edition']['_text'], + existingInputs: _.indexBy(fixedInputs, 'number'), + inputsAddedByUs: _.indexBy(inputsAddedByUs, 'name'), + overlays: (xmlState['vmix']['overlays']['overlay'] as Array).map((overlay) => { + return { + number: Number(overlay['_attributes']['number']), + input: overlay['_text'], + } + }), + mixes: [ + { + number: 1, + program: Number(xmlState['vmix']['active']['_text']), + preview: Number(xmlState['vmix']['preview']['_text']), + transition: { effect: VMixTransitionType.Cut, duration: 0 }, + }, + ...mixes.map((mix: any): VMixMix => { + return { + number: Number(mix['_attributes']['number']), + program: Number(mix['active']['_text']), + preview: Number(mix['preview']['_text']), + transition: { effect: VMixTransitionType.Cut, duration: 0 }, + } + }), + ], + fadeToBlack: xmlState['vmix']['fadeToBlack']['_text'] === 'True', + recording: xmlState['vmix']['recording']['_text'] === 'True', + external: xmlState['vmix']['external']['_text'] === 'True', + streaming: xmlState['vmix']['streaming']['_text'] === 'True', + playlist: xmlState['vmix']['playList']['_text'] === 'True', + multiCorder: xmlState['vmix']['multiCorder']['_text'] === 'True', + fullscreen: xmlState['vmix']['fullscreen']['_text'] === 'True', + audio: [ + { + volume: Number(xmlState['vmix']['audio']['master']['_attributes']['volume']), + muted: xmlState['vmix']['audio']['master']['_attributes']['muted'] === 'True', + meterF1: Number(xmlState['vmix']['audio']['master']['_attributes']['meterF1']), + meterF2: Number(xmlState['vmix']['audio']['master']['_attributes']['meterF2']), + headphonesVolume: Number(xmlState['vmix']['audio']['master']['_attributes']['headphonesVolume']), + }, + ], + } + } +} diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixPollingTimer.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixPollingTimer.spec.ts new file mode 100644 index 000000000..eb49a197b --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixPollingTimer.spec.ts @@ -0,0 +1,114 @@ +import { VMixPollingTimer } from '../VMixPollingTimer' + +describe('VMixPollingTimer', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + it('ticks in set intervals', () => { + const interval = 1500 + const timer = new VMixPollingTimer(interval) + + const onTick = jest.fn() + timer.on('tick', onTick) + + timer.start() + expect(onTick).not.toHaveBeenCalled() + + jest.advanceTimersByTime(interval - 10) + expect(onTick).not.toHaveBeenCalled() + + jest.advanceTimersByTime(10) // 1500 + expect(onTick).toHaveBeenCalledTimes(1) + onTick.mockClear() + + jest.advanceTimersByTime(interval - 10) // 2990 + expect(onTick).not.toHaveBeenCalled() + + jest.advanceTimersByTime(10) // 3500 + expect(onTick).toHaveBeenCalledTimes(1) + }) + + test('calling start() multiple times does not produce excessive events', () => { + const interval = 1500 + const timer = new VMixPollingTimer(interval) + + const onTick = jest.fn() + timer.on('tick', onTick) + + timer.start() + timer.start() + expect(onTick).not.toHaveBeenCalled() + + jest.advanceTimersByTime(interval - 10) + expect(onTick).not.toHaveBeenCalled() + + jest.advanceTimersByTime(10) // 1500 + expect(onTick).toHaveBeenCalledTimes(1) + onTick.mockClear() + + timer.start() + expect(onTick).not.toHaveBeenCalled() + + jest.advanceTimersByTime(interval - 10) // 2990 + expect(onTick).not.toHaveBeenCalled() + + jest.advanceTimersByTime(10) // 3500 + expect(onTick).toHaveBeenCalledTimes(1) + }) + + it('can be stopped', () => { + const interval = 1500 + const timer = new VMixPollingTimer(interval) + + const onTick = jest.fn() + timer.on('tick', onTick) + + timer.start() + expect(onTick).not.toHaveBeenCalled() + + jest.advanceTimersByTime(interval) // 1500 + expect(onTick).toHaveBeenCalledTimes(1) + onTick.mockClear() + + timer.stop() + + jest.advanceTimersByTime(interval) // 3000 + expect(onTick).not.toHaveBeenCalled() + + jest.advanceTimersByTime(interval) // 4500 + expect(onTick).not.toHaveBeenCalled() + }) + + it('can be postponed', () => { + const interval = 1500 + const timer = new VMixPollingTimer(interval) + + const onTick = jest.fn() + timer.on('tick', onTick) + + timer.start() + expect(onTick).not.toHaveBeenCalled() + + jest.advanceTimersByTime(interval) // 1500 + expect(onTick).toHaveBeenCalledTimes(1) + onTick.mockClear() + + const postponeTime = 5000 + timer.postponeNextTick(postponeTime) + + jest.advanceTimersByTime(interval) // 3000 + expect(onTick).not.toHaveBeenCalled() + + jest.advanceTimersByTime(postponeTime - interval - 10) // 6490 + expect(onTick).not.toHaveBeenCalled() + + jest.advanceTimersByTime(10) // 6500 + expect(onTick).toHaveBeenCalledTimes(1) + onTick.mockClear() + + // it should return to normal interval + jest.advanceTimersByTime(interval) // 8010 + expect(onTick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixStateSynchronizer.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixStateSynchronizer.spec.ts new file mode 100644 index 000000000..04f623f6c --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixStateSynchronizer.spec.ts @@ -0,0 +1,234 @@ +import { VMixTransitionType } from 'timeline-state-resolver-types' +import { TSR_INPUT_PREFIX, VMixState, VMixStateExtended } from '../VMixStateDiffer' +import { VMixStateSynchronizer } from '../VMixStateSynchronizer' + +const ADDED_INPUT_NAME_1 = `${TSR_INPUT_PREFIX}C:\\someVideo.mp4` +const ADDED_INPUT_NAME_2 = `${TSR_INPUT_PREFIX}C:\\anotherVideo.mp4` + +function makeMockReportedState(): VMixState { + return { + version: '21.0.0.55', + edition: 'HD', + existingInputs: { + '1': { + number: 1, + type: 'Capture', + state: 'Running', + position: 0, + duration: 0, + loop: false, + muted: false, + volume: 100, + balance: 0, + audioBuses: 'M', + transform: { + alpha: -1, + panX: 0, + panY: 0, + zoom: 1, + }, + listFilePaths: undefined, + overlays: undefined, + playing: true, + solo: false, + }, + '2': { + number: 2, + type: 'Capture', + state: 'Running', + position: 0, + duration: 0, + loop: false, + muted: true, + volume: 100, + balance: 0, + audioBuses: 'M,C', + transform: { + alpha: -1, + panX: 0, + panY: 0, + zoom: 1, + }, + listFilePaths: undefined, + overlays: undefined, + playing: true, + solo: false, + }, + }, + inputsAddedByUs: { + [ADDED_INPUT_NAME_1]: { + number: 1, + type: 'Video', + state: 'Running', + position: 0, + duration: 0, + loop: false, + muted: false, + volume: 100, + balance: 0, + audioBuses: 'M', + transform: { + alpha: -1, + panX: 0, + panY: 0, + zoom: 1, + }, + listFilePaths: undefined, + overlays: undefined, + playing: true, + solo: false, + name: ADDED_INPUT_NAME_1, + }, + [ADDED_INPUT_NAME_2]: { + number: 1, + type: 'Video', + state: 'Running', + position: 0, + duration: 0, + loop: false, + muted: false, + volume: 100, + balance: 0, + audioBuses: 'M', + transform: { + alpha: -1, + panX: 0, + panY: 0, + zoom: 1, + }, + listFilePaths: undefined, + overlays: undefined, + playing: true, + solo: false, + name: ADDED_INPUT_NAME_2, + }, + }, + overlays: [ + { number: 1, input: undefined }, + { number: 2, input: undefined }, + { number: 3, input: undefined }, + { number: 4, input: undefined }, + { number: 5, input: undefined }, + { number: 6, input: undefined }, + ], + mixes: [ + { + number: 1, + program: 1, + preview: 2, + transition: { + duration: 0, + effect: VMixTransitionType.Cut, + }, + }, + ], + fadeToBlack: false, + recording: true, + external: true, + streaming: true, + playlist: false, + multiCorder: false, + fullscreen: false, + audio: [ + { + volume: 100, + muted: false, + meterF1: 0.04211706, + meterF2: 0.04211706, + headphonesVolume: 74.80521, + }, + ], + } +} + +function makeMockFullState(): VMixStateExtended { + return { + inputLayers: {}, + outputs: { + External2: undefined, + 2: undefined, + 3: undefined, + 4: undefined, + Fullscreen: undefined, + Fullscreen2: undefined, + }, + runningScripts: [], + reportedState: makeMockReportedState(), + } +} + +describe('VMixStateSynchronizer', () => { + it('applies properties of existing inputs', () => { + const synchronizer = new VMixStateSynchronizer() + + const realState = makeMockReportedState() + realState.existingInputs[1].listFilePaths = ['C:\\lingeringFile.mp4'] + realState.existingInputs[2].loop = true + + const updatedState = synchronizer.applyRealState(makeMockFullState(), realState) + + const expectedState = makeMockFullState() + expectedState.reportedState.existingInputs[1].listFilePaths = ['C:\\lingeringFile.mp4'] + expectedState.reportedState.existingInputs[2].loop = true + + expect(updatedState).toEqual(expectedState) + }) + + it('applies properties of inputs added by us', () => { + const synchronizer = new VMixStateSynchronizer() + + const realState = makeMockReportedState() + realState.inputsAddedByUs[ADDED_INPUT_NAME_1].transform = { + alpha: -1, + panX: 1.1, + panY: -0.2, + zoom: 0.5, + } + realState.inputsAddedByUs[ADDED_INPUT_NAME_2].loop = true + + const updatedState = synchronizer.applyRealState(makeMockFullState(), realState) + + const expectedState = makeMockFullState() + expectedState.reportedState.inputsAddedByUs[ADDED_INPUT_NAME_1].transform = { + alpha: -1, + panX: 1.1, + panY: -0.2, + zoom: 0.5, + } + expectedState.reportedState.inputsAddedByUs[ADDED_INPUT_NAME_2].loop = true + + expect(updatedState).toEqual(expectedState) + }) + + it('does not apply unwanted properties of existing inputs', () => { + // this test is not checking for any possible property that is disallowed, but rather serves as a sanity check of last resort + + const synchronizer = new VMixStateSynchronizer() + + const realState = makeMockReportedState() + realState.existingInputs[1].volume = 50 + realState.existingInputs[2].position = 10 + + const updatedState = synchronizer.applyRealState(makeMockFullState(), realState) + + const expectedState = makeMockFullState() + + expect(updatedState).toEqual(expectedState) + }) + + it('does not apply unwanted properties of inputs added by us', () => { + // this test is not checking for any possible property that is disallowed, but rather serves as a sanity check of last resort + + const synchronizer = new VMixStateSynchronizer() + + const realState = makeMockReportedState() + realState.inputsAddedByUs[ADDED_INPUT_NAME_1].volume = 50 + realState.inputsAddedByUs[ADDED_INPUT_NAME_2].position = 10 + + const updatedState = synchronizer.applyRealState(makeMockFullState(), realState) + + const expectedState = makeMockFullState() + + expect(updatedState).toEqual(expectedState) + }) +}) diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixXmlStateParser.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixXmlStateParser.spec.ts new file mode 100644 index 000000000..9fd289ee6 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixXmlStateParser.spec.ts @@ -0,0 +1,164 @@ +import { VMixTransitionType } from 'timeline-state-resolver-types' +import { TSR_INPUT_PREFIX, VMixState } from '../VMixStateDiffer' +import { VMixXmlStateParser } from '../VMixXmlStateParser' +import { makeMockVMixXmlState } from './vmixMock' + +describe('VMixXmlStateParser', () => { + it('parses incoming state', () => { + const parser = new VMixXmlStateParser() + + const parsedState = parser.parseVMixState(makeMockVMixXmlState()) + + expect(parsedState).toEqual({ + version: '21.0.0.55', + edition: 'HD', + existingInputs: { + '1': { + number: 1, + type: 'Capture', + state: 'Running', + position: 0, + duration: 0, + loop: false, + muted: false, + volume: 100, + balance: 0, + audioBuses: 'M', + transform: { + alpha: -1, + panX: 0, + panY: 0, + zoom: 1, + }, + listFilePaths: undefined, + // name: 'Cam 1', + overlays: undefined, + playing: true, + solo: false, + }, + '2': { + number: 2, + type: 'Capture', + state: 'Running', + position: 0, + duration: 0, + loop: false, + muted: true, + volume: 100, + balance: 0, + audioBuses: 'M,C', + transform: { + alpha: -1, + panX: 0, + panY: 0, + zoom: 1, + }, + listFilePaths: undefined, + // name: 'Cam 2', + overlays: undefined, + playing: true, + solo: false, + }, + }, + inputsAddedByUs: {}, + overlays: [ + { number: 1, input: undefined }, + { number: 2, input: undefined }, + { number: 3, input: undefined }, + { number: 4, input: undefined }, + { number: 5, input: undefined }, + { number: 6, input: undefined }, + ], + mixes: [ + { + number: 1, + program: 1, + preview: 2, + transition: { + duration: 0, + effect: VMixTransitionType.Cut, + }, + }, + ], + fadeToBlack: false, + recording: true, + external: true, + streaming: true, + playlist: false, + multiCorder: false, + fullscreen: false, + audio: [ + { + volume: 100, + muted: false, + meterF1: 0.04211706, + meterF2: 0.04211706, + headphonesVolume: 74.80521, + }, + ], + }) + }) + + it('identifies TSR-added inputs', () => { + const parser = new VMixXmlStateParser() + + const xmlState = makeMockVMixXmlState([ + '', + ``, + ]) + const parsedState = parser.parseVMixState(xmlState) + + expect(parsedState).toMatchObject>({ + existingInputs: { + '1': { + number: 1, + type: 'Capture', + state: 'Running', + position: 0, + duration: 0, + loop: false, + muted: false, + volume: 100, + balance: 0, + audioBuses: 'M', + transform: { + alpha: -1, + panX: 0, + panY: 0, + zoom: 1, + }, + listFilePaths: undefined, + //name: 'Cam 1', + overlays: undefined, + playing: true, + solo: false, + }, + }, + inputsAddedByUs: { + [`${TSR_INPUT_PREFIX}C:\\someVideo.mp4`]: { + number: 2, + type: 'Video', + state: 'Running', + position: 0, + duration: 0, + loop: false, + muted: true, + volume: 100, + balance: 0, + audioBuses: 'M,C', + transform: { + alpha: -1, + panX: 0, + panY: 0, + zoom: 1, + }, + listFilePaths: undefined, + name: `${TSR_INPUT_PREFIX}C:\\someVideo.mp4`, + overlays: undefined, + playing: true, + solo: false, + }, + }, + }) + }) +}) diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vmix.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vmix.spec.ts index 02f6fcc81..4c4c5b3e2 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vmix.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vmix.spec.ts @@ -28,6 +28,7 @@ import { VMixDevice } from '..' import { MockTime } from '../../../__tests__/mockTime' import '../../../__tests__/lib' import { CommandContext } from '../vMixCommands' +import { TSR_INPUT_PREFIX } from '../VMixStateDiffer' const orgSetTimeout = setTimeout @@ -165,7 +166,7 @@ describe('vMix', () => { command: { command: VMixCommand.SET_INPUT_NAME, input: 'My Clip.mp4', - value: 'C:/videos/My Clip.mp4', + value: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, }, }), CommandContext.None, @@ -182,7 +183,7 @@ describe('vMix', () => { expect(onFunction).toHaveBeenNthCalledWith( 2, 'SetInputName', - expect.stringContaining('Input=My Clip.mp4&Value=C:/videos/My Clip.mp4') + expect.stringContaining(`Input=My Clip.mp4&Value=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`) ) clearMocks() @@ -198,7 +199,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.PLAY_INPUT, - input: 'C:/videos/My Clip.mp4', + input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, }, }), CommandContext.None, @@ -206,7 +207,11 @@ describe('vMix', () => { ) expect(onFunction).toHaveBeenCalledTimes(1) - expect(onFunction).toHaveBeenNthCalledWith(1, 'Play', expect.stringContaining('Input=C:/videos/My Clip.mp4')) + expect(onFunction).toHaveBeenNthCalledWith( + 1, + 'Play', + expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`) + ) clearMocks() commandReceiver0.mockClear() @@ -220,7 +225,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.REMOVE_INPUT, - input: 'C:/videos/My Clip.mp4', + input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, }, }), CommandContext.None, @@ -228,7 +233,11 @@ describe('vMix', () => { ) expect(onFunction).toHaveBeenCalledTimes(1) - expect(onFunction).toHaveBeenNthCalledWith(1, 'RemoveInput', expect.stringContaining('Input=C:/videos/My Clip.mp4')) + expect(onFunction).toHaveBeenNthCalledWith( + 1, + 'RemoveInput', + expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`) + ) await myConductor.destroy() @@ -333,7 +342,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.ADD_INPUT, - value: 'Video|C:/videos/My Clip.mp4', + value: `Video|C:/videos/My Clip.mp4`, }, }), CommandContext.None, @@ -346,7 +355,7 @@ describe('vMix', () => { command: { command: VMixCommand.SET_INPUT_NAME, input: 'My Clip.mp4', - value: 'C:/videos/My Clip.mp4', + value: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, }, }), CommandContext.None, @@ -363,7 +372,7 @@ describe('vMix', () => { expect(onFunction).toHaveBeenNthCalledWith( 2, 'SetInputName', - expect.stringContaining('Input=My Clip.mp4&Value=C:/videos/My Clip.mp4') + expect.stringContaining(`Input=My Clip.mp4&Value=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`) ) clearMocks() @@ -379,7 +388,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.SET_POSITION, - input: 'C:/videos/My Clip.mp4', + input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, value: 10000, }, }), @@ -392,7 +401,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.LOOP_ON, - input: 'C:/videos/My Clip.mp4', + input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, }, }), CommandContext.None, @@ -404,7 +413,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.SET_ZOOM, - input: 'C:/videos/My Clip.mp4', + input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, value: 0.5, }, }), @@ -417,7 +426,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.SET_ALPHA, - input: 'C:/videos/My Clip.mp4', + input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, value: 123, }, }), @@ -430,7 +439,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.SET_PAN_X, - input: 'C:/videos/My Clip.mp4', + input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, value: 0.3, }, }), @@ -443,7 +452,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.SET_PAN_Y, - input: 'C:/videos/My Clip.mp4', + input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, value: 1.2, }, }), @@ -456,7 +465,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.SET_INPUT_OVERLAY, - input: 'C:/videos/My Clip.mp4', + input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, index: 1, value: 'G:/videos/My Other Clip.mp4', }, @@ -470,7 +479,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.SET_INPUT_OVERLAY, - input: 'C:/videos/My Clip.mp4', + input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, index: 3, value: 5, }, @@ -484,7 +493,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.PLAY_INPUT, - input: 'C:/videos/My Clip.mp4', + input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, }, }), CommandContext.None, @@ -496,40 +505,48 @@ describe('vMix', () => { expect(onFunction).toHaveBeenNthCalledWith( 1, 'SetPosition', - expect.stringContaining('Input=C:/videos/My Clip.mp4&Value=10000') + expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4&Value=10000`) + ) + expect(onFunction).toHaveBeenNthCalledWith( + 2, + 'LoopOn', + expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`) ) - expect(onFunction).toHaveBeenNthCalledWith(2, 'LoopOn', expect.stringContaining('Input=C:/videos/My Clip.mp4')) expect(onFunction).toHaveBeenNthCalledWith( 3, 'SetZoom', - expect.stringContaining('Input=C:/videos/My Clip.mp4&Value=0.5') + expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4&Value=0.5`) ) expect(onFunction).toHaveBeenNthCalledWith( 4, 'SetAlpha', - expect.stringContaining('Input=C:/videos/My Clip.mp4&Value=123') + expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4&Value=123`) ) expect(onFunction).toHaveBeenNthCalledWith( 5, 'SetPanX', - expect.stringContaining('Input=C:/videos/My Clip.mp4&Value=0.3') + expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4&Value=0.3`) ) expect(onFunction).toHaveBeenNthCalledWith( 6, 'SetPanY', - expect.stringContaining('Input=C:/videos/My Clip.mp4&Value=1.2') + expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4&Value=1.2`) ) expect(onFunction).toHaveBeenNthCalledWith( 7, 'SetMultiViewOverlay', - expect.stringContaining('Input=C:/videos/My Clip.mp4&Value=1,G:/videos/My Other Clip.mp4') + expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4&Value=1,G:/videos/My Other Clip.mp4`) ) expect(onFunction).toHaveBeenNthCalledWith( 8, 'SetMultiViewOverlay', - expect.stringContaining('Input=C:/videos/My Clip.mp4&Value=3,5') + expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4&Value=3,5`) + ) + expect(onFunction).toHaveBeenNthCalledWith( + 9, + 'Play', + expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`) ) - expect(onFunction).toHaveBeenNthCalledWith(9, 'Play', expect.stringContaining('Input=C:/videos/My Clip.mp4')) await myConductor.destroy() @@ -918,7 +935,7 @@ describe('vMix', () => { command: { command: VMixCommand.SET_INPUT_NAME, input: 'My Clip.mp4', - value: 'C:/videos/My Clip.mp4', + value: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, }, }), CommandContext.None, @@ -935,7 +952,7 @@ describe('vMix', () => { expect(onFunction).toHaveBeenNthCalledWith( 2, 'SetInputName', - expect.stringContaining('Input=My Clip.mp4&Value=C:/videos/My Clip.mp4') + expect.stringContaining(`Input=My Clip.mp4&Value=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`) ) clearMocks() @@ -951,7 +968,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.TRANSITION, - input: 'C:/videos/My Clip.mp4', + input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, duration: 0, effect: VMixTransitionType.Cut, mix: 0, @@ -966,7 +983,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.AUDIO_VOLUME, - input: 'C:/videos/My Clip.mp4', + input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, value: 25, fade: 0, }, @@ -980,12 +997,12 @@ describe('vMix', () => { expect(onFunction).toHaveBeenNthCalledWith( 1, 'Cut', - expect.stringContaining('Input=C:/videos/My Clip.mp4&Duration=0&Mix=0') + expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4&Duration=0&Mix=0`) ) expect(onFunction).toHaveBeenNthCalledWith( 2, 'SetVolume', - expect.stringContaining('Input=C:/videos/My Clip.mp4&Value=25') + expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4&Value=25`) ) clearMocks() @@ -1014,7 +1031,7 @@ describe('vMix', () => { command: { command: VMixCommand.SET_INPUT_NAME, input: 'My Other Clip.mp4', - value: 'G:/videos/My Other Clip.mp4', + value: `${TSR_INPUT_PREFIX}G:/videos/My Other Clip.mp4`, }, }), CommandContext.None, @@ -1026,7 +1043,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.TRANSITION, - input: 'G:/videos/My Other Clip.mp4', + input: `${TSR_INPUT_PREFIX}G:/videos/My Other Clip.mp4`, duration: 0, effect: VMixTransitionType.Cut, mix: 0, @@ -1041,7 +1058,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.AUDIO_VOLUME, - input: 'G:/videos/My Other Clip.mp4', + input: `${TSR_INPUT_PREFIX}G:/videos/My Other Clip.mp4`, value: 25, fade: 0, }, @@ -1055,7 +1072,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.REMOVE_INPUT, - input: 'C:/videos/My Clip.mp4', + input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, }, }), CommandContext.None, @@ -1072,19 +1089,23 @@ describe('vMix', () => { expect(onFunction).toHaveBeenNthCalledWith( 2, 'SetInputName', - expect.stringContaining('Input=My Other Clip.mp4&Value=G:/videos/My Other Clip.mp4') + expect.stringContaining(`Input=My Other Clip.mp4&Value=${TSR_INPUT_PREFIX}G:/videos/My Other Clip.mp4`) ) expect(onFunction).toHaveBeenNthCalledWith( 3, 'Cut', - expect.stringContaining('Input=G:/videos/My Other Clip.mp4&Duration=0&Mix=0') + expect.stringContaining(`Input=${TSR_INPUT_PREFIX}G:/videos/My Other Clip.mp4&Duration=0&Mix=0`) ) expect(onFunction).toHaveBeenNthCalledWith( 4, 'SetVolume', - expect.stringContaining('Input=G:/videos/My Other Clip.mp4&Value=25') + expect.stringContaining(`Input=${TSR_INPUT_PREFIX}G:/videos/My Other Clip.mp4&Value=25`) + ) + expect(onFunction).toHaveBeenNthCalledWith( + 5, + 'RemoveInput', + expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`) ) - expect(onFunction).toHaveBeenNthCalledWith(5, 'RemoveInput', expect.stringContaining('Input=C:/videos/My Clip.mp4')) await myConductor.destroy() diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vmixAPI.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vmixAPI.spec.ts index 3d82605b7..d712259ce 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vmixAPI.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vmixAPI.spec.ts @@ -1,5 +1,5 @@ import { setupVmixMock } from './vmixMock' -import { Response, VMix } from '../connection' +import { VMixConnection } from '../connection' const orgSetTimeout = setTimeout @@ -25,19 +25,11 @@ describe('vMixAPI', () => { const onError = jest.fn() const onConnected = jest.fn() const onDisconnected = jest.fn() - const onStateChanged = jest.fn() - const onData = jest.fn((response: Response) => { - if (response.command === 'XML' && response.body) { - vmix.parseVMixState(response.body) - } - }) - - const vmix = new VMix('255.255.255.255') + + const vmix = new VMixConnection('255.255.255.255') vmix.on('error', onError) vmix.on('connected', onConnected) vmix.on('disconnected', onDisconnected) - vmix.on('stateChanged', onStateChanged) - vmix.on('data', onData) expect(vmix.connected).toBeFalsy() @@ -49,101 +41,7 @@ describe('vMixAPI', () => { expect(onConnect).toHaveBeenLastCalledWith(8099, '255.255.255.255') - await vmix.requestVMixState() - - await wait(10) - - expect(vmix.state).toEqual({ - version: '21.0.0.55', - edition: 'HD', - inputs: { - '1': { - number: 1, - type: 'Capture', - state: 'Running', - position: 0, - duration: 0, - loop: false, - muted: false, - volume: 100, - balance: 0, - audioBuses: 'M', - transform: { - alpha: -1, - panX: 0, - panY: 0, - zoom: 1, - }, - listFilePaths: undefined, - name: 'Cam 1', - overlays: undefined, - playing: true, - solo: false, - }, - '2': { - number: 2, - type: 'Capture', - state: 'Running', - position: 0, - duration: 0, - loop: false, - muted: true, - volume: 100, - balance: 0, - audioBuses: 'M,C', - transform: { - alpha: -1, - panX: 0, - panY: 0, - zoom: 1, - }, - listFilePaths: undefined, - name: 'Cam 2', - overlays: undefined, - playing: true, - solo: false, - }, - }, - overlays: [ - { number: 1, input: undefined }, - { number: 2, input: undefined }, - { number: 3, input: undefined }, - { number: 4, input: undefined }, - { number: 5, input: undefined }, - { number: 6, input: undefined }, - ], - mixes: [ - { - number: 1, - program: 1, - preview: 2, - transition: { - duration: 0, - effect: 'Cut', - }, - }, - ], - fadeToBlack: false, - recording: true, - external: true, - streaming: true, - playlist: false, - multiCorder: false, - fullscreen: false, - audio: [ - { - volume: 100, - muted: false, - meterF1: 0.04211706, - meterF2: 0.04211706, - headphonesVolume: 74.80521, - }, - ], - fixedInputsCount: 2, - }) - expect(onConnected).toHaveBeenCalledTimes(1) - expect(onStateChanged).toHaveBeenCalledTimes(1) expect(onDisconnected).toHaveBeenCalledTimes(0) expect(onError).toHaveBeenCalledTimes(0) @@ -152,15 +50,13 @@ describe('vMixAPI', () => { expect(vmix.connected).toBeFalsy() }) test('Connection status', async () => { - const vmix = new VMix('255.255.255.255') + const vmix = new VMixConnection('255.255.255.255') const onError = jest.fn() const onConnected = jest.fn() const onDisconnected = jest.fn() - const onStateChanged = jest.fn() vmix.on('error', onError) vmix.on('connected', onConnected) vmix.on('disconnected', onDisconnected) - vmix.on('stateChanged', onStateChanged) vmix.connect('255.255.255.255') await wait(10) diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vmixMock.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vmixMock.ts index 5701b8912..2240abb41 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vmixMock.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vmixMock.ts @@ -10,13 +10,15 @@ const orgSetImmediate = setImmediate const COMMAND_REGEX = /^(?\w+)(?:\s+(?\w+)?(?:\s+(?.+))?)?$/ -const vmixMockState = `\r\n +export function makeMockVMixXmlState(inputs?: string[]): string { + const defaultInputs = `\r\n + ` + return `\r\n 21.0.0.55\r\n HD\r\n C:\\Users\\server\\AppData\\Roaming\\last.vmix\r\n \r\n -\r\n -\r\n +${inputs?.join('\r\n') ?? defaultInputs}\r\n \r\n \r\n \r\n @@ -45,6 +47,7 @@ const vmixMockState = `\r\n ` +} export function setupVmixMock() { const vmixServer: VmixServerMockOptions = { @@ -115,7 +118,7 @@ function handleData( switch (command) { case 'XML': xmlClb() - sendData(socket, buildResponse('XML', undefined, vmixMockState)) + sendData(socket, buildResponse('XML', undefined, makeMockVMixXmlState())) break case 'FUNCTION': if (!funcName) throw new Error('Empty function name!') diff --git a/packages/timeline-state-resolver/src/integrations/vmix/connection.ts b/packages/timeline-state-resolver/src/integrations/vmix/connection.ts index e23fbbfd8..e55ebb3b5 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/connection.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/connection.ts @@ -1,9 +1,6 @@ import { EventEmitter } from 'eventemitter3' import { Socket } from 'net' -import * as xml from 'xml-js' -import { VMixCommand, VMixTransitionType } from 'timeline-state-resolver-types' -import * as _ from 'underscore' -import { VMixInput, VMixMix, VMixState } from './vMixStateDiffer' +import { VMixCommand } from 'timeline-state-resolver-types' import { VMixStateCommand } from './vMixCommands' const VMIX_DEFAULT_TCP_PORT = 8099 @@ -21,7 +18,6 @@ export type ConnectionEvents = { connected: [] disconnected: [] initialized: [] - stateChanged: [state: VMixState] error: [error: Error] } @@ -221,9 +217,7 @@ export class BaseConnection extends EventEmitter { } } -export class VMix extends BaseConnection { - public state: VMixState - +export class VMixConnection extends BaseConnection { public async sendCommand(command: VMixStateCommand): Promise { switch (command.command) { case VMixCommand.PREVIEW_INPUT: @@ -311,116 +305,6 @@ export class VMix extends BaseConnection { } } - public parseVMixState(responseBody: string): void { - const preParsed = xml.xml2json(responseBody, { compact: true, spaces: 4 }) - const xmlState = JSON.parse(preParsed) - let mixes = xmlState['vmix']['mix'] - mixes = Array.isArray(mixes) ? mixes : mixes ? [mixes] : [] - let fixedInputsCount = 0 - // For what lies ahead I apologise - Tom - const state: VMixState = { - version: xmlState['vmix']['version']['_text'], - edition: xmlState['vmix']['edition']['_text'], - inputs: _.indexBy( - (xmlState['vmix']['inputs']['input'] as Array).map( - (input): Required> => { - fixedInputsCount++ - - let fixedListFilePaths: VMixInput['listFilePaths'] = undefined - if (input['_attributes']['type'] === 'VideoList') { - if (Array.isArray(input['list']['item'])) { - // Handles the case where there is more than one item in the list. - fixedListFilePaths = input['list']['item'].map((item) => item['_text']) - } else if (input['list']['item']) { - // Handles the case where there is exactly one item in the list. - fixedListFilePaths = [input['list']['item']['_text']] - } - } - - let fixedOverlays: VMixInput['overlays'] = undefined - if (Array.isArray(input['overlay'])) { - // Handles the case where there is more than one item in the list. - fixedOverlays = input['overlay'].map((item) => parseInt(item['_attributes']['index'], 10)) - } else if (input['overlay']) { - // Handles the case where there is exactly one item in the list. - fixedOverlays = [parseInt(input['overlay']['_attributes']['index'], 10)] - } - - return { - number: Number(input['_attributes']['number']), - type: input['_attributes']['type'], - name: input['_attributes']['title'], - state: input['_attributes']['state'], - playing: input['_attributes']['state'] === 'Running', - position: Number(input['_attributes']['position']) || 0, - duration: Number(input['_attributes']['duration']) || 0, - loop: input['_attributes']['loop'] === 'False' ? false : true, - muted: input['_attributes']['muted'] === 'False' ? false : true, - volume: Number(input['_attributes']['volume'] || 100), - balance: Number(input['_attributes']['balance'] || 0), - solo: input['_attributes']['loop'] === 'False' ? false : true, - audioBuses: input['_attributes']['audiobusses'], - transform: { - panX: Number(input['position'] ? input['position']['_attributes']['panX'] || 0 : 0), - panY: Number(input['position'] ? input['position']['_attributes']['panY'] || 0 : 0), - alpha: -1, // unavailable - zoom: Number(input['position'] ? input['position']['_attributes']['zoomX'] || 1 : 1), // assume that zoomX==zoomY - }, - overlays: fixedOverlays!, - listFilePaths: fixedListFilePaths!, - } - } - ), - 'number' - ), - overlays: (xmlState['vmix']['overlays']['overlay'] as Array).map((overlay) => { - return { - number: Number(overlay['_attributes']['number']), - input: overlay['_text'], - } - }), - mixes: [ - { - number: 1, - program: Number(xmlState['vmix']['active']['_text']), - preview: Number(xmlState['vmix']['preview']['_text']), - transition: { effect: VMixTransitionType.Cut, duration: 0 }, - }, - ...mixes.map((mix: any): VMixMix => { - return { - number: Number(mix['_attributes']['number']), - program: Number(mix['active']['_text']), - preview: Number(mix['preview']['_text']), - transition: { effect: VMixTransitionType.Cut, duration: 0 }, - } - }), - ], - fadeToBlack: xmlState['vmix']['fadeToBlack']['_text'] === 'True' ? true : false, - recording: xmlState['vmix']['recording']['_text'] === 'True' ? true : false, - external: xmlState['vmix']['external']['_text'] === 'True' ? true : false, - streaming: xmlState['vmix']['streaming']['_text'] === 'True' ? true : false, - playlist: xmlState['vmix']['playList']['_text'] === 'True' ? true : false, - multiCorder: xmlState['vmix']['multiCorder']['_text'] === 'True' ? true : false, - fullscreen: xmlState['vmix']['fullscreen']['_text'] === 'True' ? true : false, - audio: [ - { - volume: Number(xmlState['vmix']['audio']['master']['_attributes']['volume']), - muted: xmlState['vmix']['audio']['master']['_attributes']['muted'] === 'True' ? true : false, - meterF1: Number(xmlState['vmix']['audio']['master']['_attributes']['meterF1']), - meterF2: Number(xmlState['vmix']['audio']['master']['_attributes']['meterF2']), - headphonesVolume: Number(xmlState['vmix']['audio']['master']['_attributes']['headphonesVolume']), - }, - ], - fixedInputsCount, - } - this.setState(state) - } - - public setState(state: VMixState): void { - this.state = state - this.emit('stateChanged', state) - } - public async setPreviewInput(input: number | string, mix: number): Promise { return this.sendCommandFunction('PreviewInput', { input, mix }) } diff --git a/packages/timeline-state-resolver/src/integrations/vmix/index.ts b/packages/timeline-state-resolver/src/integrations/vmix/index.ts index 44e78bfb3..58bd56432 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/index.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/index.ts @@ -1,16 +1,13 @@ import * as _ from 'underscore' -import * as deepMerge from 'deepmerge' import { DeviceWithState, CommandWithContext, DeviceStatus, StatusCode } from './../../devices/device' import { DoOnTime, SendMode } from '../../devices/doOnTime' -import { VMix } from './connection' +import { Response, VMixConnection } from './connection' import { DeviceType, DeviceOptionsVMix, VMixOptions, Mappings, - VMixTransform, - VMixInputOverlays, Timeline, TSRTimelineContent, ActionExecutionResult, @@ -20,9 +17,12 @@ import { VmixActions, } from 'timeline-state-resolver-types' import { t } from '../../lib' -import { VMixInput, VMixState, VMixStateDiffer, VMixStateExtended } from './vMixStateDiffer' +import { VMixState, VMixStateDiffer, VMixStateExtended } from './VMixStateDiffer' import { CommandContext, VMixStateCommandWithContext } from './vMixCommands' -import { VMixTimelineStateConverter } from './vMixTimelineStateConverter' +import { MappingsVmix, VMixTimelineStateConverter } from './VMixTimelineStateConverter' +import { VMixXmlStateParser } from './VMixXmlStateParser' +import { VMixPollingTimer } from './VMixPollingTimer' +import { VMixStateSynchronizer } from './VMixStateSynchronizer' /** * Default time, in milliseconds, for when we should poll vMix to query its actual state. @@ -60,13 +60,16 @@ export class VMixDevice extends DeviceWithState Promise) { super(deviceId, deviceOptions, getCurrentTime) @@ -88,56 +91,74 @@ export class VMixDevice extends DeviceWithState this.emit('slowSentCommand', info)) this._doOnTime.on('slowFulfilledCommand', (info) => this.emit('slowFulfilledCommand', info)) - this._stateDiffer = new VMixStateDiffer( - () => this._vmix.state.fixedInputsCount, - (commands: VMixStateCommandWithContext[]) => this._addToQueue(commands, this.getCurrentTime()) + this._stateDiffer = new VMixStateDiffer((commands: VMixStateCommandWithContext[]) => + this._addToQueue(commands, this.getCurrentTime()) ) this._timelineStateConverter = new VMixTimelineStateConverter( () => this._stateDiffer.getDefaultState(), - (num: number) => this._stateDiffer.getDefaultInputState(num) + (inputNumber: number) => this._stateDiffer.getDefaultInputState(inputNumber) ) + + this._xmlStateParser = new VMixXmlStateParser() + this._stateSynchronizer = new VMixStateSynchronizer() } async init(options: VMixOptions): Promise { - this._vmix = new VMix(options.host, options.port, false) - this._vmix.on('connected', () => { + this._vMixConnection = new VMixConnection(options.host, options.port, false) + this._vMixConnection.on('connected', () => { // We are not resetting the state at this point and waiting for the state to arrive. Otherwise, we risk // going back and forth on reconnections this._setConnected(true) - this._vmix.requestVMixState().catch((e) => this.emit('error', 'VMix init', e)) + this._expectingStateAfterConnecting = true + this.emit('debug', 'connected') + this._pollingTimer?.start() + this._requestVMixState('VMix init') }) - this._vmix.on('disconnected', () => { + this._vMixConnection.on('disconnected', () => { this._setConnected(false) + this._pollingTimer?.stop() }) - this._vmix.on('error', (e) => this.emit('error', 'VMix', e)) - this._vmix.on('stateChanged', (state) => this._onVMixStateChanged(state)) - this._vmix.on('data', (d) => { - if (d.message !== 'Completed') this.emit('debug', d) - if (d.command === 'XML' && d.body) { - this._vmix.parseVMixState(d.body) - if (!this._initialized) { - this._initialized = true - this.emit('connectionChanged', this.getStatus()) - this.emit('resetResolver') - } - } - }) + this._vMixConnection.on('error', (e) => this.emit('error', 'VMix', e)) + this._vMixConnection.on('data', (data) => this._onDataReceived(data)) // this._vmix.on('debug', (...args) => this.emitDebug(...args)) - this._vmix.connect() + this._vMixConnection.connect() - this._pollTime = + const pollTime = typeof options.pollInterval === 'number' && options.pollInterval >= 0 // options.pollInterval === 0 disables the polling ? options.pollInterval : DEFAULT_VMIX_POLL_INTERVAL - if (this._pollTime) { - this._pollTimeout = setTimeout(() => this._pollVmix(), this._pollTime) + + if (pollTime) { + this._pollingTimer = new VMixPollingTimer(pollTime) + this._pollingTimer.on('tick', () => { + this._expectingPolledState = true + this._requestVMixState('VMix poll') + }) } return true } + private _onDataReceived(data: Response): void { + if (data.message !== 'Completed') this.emit('debug', data) + if (data.command === 'XML' && data.body) { + if (!this._initialized) { + this._initialized = true + this.emit('connectionChanged', this.getStatus()) + } + const realState = this._xmlStateParser.parseVMixState(data.body) + if (this._expectingStateAfterConnecting) { + this._setFullState(realState) + this._expectingStateAfterConnecting = false + } else if (this._expectingPolledState) { + this._setPartialInputState(realState) + this._expectingPolledState = false + } + } + } + private _connectionChanged() { this.emit('connectionChanged', this.getStatus()) } @@ -150,42 +171,29 @@ export class VMixDevice extends DeviceWithState(expectedState.reportedState, _.omit(realState, 'inputs')) - - // This is where "enforcement" of expected state occurs. - // There is only a small number of properties which are safe to enforce. - // Enforcing others can lead to issues such as clips replaying, seeking back to the start, - // or even outright preventing Sisyfos from working. - for (const inputKey of Object.keys(realState.inputs)) { - const cherryPickedRealState: Pick = { - duration: realState.inputs[inputKey].duration, - loop: realState.inputs[inputKey].loop, - transform: realState.inputs[inputKey].transform, - overlays: realState.inputs[inputKey].overlays, - - // This particular key is what enables the ability to re-load failed/missing media in a List Input. - listFilePaths: realState.inputs[inputKey].listFilePaths, - } + /** + * Runs when we receive XML state from vMix, + * generally as the result a poll (if polling/enforcement is enabled). + * @param realState State as reported by vMix itself. + */ + private _setPartialInputState(realState: VMixState) { + const time = this.getCurrentTime() + let expectedState: VMixStateExtended = (this.getStateBefore(time) ?? { state: this._stateDiffer.getDefaultState() }) + .state - // Shallow merging is sufficient. - for (const [key, value] of Object.entries( - cherryPickedRealState - )) { - expectedState.reportedState.inputs[inputKey][key] = value - } - } + expectedState = this._stateSynchronizer.applyRealState(expectedState, realState) this.setState(expectedState, time) this.emit('resetResolver') @@ -194,8 +202,8 @@ export class VMixDevice extends DeviceWithState, newMappings: Mappings) { @@ -206,12 +214,15 @@ export class VMixDevice extends DeviceWithState this._pollVmix(), BACKOFF_VMIX_POLL_INTERVAL) + this._expectingPolledState = false + this._pollingTimer?.postponeNextTick(BACKOFF_VMIX_POLL_INTERVAL) const cwc: CommandWithContext = { context: context, @@ -393,19 +403,15 @@ export class VMixDevice extends DeviceWithState { + return this._vMixConnection.sendCommand(cmd.command).catch((error) => { this.emit('commandError', error, cwc) }) } /** - * Polls vMix's XML status endpoint, which will change our tracked state based on the response. + * Request vMix's XML status. */ - private _pollVmix() { - clearTimeout(this._pollTimeout) - if (this._pollTime) { - this._pollTimeout = setTimeout(() => this._pollVmix(), this._pollTime) - } - this._vmix.requestVMixState().catch((e) => this.emit('error', 'VMix poll', e)) + private _requestVMixState(context: string) { + this._vMixConnection.requestVMixState().catch((e) => this.emit('error', context, e)) } } diff --git a/packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts index 13a2cf180..b05378a73 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts @@ -10,22 +10,16 @@ import { CommandContext, VMixStateCommandWithContext } from './vMixCommands' import _ = require('underscore') import path = require('node:path') +/** Prefix of media input added by TSR. Only those with this prefix can be removed by this implementation */ +export const TSR_INPUT_PREFIX = 'TSR_MEDIA_' + export interface VMixStateExtended { /** * The state of vMix (as far as we know) as reported by vMix **+ * our expectations based on the commands we've set**. */ reportedState: VMixState - outputs: { - External2: VMixOutput - - '2': VMixOutput - '3': VMixOutput - '4': VMixOutput - - Fullscreen: VMixOutput - Fullscreen2: VMixOutput - } + outputs: VMixOutputsState inputLayers: { [key: string]: string } runningScripts: string[] } @@ -33,21 +27,32 @@ export interface VMixStateExtended { export interface VMixState { version: string edition: string // TODO: Enuum, need list of available editions: Trial - fixedInputsCount: number - inputs: { [key: string]: VMixInput } + existingInputs: { [key: string]: VMixInput } + inputsAddedByUs: { [key: string]: VMixInput } overlays: VMixOverlay[] mixes: VMixMix[] fadeToBlack: boolean faderPosition?: number - recording: boolean - external: boolean - streaming: boolean + recording: boolean | undefined + external: boolean | undefined + streaming: boolean | undefined playlist: boolean multiCorder: boolean fullscreen: boolean audio: VMixAudioChannel[] } +interface VMixOutputsState { + External2: VMixOutput | undefined + + '2': VMixOutput | undefined + '3': VMixOutput | undefined + '4': VMixOutput | undefined + + Fullscreen: VMixOutput | undefined + Fullscreen2: VMixOutput | undefined +} + export interface VMixMix { number: number program: string | number | undefined @@ -97,11 +102,13 @@ export interface VMixAudioChannel { headphonesVolume: number } +interface PreAndPostTransitionCommands { + preTransitionCommands: Array + postTransitionCommands: Array +} + export class VMixStateDiffer { - constructor( - private readonly getFixedInputsCount: () => number, - private readonly queueNow: (commands: VMixStateCommandWithContext[]) => void - ) {} + constructor(private readonly queueNow: (commands: VMixStateCommandWithContext[]) => void) {} getCommandsToAchieveState(oldVMixState: VMixStateExtended, newVMixState: VMixStateExtended) { let commands: Array = [] @@ -111,11 +118,13 @@ export class VMixStateDiffer { commands = commands.concat(this._resolveMixState(oldVMixState, newVMixState)) commands = commands.concat(this._resolveOverlaysState(oldVMixState, newVMixState)) commands = commands.concat(inputCommands.postTransitionCommands) - commands = commands.concat(this._resolveRecordingState(oldVMixState, newVMixState)) - commands = commands.concat(this._resolveStreamingState(oldVMixState, newVMixState)) - commands = commands.concat(this._resolveExternalState(oldVMixState, newVMixState)) + commands = commands.concat(this._resolveRecordingState(oldVMixState.reportedState, newVMixState.reportedState)) + commands = commands.concat(this._resolveStreamingState(oldVMixState.reportedState, newVMixState.reportedState)) + commands = commands.concat(this._resolveExternalState(oldVMixState.reportedState, newVMixState.reportedState)) commands = commands.concat(this._resolveOutputsState(oldVMixState, newVMixState)) - commands = commands.concat(this._resolveInputsRemovalState(oldVMixState, newVMixState)) + commands = commands.concat( + this._resolveAddedByUsInputsRemovalState(oldVMixState.reportedState, newVMixState.reportedState) + ) commands = commands.concat(this._resolveScriptsState(oldVMixState, newVMixState)) return commands @@ -126,8 +135,8 @@ export class VMixStateDiffer { reportedState: { version: '', edition: '', - fixedInputsCount: 0, - inputs: this._getDefaultInputsState(this.getFixedInputsCount()), + existingInputs: {}, + inputsAddedByUs: {}, overlays: _.map([1, 2, 3, 4, 5, 6], (num) => { return { number: num, @@ -144,30 +153,30 @@ export class VMixStateDiffer { }), fadeToBlack: false, faderPosition: 0, - recording: false, - external: false, - streaming: false, + recording: undefined, + external: undefined, + streaming: undefined, playlist: false, multiCorder: false, fullscreen: false, audio: [], }, outputs: { - '2': { source: 'Program' }, - '3': { source: 'Program' }, - '4': { source: 'Program' }, - External2: { source: 'Program' }, - Fullscreen: { source: 'Program' }, - Fullscreen2: { source: 'Program' }, + '2': undefined, + '3': undefined, + '4': undefined, + External2: undefined, + Fullscreen: undefined, + Fullscreen2: undefined, }, inputLayers: {}, runningScripts: [], } } - getDefaultInputState(num: number): VMixInput { + getDefaultInputState(inputNumber: number | string | undefined): VMixInput { return { - number: num, + number: Number(inputNumber) || undefined, position: 0, muted: true, loop: false, @@ -187,14 +196,6 @@ export class VMixStateDiffer { } } - private _getDefaultInputsState(count: number): { [key: string]: VMixInput } { - const defaultInputs: { [key: string]: VMixInput } = {} - for (let i = 1; i <= count; i++) { - defaultInputs[i] = this.getDefaultInputState(i) - } - return defaultInputs - } - private _resolveMixState( oldVMixState: VMixStateExtended, newVMixState: VMixStateExtended @@ -277,348 +278,383 @@ export class VMixStateDiffer { private _resolveInputsState( oldVMixState: VMixStateExtended, newVMixState: VMixStateExtended - ): { - preTransitionCommands: Array - postTransitionCommands: Array - } { + ): PreAndPostTransitionCommands { const preTransitionCommands: Array = [] const postTransitionCommands: Array = [] - _.each(newVMixState.reportedState.inputs, (input, key) => { - if (input.name === undefined) { - input.name = key - } - if (!_.has(oldVMixState.reportedState.inputs, key) && input.type !== undefined) { - const addCommands: Array = [] - addCommands.push({ - command: { - command: VMixCommand.ADD_INPUT, - value: `${input.type}|${input.name}`, - }, - context: CommandContext.None, - timelineId: '', - }) - addCommands.push({ + _.map(newVMixState.reportedState.existingInputs, (input, key) => + this._resolveStationaryInputState(oldVMixState.reportedState.existingInputs[key], input, key, oldVMixState) + ).forEach((commands) => { + preTransitionCommands.push(...commands.preTransitionCommands) + postTransitionCommands.push(...commands.postTransitionCommands) + }) + _.map(newVMixState.reportedState.inputsAddedByUs, (input, key) => + this._resolveAddedByUsInputState(oldVMixState.reportedState.inputsAddedByUs[key], input, key, oldVMixState) + ).forEach((commands) => { + preTransitionCommands.push(...commands.preTransitionCommands) + postTransitionCommands.push(...commands.postTransitionCommands) + }) + return { preTransitionCommands, postTransitionCommands } + } + + private _resolveStationaryInputState( + oldInput: VMixInput | undefined, + input: VMixInput, + key: string, + oldVMixState: VMixStateExtended + ): PreAndPostTransitionCommands { + oldInput ??= this.getDefaultInputState(0) // or {} but we assume that a new input has all parameters default + + return this._resolveInputState(oldVMixState, oldInput, input, key) + } + + private _resolveInputState(oldVMixState: VMixStateExtended, oldInput: VMixInput, input: VMixInput, key: string) { + if (input.name === undefined) { + input.name = key + } + const preTransitionCommands: Array = [] + const postTransitionCommands: Array = [] + /** + * If an input is currently on air, then we delay changes to it until after the transition has began. + * Note the word "began", instead of "completed". + * + * This mostly helps in the case of CUT transitions, where in theory everything happens + * on the same frame but, in reality, thanks to how vMix processes API commands, + * things take place over the course of a few frames. + */ + const commands = this._isInUse(oldVMixState, oldInput) ? postTransitionCommands : preTransitionCommands + + // It is important that the operations on listFilePaths happen before most other operations. + // Consider the case where we want to change the contents of a List input AND set it to playing. + // If we set it to playing first, it will automatically be forced to stop playing when + // we dispatch LIST_REMOVE_ALL. + // So, order of operations matters here. + if (!_.isEqual(oldInput.listFilePaths, input.listFilePaths)) { + // vMix has a quirk that we are working around here: + // When a List input has no items, its Play/Pause button becomes inactive and + // clicking it does nothing. However, if the List was playing when it was emptied, + // it'll remain in a playing state. This means that as soon as new content is + // added to the playlist, it will immediately begin playing. This feels like a + // bug/mistake/otherwise unwanted behavior in every scenario. To work around this, + // we automatically dispatch a PAUSE_INPUT command before emptying the playlist, + // but only if there's no new content being added afterward. + if (!input.listFilePaths || (Array.isArray(input.listFilePaths) && input.listFilePaths.length <= 0)) { + commands.push({ command: { - command: VMixCommand.SET_INPUT_NAME, - input: this._getFilename(input.name), - value: input.name, + command: VMixCommand.PAUSE_INPUT, + input: input.name, }, context: CommandContext.None, timelineId: '', }) - this.queueNow(addCommands) } - const oldInput = oldVMixState.reportedState.inputs[key] || this.getDefaultInputState(0) // or {} but we assume that a new input has all parameters default - - /** - * If an input is currently on air, then we delay changes to it until after the transition has began. - * Note the word "began", instead of "completed". - * - * This mostly helps in the case of CUT transitions, where in theory everything happens - * on the same frame but, in reality, thanks to how vMix processes API commands, - * things take place over the course of a few frames. - */ - const commands = this._isInUse(oldVMixState, oldInput) ? postTransitionCommands : preTransitionCommands - - // It is important that the operations on listFilePaths happen before most other operations. - // Consider the case where we want to change the contents of a List input AND set it to playing. - // If we set it to playing first, it will automatically be forced to stop playing when - // we dispatch LIST_REMOVE_ALL. - // So, order of operations matters here. - if (!_.isEqual(oldInput.listFilePaths, input.listFilePaths)) { - // vMix has a quirk that we are working around here: - // When a List input has no items, its Play/Pause button becomes inactive and - // clicking it does nothing. However, if the List was playing when it was emptied, - // it'll remain in a playing state. This means that as soon as new content is - // added to the playlist, it will immediately begin playing. This feels like a - // bug/mistake/otherwise unwanted behavior in every scenario. To work around this, - // we automatically dispatch a PAUSE_INPUT command before emptying the playlist, - // but only if there's no new content being added afterward. - if (!input.listFilePaths || (Array.isArray(input.listFilePaths) && input.listFilePaths.length <= 0)) { + commands.push({ + command: { + command: VMixCommand.LIST_REMOVE_ALL, + input: input.name, + }, + context: CommandContext.None, + timelineId: '', + }) + if (Array.isArray(input.listFilePaths)) { + for (const filePath of input.listFilePaths) { commands.push({ command: { - command: VMixCommand.PAUSE_INPUT, + command: VMixCommand.LIST_ADD, input: input.name, + value: filePath, }, context: CommandContext.None, timelineId: '', }) } + } + } + if (input.playing !== undefined && oldInput.playing !== input.playing && !input.playing) { + commands.push({ + command: { + command: VMixCommand.PAUSE_INPUT, + input: input.name, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if (oldInput.position !== input.position) { + commands.push({ + command: { + command: VMixCommand.SET_POSITION, + input: key, + value: input.position ? input.position : 0, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if (input.restart !== undefined && oldInput.restart !== input.restart && input.restart) { + commands.push({ + command: { + command: VMixCommand.RESTART_INPUT, + input: key, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if (input.loop !== undefined && oldInput.loop !== input.loop) { + if (input.loop) { commands.push({ command: { - command: VMixCommand.LIST_REMOVE_ALL, + command: VMixCommand.LOOP_ON, input: input.name, }, context: CommandContext.None, timelineId: '', }) - if (Array.isArray(input.listFilePaths)) { - for (const filePath of input.listFilePaths) { - commands.push({ - command: { - command: VMixCommand.LIST_ADD, - input: input.name, - value: filePath, - }, - context: CommandContext.None, - timelineId: '', - }) - } - } - } - if (input.playing !== undefined && oldInput.playing !== input.playing && !input.playing) { + } else { commands.push({ command: { - command: VMixCommand.PAUSE_INPUT, + command: VMixCommand.LOOP_OFF, input: input.name, }, context: CommandContext.None, timelineId: '', }) } - if (oldInput.position !== input.position) { + } + if (input.muted !== undefined && oldInput.muted !== input.muted && input.muted) { + commands.push({ + command: { + command: VMixCommand.AUDIO_OFF, + input: key, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if (oldInput.volume !== input.volume && input.volume !== undefined) { + commands.push({ + command: { + command: VMixCommand.AUDIO_VOLUME, + input: key, + value: input.volume, + fade: input.fade, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if (oldInput.balance !== input.balance && input.balance !== undefined) { + commands.push({ + command: { + command: VMixCommand.AUDIO_BALANCE, + input: key, + value: input.balance, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if (input.audioAuto !== undefined && oldInput.audioAuto !== input.audioAuto) { + if (!input.audioAuto) { commands.push({ command: { - command: VMixCommand.SET_POSITION, + command: VMixCommand.AUDIO_AUTO_OFF, input: key, - value: input.position ? input.position : 0, }, context: CommandContext.None, timelineId: '', }) - } - if (input.restart !== undefined && oldInput.restart !== input.restart && input.restart) { + } else { commands.push({ command: { - command: VMixCommand.RESTART_INPUT, + command: VMixCommand.AUDIO_AUTO_ON, input: key, }, context: CommandContext.None, timelineId: '', }) } - if (input.loop !== undefined && oldInput.loop !== input.loop) { - if (input.loop) { - commands.push({ - command: { - command: VMixCommand.LOOP_ON, - input: input.name, - }, - context: CommandContext.None, - timelineId: '', - }) - } else { - commands.push({ - command: { - command: VMixCommand.LOOP_OFF, - input: input.name, - }, - context: CommandContext.None, - timelineId: '', - }) - } - } - if (input.muted !== undefined && oldInput.muted !== input.muted && input.muted) { + } + if (input.audioBuses !== undefined && oldInput.audioBuses !== input.audioBuses) { + const oldBuses = (oldInput.audioBuses || '').split(',').filter((x) => x) + const newBuses = input.audioBuses.split(',').filter((x) => x) + _.difference(newBuses, oldBuses).forEach((bus) => { commands.push({ command: { - command: VMixCommand.AUDIO_OFF, + command: VMixCommand.AUDIO_BUS_ON, input: key, + value: bus, }, context: CommandContext.None, timelineId: '', }) - } - if (oldInput.volume !== input.volume && input.volume !== undefined) { + }) + _.difference(oldBuses, newBuses).forEach((bus) => { commands.push({ command: { - command: VMixCommand.AUDIO_VOLUME, + command: VMixCommand.AUDIO_BUS_OFF, input: key, - value: input.volume, - fade: input.fade, + value: bus, }, context: CommandContext.None, timelineId: '', }) - } - if (oldInput.balance !== input.balance && input.balance !== undefined) { + }) + } + if (input.muted !== undefined && oldInput.muted !== input.muted && !input.muted) { + commands.push({ + command: { + command: VMixCommand.AUDIO_ON, + input: key, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if (input.transform !== undefined && !_.isEqual(oldInput.transform, input.transform)) { + if (oldInput.transform === undefined || input.transform.zoom !== oldInput.transform.zoom) { commands.push({ command: { - command: VMixCommand.AUDIO_BALANCE, + command: VMixCommand.SET_ZOOM, input: key, - value: input.balance, + value: input.transform.zoom, }, context: CommandContext.None, timelineId: '', }) } - if (input.audioAuto !== undefined && oldInput.audioAuto !== input.audioAuto) { - if (!input.audioAuto) { - commands.push({ - command: { - command: VMixCommand.AUDIO_AUTO_OFF, - input: key, - }, - context: CommandContext.None, - timelineId: '', - }) - } else { - commands.push({ - command: { - command: VMixCommand.AUDIO_AUTO_ON, - input: key, - }, - context: CommandContext.None, - timelineId: '', - }) - } - } - if (input.audioBuses !== undefined && oldInput.audioBuses !== input.audioBuses) { - const oldBuses = (oldInput.audioBuses || '').split(',').filter((x) => x) - const newBuses = input.audioBuses.split(',').filter((x) => x) - _.difference(newBuses, oldBuses).forEach((bus) => { - commands.push({ - command: { - command: VMixCommand.AUDIO_BUS_ON, - input: key, - value: bus, - }, - context: CommandContext.None, - timelineId: '', - }) + if (oldInput.transform === undefined || input.transform.alpha !== oldInput.transform.alpha) { + commands.push({ + command: { + command: VMixCommand.SET_ALPHA, + input: key, + value: input.transform.alpha, + }, + context: CommandContext.None, + timelineId: '', }) - _.difference(oldBuses, newBuses).forEach((bus) => { - commands.push({ - command: { - command: VMixCommand.AUDIO_BUS_OFF, - input: key, - value: bus, - }, - context: CommandContext.None, - timelineId: '', - }) + } + if (oldInput.transform === undefined || input.transform.panX !== oldInput.transform.panX) { + commands.push({ + command: { + command: VMixCommand.SET_PAN_X, + input: key, + value: input.transform.panX, + }, + context: CommandContext.None, + timelineId: '', }) } - if (input.muted !== undefined && oldInput.muted !== input.muted && !input.muted) { + if (oldInput.transform === undefined || input.transform.panY !== oldInput.transform.panY) { commands.push({ command: { - command: VMixCommand.AUDIO_ON, + command: VMixCommand.SET_PAN_Y, input: key, + value: input.transform.panY, }, context: CommandContext.None, timelineId: '', }) } - if (input.transform !== undefined && !_.isEqual(oldInput.transform, input.transform)) { - if (oldInput.transform === undefined || input.transform.zoom !== oldInput.transform.zoom) { - commands.push({ - command: { - command: VMixCommand.SET_ZOOM, - input: key, - value: input.transform.zoom, - }, - context: CommandContext.None, - timelineId: '', - }) - } - if (oldInput.transform === undefined || input.transform.alpha !== oldInput.transform.alpha) { - commands.push({ - command: { - command: VMixCommand.SET_ALPHA, - input: key, - value: input.transform.alpha, - }, - context: CommandContext.None, - timelineId: '', - }) - } - if (oldInput.transform === undefined || input.transform.panX !== oldInput.transform.panX) { + } + if (input.overlays !== undefined && !_.isEqual(oldInput.overlays, input.overlays)) { + for (const index of Object.keys(input.overlays)) { + if (input.overlays !== oldInput.overlays?.[index]) { commands.push({ command: { - command: VMixCommand.SET_PAN_X, + command: VMixCommand.SET_INPUT_OVERLAY, input: key, - value: input.transform.panX, + value: input.overlays[Number(index)], + index: Number(index), }, context: CommandContext.None, timelineId: '', }) } - if (oldInput.transform === undefined || input.transform.panY !== oldInput.transform.panY) { + } + for (const index of Object.keys(oldInput.overlays ?? {})) { + if (!input.overlays?.[index]) { commands.push({ command: { - command: VMixCommand.SET_PAN_Y, + command: VMixCommand.SET_INPUT_OVERLAY, input: key, - value: input.transform.panY, + value: '', + index: Number(index), }, context: CommandContext.None, timelineId: '', }) } } - if (input.overlays !== undefined && !_.isEqual(oldInput.overlays, input.overlays)) { - Object.keys(input.overlays).forEach((index) => { - if (input.overlays !== oldInput.overlays?.[index]) { - commands.push({ - command: { - command: VMixCommand.SET_INPUT_OVERLAY, - input: key, - value: input.overlays![Number(index)], - index: Number(index), - }, - context: CommandContext.None, - timelineId: '', - }) - } - }) - Object.keys(oldInput?.overlays || {}).forEach((index) => { - if (!input.overlays?.[index]) { - commands.push({ - command: { - command: VMixCommand.SET_INPUT_OVERLAY, - input: key, - value: '', - index: Number(index), - }, - context: CommandContext.None, - timelineId: '', - }) - } - }) - } - if (input.playing !== undefined && oldInput.playing !== input.playing && input.playing) { - commands.push({ - command: { - command: VMixCommand.PLAY_INPUT, - input: input.name, - }, - context: CommandContext.None, - timelineId: '', - }) - } - }) + } + if (input.playing !== undefined && oldInput.playing !== input.playing && input.playing) { + commands.push({ + command: { + command: VMixCommand.PLAY_INPUT, + input: input.name, + }, + context: CommandContext.None, + timelineId: '', + }) + } return { preTransitionCommands, postTransitionCommands } } - private _resolveInputsRemovalState( - oldVMixState: VMixStateExtended, - newVMixState: VMixStateExtended + private _resolveAddedByUsInputState( + oldInput: VMixInput | undefined, + input: VMixInput, + key: string, + oldVMixState: VMixStateExtended + ): PreAndPostTransitionCommands { + if (input.name === undefined) { + input.name = key + } + const actualName = key.substring(TSR_INPUT_PREFIX.length) + if (oldInput == null && input.type !== undefined) { + const addCommands: Array = [] + addCommands.push({ + command: { + command: VMixCommand.ADD_INPUT, + value: `${input.type}|${actualName}`, + }, + context: CommandContext.None, + timelineId: '', + }) + addCommands.push({ + command: { + command: VMixCommand.SET_INPUT_NAME, + input: this._getFilename(actualName), + value: key, + }, + context: CommandContext.None, + timelineId: '', + }) + this.queueNow(addCommands) + } + + oldInput ??= this.getDefaultInputState(0) // or {} but we assume that a new input has all parameters default + + return this._resolveInputState(oldVMixState, oldInput, input, key) + } + + private _resolveAddedByUsInputsRemovalState( + oldVMixState: VMixState, + newVMixState: VMixState ): Array { const commands: Array = [] - _.difference( - Object.keys(oldVMixState.reportedState.inputs), - Object.keys(newVMixState.reportedState.inputs) - ).forEach((input) => { - if (oldVMixState.reportedState.inputs[input].type !== undefined) { + _.difference(Object.keys(oldVMixState.inputsAddedByUs), Object.keys(newVMixState.inputsAddedByUs)).forEach( + (input) => { // TODO: either schedule this command for later or make the timeline object long enough to prevent removing while transitioning commands.push({ command: { command: VMixCommand.REMOVE_INPUT, - input: oldVMixState.reportedState.inputs[input].name || input, + input: oldVMixState.inputsAddedByUs[input].name || input, }, context: CommandContext.None, timelineId: '', }) } - }) + ) return commands } @@ -655,13 +691,10 @@ export class VMixStateDiffer { return commands } - private _resolveRecordingState( - oldVMixState: VMixStateExtended, - newVMixState: VMixStateExtended - ): Array { + private _resolveRecordingState(oldVMixState: VMixState, newVMixState: VMixState): Array { const commands: Array = [] - if (oldVMixState.reportedState.recording !== newVMixState.reportedState.recording) { - if (newVMixState.reportedState.recording) { + if (newVMixState.recording != null && oldVMixState.recording !== newVMixState.recording) { + if (newVMixState.recording) { commands.push({ command: { command: VMixCommand.START_RECORDING, @@ -682,13 +715,10 @@ export class VMixStateDiffer { return commands } - private _resolveStreamingState( - oldVMixState: VMixStateExtended, - newVMixState: VMixStateExtended - ): Array { + private _resolveStreamingState(oldVMixState: VMixState, newVMixState: VMixState): Array { const commands: Array = [] - if (oldVMixState.reportedState.streaming !== newVMixState.reportedState.streaming) { - if (newVMixState.reportedState.streaming) { + if (newVMixState.streaming != null && oldVMixState.streaming !== newVMixState.streaming) { + if (newVMixState.streaming) { commands.push({ command: { command: VMixCommand.START_STREAMING, @@ -709,13 +739,10 @@ export class VMixStateDiffer { return commands } - private _resolveExternalState( - oldVMixState: VMixStateExtended, - newVMixState: VMixStateExtended - ): Array { + private _resolveExternalState(oldVMixState: VMixState, newVMixState: VMixState): Array { const commands: Array = [] - if (oldVMixState.reportedState.external !== newVMixState.reportedState.external) { - if (newVMixState.reportedState.external) { + if (newVMixState.external != null && oldVMixState.external !== newVMixState.external) { + if (newVMixState.external) { commands.push({ command: { command: VMixCommand.START_EXTERNAL, @@ -741,10 +768,10 @@ export class VMixStateDiffer { newVMixState: VMixStateExtended ): Array { const commands: Array = [] - _.map(newVMixState.outputs, (output, name) => { + for (const [name, output] of Object.entries({ ...newVMixState.outputs })) { const nameKey = name as keyof VMixStateExtended['outputs'] const oldOutput = nameKey in oldVMixState.outputs ? oldVMixState.outputs[nameKey] : undefined - if (!_.isEqual(output, oldOutput)) { + if (output != null && !_.isEqual(output, oldOutput)) { const value = output.source === 'Program' ? 'Output' : output.source commands.push({ command: { @@ -757,7 +784,7 @@ export class VMixStateDiffer { timelineId: '', }) } - }) + } return commands } @@ -808,7 +835,9 @@ export class VMixStateDiffer { if (typeof mix.program === 'undefined') continue - const pgmInput = state.reportedState.inputs[mix.program] as VMixInput | undefined + const pgmInput = + state.reportedState.existingInputs[mix.program] ?? + (state.reportedState.inputsAddedByUs[mix.program] as VMixInput | undefined) if (!pgmInput || !pgmInput.overlays) continue for (const layer of Object.keys(pgmInput.overlays)) { @@ -829,8 +858,8 @@ export class VMixStateDiffer { } } - for (const output of Object.values(state.outputs)) { - if (output.input === input.name || output.input === input.number) { + for (const output of Object.values({ ...state.outputs })) { + if (output != null && (output.input === input.name || output.input === input.number)) { // Input might not technically be in PGM, but it's being used by an output, // so stop the search and return true. return true diff --git a/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts index ae6e13895..c58d7e748 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts @@ -10,7 +10,7 @@ import { VMixTransition, VMixTransitionType, } from 'timeline-state-resolver-types' -import { VMixInput, VMixStateExtended } from './vMixStateDiffer' +import { TSR_INPUT_PREFIX, VMixInput, VMixState, VMixStateExtended } from './VMixStateDiffer' import * as deepMerge from 'deepmerge' import _ = require('underscore') @@ -29,24 +29,26 @@ const mappingPriority: { [k in MappingVmixType]: number } = { [MappingVmixType.Script]: 11, } +export type MappingsVmix = Mappings + export class VMixTimelineStateConverter { constructor( private readonly getDefaultState: () => VMixStateExtended, - private readonly getDefaultInputState: (num: number) => VMixInput + private readonly getDefaultInputState: (inputIndex: number | string | undefined) => VMixInput ) {} getVMixStateFromTimelineState( state: Timeline.TimelineState, - mappings: Mappings + mappings: MappingsVmix ): VMixStateExtended { - const deviceState = this.getDefaultState() + const deviceState = this._fillStateWithMappingsDefaults(this.getDefaultState(), mappings) // Sort layer based on Mapping type (to make sure audio is after inputs) and Layer name const sortedLayers = _.sortBy( _.map(state.layers, (tlObject, layerName) => ({ layerName, tlObject, - mapping: mappings[layerName] as Mapping, + mapping: mappings[layerName], })).sort((a, b) => a.layerName.localeCompare(b.layerName)), (o) => mappingPriority[o.mapping.options.mappingType] ?? Number.POSITIVE_INFINITY ) @@ -60,9 +62,9 @@ export class VMixTimelineStateConverter { if (content.type === TimelineContentTypeVMix.PROGRAM) { const mixProgram = (mapping.options.index || 1) - 1 if (content.input !== undefined) { - this.switchToInput(content.input, deviceState, mixProgram, content.transition) + this._switchToInput(content.input, deviceState, mixProgram, content.transition) } else if (content.inputLayer) { - this.switchToInput(content.inputLayer, deviceState, mixProgram, content.transition, true) + this._switchToInput(content.inputLayer, deviceState, mixProgram, content.transition, true) } } break @@ -76,11 +78,11 @@ export class VMixTimelineStateConverter { if (content.type === TimelineContentTypeVMix.AUDIO) { const vmixTlAudioPicked = _.pick(content, 'volume', 'balance', 'audioAuto', 'audioBuses', 'muted', 'fade') if (mapping.options.index) { - deviceState.reportedState.inputs = this.modifyInput(deviceState, vmixTlAudioPicked, { + deviceState.reportedState = this._modifyInput(deviceState, vmixTlAudioPicked, { key: mapping.options.index, }) } else if (mapping.options.inputLayer) { - deviceState.reportedState.inputs = this.modifyInput(deviceState, vmixTlAudioPicked, { + deviceState.reportedState = this._modifyInput(deviceState, vmixTlAudioPicked, { layer: mapping.options.inputLayer, }) } @@ -113,7 +115,7 @@ export class VMixTimelineStateConverter { break case MappingVmixType.Input: if (content.type === TimelineContentTypeVMix.INPUT) { - deviceState.reportedState.inputs = this.modifyInput( + deviceState.reportedState = this._modifyInput( deviceState, { type: content.inputType, @@ -126,7 +128,7 @@ export class VMixTimelineStateConverter { restart: content.restart, }, - { key: mapping.options.index || content.filePath }, + { key: mapping.options.index, filePath: content.filePath }, layerName ) } @@ -156,35 +158,34 @@ export class VMixTimelineStateConverter { return deviceState } - modifyInput( + private _modifyInput( deviceState: VMixStateExtended, newInput: VMixInput, - input: { key?: string | number; layer?: string }, + input: { key?: string | number; layer?: string; filePath?: string }, layerName?: string - ): { [key: string]: VMixInput } { - const inputs = deviceState.reportedState.inputs + ): VMixState { + let inputs = deviceState.reportedState.existingInputs const newInputPicked = _.pick(newInput, (x) => !_.isUndefined(x)) let inputKey: string | number | undefined if (input.layer) { inputKey = deviceState.inputLayers[input.layer] + inputs = deviceState.reportedState.inputsAddedByUs + } else if (input.filePath) { + inputKey = TSR_INPUT_PREFIX + input.filePath + inputs = deviceState.reportedState.inputsAddedByUs } else { - inputKey = input.key! + inputKey = input.key } if (inputKey) { - if (inputKey in inputs) { - inputs[inputKey] = deepMerge(inputs[inputKey], newInputPicked) - } else { - const inputState = this.getDefaultInputState(0) - inputs[inputKey] = deepMerge(inputState, newInputPicked) - } + inputs[inputKey] = deepMerge(inputs[inputKey] ?? this.getDefaultInputState(inputKey), newInputPicked) if (layerName) { deviceState.inputLayers[layerName] = inputKey as string } } - return inputs + return deviceState.reportedState } - switchToInput( + private _switchToInput( input: number | string, deviceState: VMixStateExtended, mix: number, @@ -203,4 +204,34 @@ export class VMixTimelineStateConverter { mixState.layerToProgram = layerToProgram } } + + private _fillStateWithMappingsDefaults(state: VMixStateExtended, mappings: MappingsVmix) { + for (const mapping of Object.values>(mappings)) { + switch (mapping.options.mappingType) { + case MappingVmixType.Input: + if (mapping.options.index) { + state.reportedState.existingInputs[mapping.options.index] = this.getDefaultInputState(mapping.options.index) + } + break + case MappingVmixType.AudioChannel: + if (mapping.options.index) { + state.reportedState.existingInputs[mapping.options.index] = this.getDefaultInputState(mapping.options.index) + } + break + case MappingVmixType.Recording: + state.reportedState.recording = false + break + case MappingVmixType.Streaming: + state.reportedState.streaming = false + break + case MappingVmixType.External: + state.reportedState.external = false + break + case MappingVmixType.Output: + state.outputs[mapping.options.index] = { source: 'Program' } + break + } + } + return state + } } From d80a945b4bc4f7ed8928a3cce7629cf843299f90 Mon Sep 17 00:00:00 2001 From: ianshade Date: Mon, 18 Dec 2023 22:11:41 +0100 Subject: [PATCH 3/6] refactor(vMix): separate input audio, fix overlay bugs, add test --- .../src/integrations/vmix/README.md | 22 ++ .../integrations/vmix/VMixXmlStateParser.ts | 75 +++-- .../vmix/__tests__/VMixStateDiffer.spec.ts | 35 +++ .../__tests__/VMixStateSynchronizer.spec.ts | 162 +--------- .../VMixTimelineStateConverter.spec.ts | 163 +++++++++++ .../vmix/__tests__/VMixXmlStateParser.spec.ts | 97 ++++-- .../integrations/vmix/__tests__/mockState.ts | 173 +++++++++++ .../integrations/vmix/__tests__/vmix.spec.ts | 84 +++--- .../src/integrations/vmix/connection.ts | 8 +- .../src/integrations/vmix/index.ts | 8 +- .../src/integrations/vmix/vMixStateDiffer.ts | 277 ++++++++++-------- .../vmix/vMixTimelineStateConverter.ts | 74 ++++- 12 files changed, 777 insertions(+), 401 deletions(-) create mode 100644 packages/timeline-state-resolver/src/integrations/vmix/README.md create mode 100644 packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixStateDiffer.spec.ts create mode 100644 packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixTimelineStateConverter.spec.ts create mode 100644 packages/timeline-state-resolver/src/integrations/vmix/__tests__/mockState.ts diff --git a/packages/timeline-state-resolver/src/integrations/vmix/README.md b/packages/timeline-state-resolver/src/integrations/vmix/README.md new file mode 100644 index 000000000..4aa2aeda6 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/vmix/README.md @@ -0,0 +1,22 @@ +This is the vMix integration. + +## Shared control + +Similarly to the TriCaster integration, only resources (features) that have corresponding mappings explicitly defined are controlled by the TSR and will have commands generated. Those resources may receive commands resetting them to TSR-defined defaults when no timeline objects are present on corresponding layers. The resources are divided as follows: + - inputs - each input individually, when a corresponding mapping of type `MappingVmixType.Input` exists + - audio channels - each audio channel (input), when a corresponding mapping of type `MappingVmixType.AudioChannel` exists + - outputs - each output individually, when a corresponding mapping of type `MappingVmixType.Output` exists + - overlays - each overlay individually, when a corresponding mapping of type `MappingVmixType.Overlay` exists + - recording - when a mapping of type `MappingVmixType.Recording` exists + - streaming - when a mapping of type `MappingVmixType.Streaming` exists + - external - when a mapping of type `MappingVmixType.External` exists + - mix inputs and main mix - each mix individually when at least one of corresponding mappings of type `MappingVmixType.Program` or `MappingVmixType.Preview` exists + +## Current limitations + + - For most purposes, referencing inputs by numbers is recommended. Mappings refrencing inputs by names are suitable only when the names are known to be unique. However, the state read from vMix primarily uses input numbers, so restart of the TSR when names were used in the mappings and on the timeline, might trigger some unwanted transitions. Mixing string and numeric names may lead to even more unexpected results. + - Adding more than one input with the same `filePath` is not supported. + +## Known bugs + + - Commands adding inputs may be sent multiple times, resulting in the same input being added more than once. Fixed in release51. diff --git a/packages/timeline-state-resolver/src/integrations/vmix/VMixXmlStateParser.ts b/packages/timeline-state-resolver/src/integrations/vmix/VMixXmlStateParser.ts index f2f7d6c46..7afb930fc 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/VMixXmlStateParser.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/VMixXmlStateParser.ts @@ -1,11 +1,10 @@ import * as xml from 'xml-js' -import { TSR_INPUT_PREFIX, VMixInput, VMixMix, VMixState } from './VMixStateDiffer' +import { TSR_INPUT_PREFIX, VMixInput, VMixInputAudio, VMixMix, VMixState } from './VMixStateDiffer' import { InferredPartialInputStateKeys } from './connection' import { VMixTransitionType } from 'timeline-state-resolver-types' -import _ = require('underscore') /** - * Parses the state incoming from vMix + * Parses the state incoming from vMix into a TSR representation */ export class VMixXmlStateParser { parseVMixState(responseBody: string): VMixState { @@ -14,35 +13,36 @@ export class VMixXmlStateParser { let mixes = xmlState['vmix']['mix'] mixes = Array.isArray(mixes) ? mixes : mixes ? [mixes] : [] - const fixedInputs: VMixInput[] = [] - const inputsAddedByUs: VMixInput[] = [] + const existingInputs: Record = {} + const existingInputsAudio: Record = {} + const inputsAddedByUs: Record = {} + const inputsAddedByUsAudio: Record = {} + + const inputKeysToNumbers: Record = {} + for (const input of xmlState['vmix']['inputs']['input'] as Omit[]) { + inputKeysToNumbers[input['_attributes']['key']] = Number(input['_attributes']['number']) + } for (const input of xmlState['vmix']['inputs']['input'] as Omit[]) { const title = input['_attributes']['title'] as string + const inputNumber = Number(input['_attributes']['number']) const isAddedByUs = title.startsWith(TSR_INPUT_PREFIX) let fixedListFilePaths: VMixInput['listFilePaths'] = undefined - if (input['_attributes']['type'] === 'VideoList') { - if (Array.isArray(input['list']['item'])) { - // Handles the case where there is more than one item in the list. - fixedListFilePaths = input['list']['item'].map((item) => item['_text']) - } else if (input['list']['item']) { - // Handles the case where there is exactly one item in the list. - fixedListFilePaths = [input['list']['item']['_text']] - } + if (input['_attributes']['type'] === 'VideoList' && input['list']['item'] != null) { + fixedListFilePaths = this.ensureArray(input['list']['item']).map((item) => item['_text']) } - let fixedOverlays: VMixInput['overlays'] = undefined - if (Array.isArray(input['overlay'])) { - // Handles the case where there is more than one item in the list. - fixedOverlays = input['overlay'].map((item) => parseInt(item['_attributes']['index'], 10)) - } else if (input['overlay']) { - // Handles the case where there is exactly one item in the list. - fixedOverlays = [parseInt(input['overlay']['_attributes']['index'], 10)] + const overlays: VMixInput['overlays'] = {} + if (input['overlay'] != null) { + this.ensureArray(input['overlay']).forEach( + (item) => + (overlays[parseInt(item['_attributes']['index'], 10) + 1] = inputKeysToNumbers[item['_attributes']['key']]) + ) } const result: VMixInput = { - number: Number(input['_attributes']['number']), + number: inputNumber, type: input['_attributes']['type'], name: isAddedByUs ? title : undefined, state: input['_attributes']['state'], @@ -50,25 +50,32 @@ export class VMixXmlStateParser { position: Number(input['_attributes']['position']) || 0, duration: Number(input['_attributes']['duration']) || 0, loop: input['_attributes']['loop'] !== 'False', - muted: input['_attributes']['muted'] !== 'False', - volume: Number(input['_attributes']['volume'] || 100), - balance: Number(input['_attributes']['balance'] || 0), - solo: input['_attributes']['loop'] !== 'False', - audioBuses: input['_attributes']['audiobusses'], + transform: { panX: Number(input['position'] ? input['position']['_attributes']['panX'] || 0 : 0), panY: Number(input['position'] ? input['position']['_attributes']['panY'] || 0 : 0), alpha: -1, // unavailable zoom: Number(input['position'] ? input['position']['_attributes']['zoomX'] || 1 : 1), // assume that zoomX==zoomY }, - overlays: fixedOverlays!, + overlays, listFilePaths: fixedListFilePaths!, } + const resultAudio = { + muted: input['_attributes']['muted'] !== 'False', + volume: Number(input['_attributes']['volume'] || 100), + balance: Number(input['_attributes']['balance'] || 0), + solo: input['_attributes']['loop'] !== 'False', + audioBuses: input['_attributes']['audiobusses'], + } + if (isAddedByUs) { - inputsAddedByUs.push(result) + inputsAddedByUs[title] = result + inputsAddedByUsAudio[title] = resultAudio } else { - fixedInputs.push(result) + existingInputs[inputNumber] = result + existingInputsAudio[inputNumber] = resultAudio + // TODO: how about we insert those under their titles too? That should partially lift the limitation of not being able to mix string and number input indexes } } @@ -76,8 +83,10 @@ export class VMixXmlStateParser { return { version: xmlState['vmix']['version']['_text'], edition: xmlState['vmix']['edition']['_text'], - existingInputs: _.indexBy(fixedInputs, 'number'), - inputsAddedByUs: _.indexBy(inputsAddedByUs, 'name'), + existingInputs, + existingInputsAudio, + inputsAddedByUs, + inputsAddedByUsAudio, overlays: (xmlState['vmix']['overlays']['overlay'] as Array).map((overlay) => { return { number: Number(overlay['_attributes']['number']), @@ -118,4 +127,8 @@ export class VMixXmlStateParser { ], } } + + private ensureArray(value: T[] | T): T[] { + return Array.isArray(value) ? value : [value] + } } diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixStateDiffer.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixStateDiffer.spec.ts new file mode 100644 index 000000000..797980679 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixStateDiffer.spec.ts @@ -0,0 +1,35 @@ +import { VMixCommand } from 'timeline-state-resolver-types' +import { VMixStateDiffer } from '../VMixStateDiffer' +import { makeMockFullState } from './mockState' + +function createTestee(): VMixStateDiffer { + return new VMixStateDiffer(jest.fn()) +} + +/** + * Note: most of the coverage is still in vmix.spec.ts + */ +describe('VMixStateDiffer', () => { + it('does not generate commands for identical states', () => { + const differ = createTestee() + + const oldState = makeMockFullState() + const newState = makeMockFullState() + + expect(differ.getCommandsToAchieveState(oldState, newState)).toEqual([]) + }) + + it('resets audio buses when audio starts to be controlled', () => { + const differ = createTestee() + + const oldState = makeMockFullState() + const newState = makeMockFullState() + + newState.reportedState.existingInputsAudio['99'] = differ.getDefaultInputAudioState(99) + + const commands = differ.getCommandsToAchieveState(oldState, newState) + const busCommands = commands.filter((command) => command.command.command === VMixCommand.AUDIO_BUS_OFF) + + expect(busCommands.length).toBe(7) // all but Master + }) +}) diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixStateSynchronizer.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixStateSynchronizer.spec.ts index 04f623f6c..2478732bb 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixStateSynchronizer.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixStateSynchronizer.spec.ts @@ -1,161 +1,5 @@ -import { VMixTransitionType } from 'timeline-state-resolver-types' -import { TSR_INPUT_PREFIX, VMixState, VMixStateExtended } from '../VMixStateDiffer' import { VMixStateSynchronizer } from '../VMixStateSynchronizer' - -const ADDED_INPUT_NAME_1 = `${TSR_INPUT_PREFIX}C:\\someVideo.mp4` -const ADDED_INPUT_NAME_2 = `${TSR_INPUT_PREFIX}C:\\anotherVideo.mp4` - -function makeMockReportedState(): VMixState { - return { - version: '21.0.0.55', - edition: 'HD', - existingInputs: { - '1': { - number: 1, - type: 'Capture', - state: 'Running', - position: 0, - duration: 0, - loop: false, - muted: false, - volume: 100, - balance: 0, - audioBuses: 'M', - transform: { - alpha: -1, - panX: 0, - panY: 0, - zoom: 1, - }, - listFilePaths: undefined, - overlays: undefined, - playing: true, - solo: false, - }, - '2': { - number: 2, - type: 'Capture', - state: 'Running', - position: 0, - duration: 0, - loop: false, - muted: true, - volume: 100, - balance: 0, - audioBuses: 'M,C', - transform: { - alpha: -1, - panX: 0, - panY: 0, - zoom: 1, - }, - listFilePaths: undefined, - overlays: undefined, - playing: true, - solo: false, - }, - }, - inputsAddedByUs: { - [ADDED_INPUT_NAME_1]: { - number: 1, - type: 'Video', - state: 'Running', - position: 0, - duration: 0, - loop: false, - muted: false, - volume: 100, - balance: 0, - audioBuses: 'M', - transform: { - alpha: -1, - panX: 0, - panY: 0, - zoom: 1, - }, - listFilePaths: undefined, - overlays: undefined, - playing: true, - solo: false, - name: ADDED_INPUT_NAME_1, - }, - [ADDED_INPUT_NAME_2]: { - number: 1, - type: 'Video', - state: 'Running', - position: 0, - duration: 0, - loop: false, - muted: false, - volume: 100, - balance: 0, - audioBuses: 'M', - transform: { - alpha: -1, - panX: 0, - panY: 0, - zoom: 1, - }, - listFilePaths: undefined, - overlays: undefined, - playing: true, - solo: false, - name: ADDED_INPUT_NAME_2, - }, - }, - overlays: [ - { number: 1, input: undefined }, - { number: 2, input: undefined }, - { number: 3, input: undefined }, - { number: 4, input: undefined }, - { number: 5, input: undefined }, - { number: 6, input: undefined }, - ], - mixes: [ - { - number: 1, - program: 1, - preview: 2, - transition: { - duration: 0, - effect: VMixTransitionType.Cut, - }, - }, - ], - fadeToBlack: false, - recording: true, - external: true, - streaming: true, - playlist: false, - multiCorder: false, - fullscreen: false, - audio: [ - { - volume: 100, - muted: false, - meterF1: 0.04211706, - meterF2: 0.04211706, - headphonesVolume: 74.80521, - }, - ], - } -} - -function makeMockFullState(): VMixStateExtended { - return { - inputLayers: {}, - outputs: { - External2: undefined, - 2: undefined, - 3: undefined, - 4: undefined, - Fullscreen: undefined, - Fullscreen2: undefined, - }, - runningScripts: [], - reportedState: makeMockReportedState(), - } -} +import { ADDED_INPUT_NAME_1, ADDED_INPUT_NAME_2, makeMockFullState, makeMockReportedState } from './mockState' describe('VMixStateSynchronizer', () => { it('applies properties of existing inputs', () => { @@ -206,7 +50,7 @@ describe('VMixStateSynchronizer', () => { const synchronizer = new VMixStateSynchronizer() const realState = makeMockReportedState() - realState.existingInputs[1].volume = 50 + realState.existingInputs[1].playing = false realState.existingInputs[2].position = 10 const updatedState = synchronizer.applyRealState(makeMockFullState(), realState) @@ -222,7 +66,7 @@ describe('VMixStateSynchronizer', () => { const synchronizer = new VMixStateSynchronizer() const realState = makeMockReportedState() - realState.inputsAddedByUs[ADDED_INPUT_NAME_1].volume = 50 + realState.inputsAddedByUs[ADDED_INPUT_NAME_1].playing = false realState.inputsAddedByUs[ADDED_INPUT_NAME_2].position = 10 const updatedState = synchronizer.applyRealState(makeMockFullState(), realState) diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixTimelineStateConverter.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixTimelineStateConverter.spec.ts new file mode 100644 index 000000000..953722757 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixTimelineStateConverter.spec.ts @@ -0,0 +1,163 @@ +import { + DeviceType, + Mapping, + MappingVmixType, + SomeMappingVmix, + TSRTimelineContent, + Timeline, + TimelineContentTypeVMix, + TimelineContentVMixAny, + VMixInputType, +} from 'timeline-state-resolver-types' +import { VMixTimelineStateConverter } from '../VMixTimelineStateConverter' +import { VMixOutput, VMixStateDiffer } from '../VMixStateDiffer' +import { prefixAddedInput } from './mockState' + +function createTestee(): VMixTimelineStateConverter { + const stateDiffer = new VMixStateDiffer(() => { + // + }) // should those be mocks? or should this be taken from somewhere else? this is now more of an integration test, and maybe that's fine? + return new VMixTimelineStateConverter( + () => stateDiffer.getDefaultState(), + (inputNumber) => stateDiffer.getDefaultInputState(inputNumber), + (inputNumber) => stateDiffer.getDefaultInputAudioState(inputNumber) + ) +} + +function wrapInTimelineState( + layers: Timeline.StateInTime +): Timeline.TimelineState { + return { + time: Date.now(), + layers, + nextEvents: [], + } +} + +function wrapInTimelineObject( + layer: string, + content: TimelineContentVMixAny +): Timeline.ResolvedTimelineObjectInstance { + return { + id: '', + enable: { while: '1' }, + content, + layer, + } as Timeline.ResolvedTimelineObjectInstance +} + +function wrapInMapping(options: SomeMappingVmix): Mapping { + return { + device: DeviceType.VMIX, + deviceId: 'vmix0', + options, + } +} + +/** + * Note: most of the coverage is still in vmix.spec.ts + */ +describe('VMixTimelineStateConverter', () => { + it('does not track state for outputs when not mapped', () => { + const converter = createTestee() + + const result = converter.getVMixStateFromTimelineState(wrapInTimelineState({}), {}) + const controlledOutputs = Object.values({ ...result.outputs }).filter( + (output) => output !== undefined + ) + expect(controlledOutputs.length).toBe(0) + }) + + describe('inputs', () => { + it('does not track state for not mapped existing inputs', () => { + const converter = createTestee() + + const result = converter.getVMixStateFromTimelineState(wrapInTimelineState({}), {}) + expect(Object.keys(result.reportedState.existingInputs).length).toBe(0) + expect(Object.keys(result.reportedState.existingInputsAudio).length).toBe(0) + }) + + it('does not track state for not mapped inputs added by us', () => { + const converter = createTestee() + + const result = converter.getVMixStateFromTimelineState(wrapInTimelineState({}), {}) + expect(Object.keys(result.reportedState.inputsAddedByUs).length).toBe(0) + expect(Object.keys(result.reportedState.inputsAddedByUsAudio).length).toBe(0) + }) + + it('tracks state for mapped existing inputs', () => { + const converter = createTestee() + + const result = converter.getVMixStateFromTimelineState(wrapInTimelineState({}), { + inp0: wrapInMapping({ + mappingType: MappingVmixType.Input, + index: '1', + }), + }) + expect(result.reportedState.existingInputs[1]).toBeDefined() + expect(result.reportedState.existingInputsAudio[1]).toBeUndefined() // but audio is independend + }) + + it('tracks audio state for mapped existing inputs', () => { + const converter = createTestee() + + const result = converter.getVMixStateFromTimelineState(wrapInTimelineState({}), { + inp0: wrapInMapping({ + mappingType: MappingVmixType.AudioChannel, + index: '1', + }), + }) + expect(result.reportedState.existingInputs[1]).toBeUndefined() + expect(result.reportedState.existingInputsAudio[1]).toBeDefined() + }) + + it('tracks state for mapped inputs added by us', () => { + const converter = createTestee() + const filePath = 'C:\\someFile.mp4' + const result = converter.getVMixStateFromTimelineState( + wrapInTimelineState({ + inp0: wrapInTimelineObject('inp0', { + deviceType: DeviceType.VMIX, + filePath: 'C:\\someFile.mp4', + type: TimelineContentTypeVMix.INPUT, + inputType: VMixInputType.Video, + }), + }), + { + inp0: wrapInMapping({ + mappingType: MappingVmixType.Input, + }), + } + ) + expect(result.reportedState.inputsAddedByUs[prefixAddedInput(filePath)]).toBeDefined() + expect(result.reportedState.inputsAddedByUsAudio[prefixAddedInput(filePath)]).toBeUndefined() + }) + + // TODO: maybe we can't trust the defaults when adding an input? Make this test pass eventually + // it('tracks audio state for mapped inputs added by us', () => { + // const converter = createTestee() + // const filePath = 'C:\\someFile.mp4' + // const result = converter.getVMixStateFromTimelineState( + // wrapInTimelineState({ + // inp0: wrapInTimelineObject('inp0', { + // deviceType: DeviceType.VMIX, + // filePath: 'C:\\someFile.mp4', + // type: TimelineContentTypeVMix.INPUT, + // inputType: VMixInputType.Video, + // }), + // }), + // { + // inp0: wrapInMapping({ + // mappingType: MappingVmixType.Input, + // }), + // inp0_audio: wrapInMapping({ + // mappingType: MappingVmixType.AudioChannel, + // inputLayer: 'inp0', + // }), + // } + // ) + // expect(result.reportedState.inputsAddedByUs[prefixAddedInput(filePath)]).toBeDefined() + // expect(result.reportedState.inputsAddedByUsAudio[prefixAddedInput(filePath)]).toBeDefined() + // }) + }) +}) diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixXmlStateParser.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixXmlStateParser.spec.ts index 9fd289ee6..5f61e1de6 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixXmlStateParser.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixXmlStateParser.spec.ts @@ -1,7 +1,8 @@ import { VMixTransitionType } from 'timeline-state-resolver-types' -import { TSR_INPUT_PREFIX, VMixState } from '../VMixStateDiffer' +import { VMixState } from '../VMixStateDiffer' import { VMixXmlStateParser } from '../VMixXmlStateParser' import { makeMockVMixXmlState } from './vmixMock' +import { prefixAddedInput } from './mockState' describe('VMixXmlStateParser', () => { it('parses incoming state', () => { @@ -20,10 +21,6 @@ describe('VMixXmlStateParser', () => { position: 0, duration: 0, loop: false, - muted: false, - volume: 100, - balance: 0, - audioBuses: 'M', transform: { alpha: -1, panX: 0, @@ -31,10 +28,8 @@ describe('VMixXmlStateParser', () => { zoom: 1, }, listFilePaths: undefined, - // name: 'Cam 1', - overlays: undefined, + overlays: {}, playing: true, - solo: false, }, '2': { number: 2, @@ -43,10 +38,6 @@ describe('VMixXmlStateParser', () => { position: 0, duration: 0, loop: false, - muted: true, - volume: 100, - balance: 0, - audioBuses: 'M,C', transform: { alpha: -1, panX: 0, @@ -54,13 +45,28 @@ describe('VMixXmlStateParser', () => { zoom: 1, }, listFilePaths: undefined, - // name: 'Cam 2', - overlays: undefined, + overlays: {}, playing: true, + }, + }, + existingInputsAudio: { + '1': { + muted: false, + volume: 100, + balance: 0, + audioBuses: 'M', + solo: false, + }, + '2': { + muted: true, + volume: 100, + balance: 0, + audioBuses: 'M,C', solo: false, }, }, inputsAddedByUs: {}, + inputsAddedByUsAudio: {}, overlays: [ { number: 1, input: undefined }, { number: 2, input: undefined }, @@ -104,7 +110,9 @@ describe('VMixXmlStateParser', () => { const xmlState = makeMockVMixXmlState([ '', - ``, + ``, ]) const parsedState = parser.parseVMixState(xmlState) @@ -117,10 +125,6 @@ describe('VMixXmlStateParser', () => { position: 0, duration: 0, loop: false, - muted: false, - volume: 100, - balance: 0, - audioBuses: 'M', transform: { alpha: -1, panX: 0, @@ -128,24 +132,27 @@ describe('VMixXmlStateParser', () => { zoom: 1, }, listFilePaths: undefined, - //name: 'Cam 1', - overlays: undefined, + overlays: {}, playing: true, + }, + }, + existingInputsAudio: { + '1': { + muted: false, + volume: 100, + balance: 0, + audioBuses: 'M', solo: false, }, }, inputsAddedByUs: { - [`${TSR_INPUT_PREFIX}C:\\someVideo.mp4`]: { + [prefixAddedInput('C:\\someVideo.mp4')]: { number: 2, type: 'Video', state: 'Running', position: 0, duration: 0, loop: false, - muted: true, - volume: 100, - balance: 0, - audioBuses: 'M,C', transform: { alpha: -1, panX: 0, @@ -153,12 +160,46 @@ describe('VMixXmlStateParser', () => { zoom: 1, }, listFilePaths: undefined, - name: `${TSR_INPUT_PREFIX}C:\\someVideo.mp4`, - overlays: undefined, + name: prefixAddedInput('C:\\someVideo.mp4'), + overlays: {}, playing: true, + }, + }, + inputsAddedByUsAudio: { + [prefixAddedInput('C:\\someVideo.mp4')]: { + muted: true, + volume: 100, + balance: 0, + audioBuses: 'M,C', solo: false, }, }, }) }) + + it('parses input overlays', () => { + const parser = new VMixXmlStateParser() + + const parsedState = parser.parseVMixState( + makeMockVMixXmlState([ + '', + ` + + +`, + '', + ]) + ) + + expect(parsedState).toMatchObject>({ + existingInputs: { + '2': { + overlays: { + 3: 3, + 6: 1, + }, + }, + }, + }) + }) }) diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/mockState.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/mockState.ts new file mode 100644 index 000000000..806b9d814 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/mockState.ts @@ -0,0 +1,173 @@ +import { VMixTransitionType } from 'timeline-state-resolver-types' +import { TSR_INPUT_PREFIX, VMixState, VMixStateExtended } from '../VMixStateDiffer' + +export const ADDED_INPUT_NAME_1 = `${TSR_INPUT_PREFIX}C:\\someVideo.mp4` +export const ADDED_INPUT_NAME_2 = `${TSR_INPUT_PREFIX}C:\\anotherVideo.mp4` + +export function makeMockReportedState(): VMixState { + return { + version: '21.0.0.55', + edition: 'HD', + existingInputs: { + '1': { + number: 1, + type: 'Capture', + state: 'Running', + position: 0, + duration: 0, + loop: false, + transform: { + alpha: -1, + panX: 0, + panY: 0, + zoom: 1, + }, + listFilePaths: undefined, + overlays: undefined, + playing: true, + }, + '2': { + number: 2, + type: 'Capture', + state: 'Running', + position: 0, + duration: 0, + loop: false, + transform: { + alpha: -1, + panX: 0, + panY: 0, + zoom: 1, + }, + listFilePaths: undefined, + overlays: undefined, + playing: true, + }, + }, + existingInputsAudio: { + '1': { + muted: false, + volume: 100, + balance: 0, + audioBuses: 'M', + solo: false, + }, + '2': { + muted: true, + volume: 100, + balance: 0, + audioBuses: 'M,C', + solo: false, + }, + }, + inputsAddedByUs: { + [ADDED_INPUT_NAME_1]: { + number: 1, + type: 'Video', + state: 'Running', + position: 0, + duration: 0, + loop: false, + transform: { + alpha: -1, + panX: 0, + panY: 0, + zoom: 1, + }, + listFilePaths: undefined, + overlays: undefined, + playing: true, + name: ADDED_INPUT_NAME_1, + }, + [ADDED_INPUT_NAME_2]: { + number: 1, + type: 'Video', + state: 'Running', + position: 0, + duration: 0, + loop: false, + transform: { + alpha: -1, + panX: 0, + panY: 0, + zoom: 1, + }, + listFilePaths: undefined, + overlays: undefined, + playing: true, + name: ADDED_INPUT_NAME_2, + }, + }, + inputsAddedByUsAudio: { + '1': { + muted: false, + volume: 100, + balance: 0, + audioBuses: 'M', + solo: false, + }, + '2': { + muted: false, + volume: 100, + balance: 0, + audioBuses: 'M', + solo: false, + }, + }, + overlays: [ + { number: 1, input: undefined }, + { number: 2, input: undefined }, + { number: 3, input: undefined }, + { number: 4, input: undefined }, + { number: 5, input: undefined }, + { number: 6, input: undefined }, + ], + mixes: [ + { + number: 1, + program: 1, + preview: 2, + transition: { + duration: 0, + effect: VMixTransitionType.Cut, + }, + }, + ], + fadeToBlack: false, + recording: true, + external: true, + streaming: true, + playlist: false, + multiCorder: false, + fullscreen: false, + audio: [ + { + volume: 100, + muted: false, + meterF1: 0.04211706, + meterF2: 0.04211706, + headphonesVolume: 74.80521, + }, + ], + } +} + +export function makeMockFullState(): VMixStateExtended { + return { + inputLayers: {}, + outputs: { + External2: undefined, + 2: undefined, + 3: undefined, + 4: undefined, + Fullscreen: undefined, + Fullscreen2: undefined, + }, + runningScripts: [], + reportedState: makeMockReportedState(), + } +} + +export function prefixAddedInput(inputName: string): string { + return TSR_INPUT_PREFIX + inputName +} diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vmix.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vmix.spec.ts index 4c4c5b3e2..c0e05e4c6 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vmix.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vmix.spec.ts @@ -28,7 +28,7 @@ import { VMixDevice } from '..' import { MockTime } from '../../../__tests__/mockTime' import '../../../__tests__/lib' import { CommandContext } from '../vMixCommands' -import { TSR_INPUT_PREFIX } from '../VMixStateDiffer' +import { prefixAddedInput } from './mockState' const orgSetTimeout = setTimeout @@ -166,7 +166,7 @@ describe('vMix', () => { command: { command: VMixCommand.SET_INPUT_NAME, input: 'My Clip.mp4', - value: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, + value: prefixAddedInput('C:/videos/My Clip.mp4'), }, }), CommandContext.None, @@ -183,7 +183,7 @@ describe('vMix', () => { expect(onFunction).toHaveBeenNthCalledWith( 2, 'SetInputName', - expect.stringContaining(`Input=My Clip.mp4&Value=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`) + expect.stringContaining('Input=My Clip.mp4&Value=' + prefixAddedInput('C:/videos/My Clip.mp4')) ) clearMocks() @@ -199,7 +199,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.PLAY_INPUT, - input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, + input: prefixAddedInput('C:/videos/My Clip.mp4'), }, }), CommandContext.None, @@ -210,7 +210,7 @@ describe('vMix', () => { expect(onFunction).toHaveBeenNthCalledWith( 1, 'Play', - expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`) + expect.stringContaining('Input=' + prefixAddedInput('C:/videos/My Clip.mp4')) ) clearMocks() @@ -225,7 +225,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.REMOVE_INPUT, - input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, + input: prefixAddedInput('C:/videos/My Clip.mp4'), }, }), CommandContext.None, @@ -236,7 +236,7 @@ describe('vMix', () => { expect(onFunction).toHaveBeenNthCalledWith( 1, 'RemoveInput', - expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`) + expect.stringContaining('Input=' + prefixAddedInput('C:/videos/My Clip.mp4')) ) await myConductor.destroy() @@ -355,7 +355,7 @@ describe('vMix', () => { command: { command: VMixCommand.SET_INPUT_NAME, input: 'My Clip.mp4', - value: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, + value: prefixAddedInput('C:/videos/My Clip.mp4'), }, }), CommandContext.None, @@ -372,7 +372,7 @@ describe('vMix', () => { expect(onFunction).toHaveBeenNthCalledWith( 2, 'SetInputName', - expect.stringContaining(`Input=My Clip.mp4&Value=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`) + expect.stringContaining('Input=My Clip.mp4&Value=' + prefixAddedInput('C:/videos/My Clip.mp4')) ) clearMocks() @@ -388,7 +388,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.SET_POSITION, - input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, + input: prefixAddedInput('C:/videos/My Clip.mp4'), value: 10000, }, }), @@ -401,7 +401,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.LOOP_ON, - input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, + input: prefixAddedInput('C:/videos/My Clip.mp4'), }, }), CommandContext.None, @@ -413,7 +413,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.SET_ZOOM, - input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, + input: prefixAddedInput('C:/videos/My Clip.mp4'), value: 0.5, }, }), @@ -426,7 +426,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.SET_ALPHA, - input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, + input: prefixAddedInput('C:/videos/My Clip.mp4'), value: 123, }, }), @@ -439,7 +439,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.SET_PAN_X, - input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, + input: prefixAddedInput('C:/videos/My Clip.mp4'), value: 0.3, }, }), @@ -452,7 +452,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.SET_PAN_Y, - input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, + input: prefixAddedInput('C:/videos/My Clip.mp4'), value: 1.2, }, }), @@ -465,7 +465,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.SET_INPUT_OVERLAY, - input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, + input: prefixAddedInput('C:/videos/My Clip.mp4'), index: 1, value: 'G:/videos/My Other Clip.mp4', }, @@ -479,7 +479,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.SET_INPUT_OVERLAY, - input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, + input: prefixAddedInput('C:/videos/My Clip.mp4'), index: 3, value: 5, }, @@ -493,7 +493,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.PLAY_INPUT, - input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, + input: prefixAddedInput('C:/videos/My Clip.mp4'), }, }), CommandContext.None, @@ -505,47 +505,49 @@ describe('vMix', () => { expect(onFunction).toHaveBeenNthCalledWith( 1, 'SetPosition', - expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4&Value=10000`) + expect.stringContaining('Input=' + prefixAddedInput('C:/videos/My Clip.mp4') + '&Value=10000') ) expect(onFunction).toHaveBeenNthCalledWith( 2, 'LoopOn', - expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`) + expect.stringContaining('Input=' + prefixAddedInput('C:/videos/My Clip.mp4')) ) expect(onFunction).toHaveBeenNthCalledWith( 3, 'SetZoom', - expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4&Value=0.5`) + expect.stringContaining('Input=' + prefixAddedInput('C:/videos/My Clip.mp4') + '&Value=0.5') ) expect(onFunction).toHaveBeenNthCalledWith( 4, 'SetAlpha', - expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4&Value=123`) + expect.stringContaining('Input=' + prefixAddedInput('C:/videos/My Clip.mp4') + '&Value=123') ) expect(onFunction).toHaveBeenNthCalledWith( 5, 'SetPanX', - expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4&Value=0.3`) + expect.stringContaining('Input=' + prefixAddedInput('C:/videos/My Clip.mp4') + '&Value=0.3') ) expect(onFunction).toHaveBeenNthCalledWith( 6, 'SetPanY', - expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4&Value=1.2`) + expect.stringContaining('Input=' + prefixAddedInput('C:/videos/My Clip.mp4') + '&Value=1.2') ) expect(onFunction).toHaveBeenNthCalledWith( 7, 'SetMultiViewOverlay', - expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4&Value=1,G:/videos/My Other Clip.mp4`) + expect.stringContaining( + 'Input=' + prefixAddedInput('C:/videos/My Clip.mp4') + '&Value=1,G:/videos/My Other Clip.mp4' + ) ) expect(onFunction).toHaveBeenNthCalledWith( 8, 'SetMultiViewOverlay', - expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4&Value=3,5`) + expect.stringContaining('Input=' + prefixAddedInput('C:/videos/My Clip.mp4') + '&Value=3,5') ) expect(onFunction).toHaveBeenNthCalledWith( 9, 'Play', - expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`) + expect.stringContaining('Input=' + prefixAddedInput('C:/videos/My Clip.mp4')) ) await myConductor.destroy() @@ -935,7 +937,7 @@ describe('vMix', () => { command: { command: VMixCommand.SET_INPUT_NAME, input: 'My Clip.mp4', - value: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, + value: prefixAddedInput('C:/videos/My Clip.mp4'), }, }), CommandContext.None, @@ -952,7 +954,7 @@ describe('vMix', () => { expect(onFunction).toHaveBeenNthCalledWith( 2, 'SetInputName', - expect.stringContaining(`Input=My Clip.mp4&Value=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`) + expect.stringContaining('Input=My Clip.mp4&Value=' + prefixAddedInput('C:/videos/My Clip.mp4')) ) clearMocks() @@ -968,7 +970,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.TRANSITION, - input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, + input: prefixAddedInput('C:/videos/My Clip.mp4'), duration: 0, effect: VMixTransitionType.Cut, mix: 0, @@ -983,7 +985,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.AUDIO_VOLUME, - input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, + input: prefixAddedInput('C:/videos/My Clip.mp4'), value: 25, fade: 0, }, @@ -997,12 +999,12 @@ describe('vMix', () => { expect(onFunction).toHaveBeenNthCalledWith( 1, 'Cut', - expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4&Duration=0&Mix=0`) + expect.stringContaining('Input=' + prefixAddedInput('C:/videos/My Clip.mp4') + '&Duration=0&Mix=0') ) expect(onFunction).toHaveBeenNthCalledWith( 2, 'SetVolume', - expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4&Value=25`) + expect.stringContaining('Input=' + prefixAddedInput('C:/videos/My Clip.mp4') + '&Value=25') ) clearMocks() @@ -1031,7 +1033,7 @@ describe('vMix', () => { command: { command: VMixCommand.SET_INPUT_NAME, input: 'My Other Clip.mp4', - value: `${TSR_INPUT_PREFIX}G:/videos/My Other Clip.mp4`, + value: prefixAddedInput('G:/videos/My Other Clip.mp4'), }, }), CommandContext.None, @@ -1043,7 +1045,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.TRANSITION, - input: `${TSR_INPUT_PREFIX}G:/videos/My Other Clip.mp4`, + input: prefixAddedInput('G:/videos/My Other Clip.mp4'), duration: 0, effect: VMixTransitionType.Cut, mix: 0, @@ -1058,7 +1060,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.AUDIO_VOLUME, - input: `${TSR_INPUT_PREFIX}G:/videos/My Other Clip.mp4`, + input: prefixAddedInput('G:/videos/My Other Clip.mp4'), value: 25, fade: 0, }, @@ -1072,7 +1074,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.REMOVE_INPUT, - input: `${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`, + input: prefixAddedInput('C:/videos/My Clip.mp4'), }, }), CommandContext.None, @@ -1089,22 +1091,22 @@ describe('vMix', () => { expect(onFunction).toHaveBeenNthCalledWith( 2, 'SetInputName', - expect.stringContaining(`Input=My Other Clip.mp4&Value=${TSR_INPUT_PREFIX}G:/videos/My Other Clip.mp4`) + expect.stringContaining('Input=My Other Clip.mp4&Value=' + prefixAddedInput('G:/videos/My Other Clip.mp4')) ) expect(onFunction).toHaveBeenNthCalledWith( 3, 'Cut', - expect.stringContaining(`Input=${TSR_INPUT_PREFIX}G:/videos/My Other Clip.mp4&Duration=0&Mix=0`) + expect.stringContaining('Input=' + prefixAddedInput('G:/videos/My Other Clip.mp4') + '&Duration=0&Mix=0') ) expect(onFunction).toHaveBeenNthCalledWith( 4, 'SetVolume', - expect.stringContaining(`Input=${TSR_INPUT_PREFIX}G:/videos/My Other Clip.mp4&Value=25`) + expect.stringContaining('Input=' + prefixAddedInput('G:/videos/My Other Clip.mp4') + '&Value=25') ) expect(onFunction).toHaveBeenNthCalledWith( 5, 'RemoveInput', - expect.stringContaining(`Input=${TSR_INPUT_PREFIX}C:/videos/My Clip.mp4`) + expect.stringContaining('Input=' + prefixAddedInput('C:/videos/My Clip.mp4')) ) await myConductor.destroy() diff --git a/packages/timeline-state-resolver/src/integrations/vmix/connection.ts b/packages/timeline-state-resolver/src/integrations/vmix/connection.ts index e55ebb3b5..07b7d570a 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/connection.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/connection.ts @@ -208,11 +208,9 @@ export class BaseConnection extends EventEmitter { this._connected = true this.emit('connected') } - } else { - if (this._connected) { - this._connected = false - this.emit('disconnected') - } + } else if (this._connected) { + this._connected = false + this.emit('disconnected') } } } diff --git a/packages/timeline-state-resolver/src/integrations/vmix/index.ts b/packages/timeline-state-resolver/src/integrations/vmix/index.ts index 58bd56432..055138d09 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/index.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/index.ts @@ -97,7 +97,8 @@ export class VMixDevice extends DeviceWithState this._stateDiffer.getDefaultState(), - (inputNumber: number) => this._stateDiffer.getDefaultInputState(inputNumber) + (inputNumber: number) => this._stateDiffer.getDefaultInputState(inputNumber), + (inputNumber: number) => this._stateDiffer.getDefaultInputAudioState(inputNumber) ) this._xmlStateParser = new VMixXmlStateParser() @@ -111,13 +112,14 @@ export class VMixDevice extends DeviceWithState { this._setConnected(false) this._pollingTimer?.stop() + this.emitDebug('disconnected') }) this._vMixConnection.on('error', (e) => this.emit('error', 'VMix', e)) this._vMixConnection.on('data', (data) => this._onDataReceived(data)) @@ -142,7 +144,7 @@ export class VMixDevice extends DeviceWithState { - return { - number: num, - input: undefined, - } - }), - mixes: _.map([1, 2, 3, 4], (num) => { - return { - number: num, - program: undefined, - preview: undefined, - transition: { effect: VMixTransitionType.Cut, duration: 0 }, - } - }), + inputsAddedByUsAudio: {}, + overlays: [], + mixes: [], fadeToBlack: false, faderPosition: 0, recording: undefined, @@ -178,14 +175,8 @@ export class VMixStateDiffer { return { number: Number(inputNumber) || undefined, position: 0, - muted: true, loop: false, playing: false, - volume: 100, - balance: 0, - fade: 0, - audioBuses: 'M', - audioAuto: true, transform: { zoom: 1, panX: 0, @@ -196,14 +187,26 @@ export class VMixStateDiffer { } } + getDefaultInputAudioState(inputNumber: number | string | undefined): VMixInputAudio { + return { + number: Number(inputNumber) || undefined, + muted: true, + volume: 100, + balance: 0, + fade: 0, + audioBuses: 'M', + audioAuto: true, + } + } + private _resolveMixState( oldVMixState: VMixStateExtended, newVMixState: VMixStateExtended ): Array { const commands: Array = [] - for (let i = 0; i < 4; i++) { + newVMixState.reportedState.mixes.forEach((_mix, i) => { /** - * It is *not* guaranteed to have all 4 mixes present in the vMix state, for reasons unknown. + * It is *not* guaranteed to have all mixes present in the vMix state because it's a sparse array. */ const oldMixState = oldVMixState.reportedState.mixes[i] as VMixMix | undefined const newMixState = newVMixState.reportedState.mixes[i] as VMixMix | undefined @@ -245,9 +248,9 @@ export class VMixStateDiffer { timelineId: '', }) } - } + }) // Only set fader bar position if no other transitions are happening - if (oldVMixState.reportedState.mixes[0].program === newVMixState.reportedState.mixes[0].program) { + if (oldVMixState.reportedState.mixes[0]?.program === newVMixState.reportedState.mixes[0]?.program) { if (newVMixState.reportedState.faderPosition !== oldVMixState.reportedState.faderPosition) { commands.push({ command: { @@ -282,7 +285,7 @@ export class VMixStateDiffer { const preTransitionCommands: Array = [] const postTransitionCommands: Array = [] _.map(newVMixState.reportedState.existingInputs, (input, key) => - this._resolveStationaryInputState(oldVMixState.reportedState.existingInputs[key], input, key, oldVMixState) + this._resolveExistingInputState(oldVMixState.reportedState.existingInputs[key], input, key, oldVMixState) ).forEach((commands) => { preTransitionCommands.push(...commands.preTransitionCommands) postTransitionCommands.push(...commands.postTransitionCommands) @@ -296,13 +299,13 @@ export class VMixStateDiffer { return { preTransitionCommands, postTransitionCommands } } - private _resolveStationaryInputState( + private _resolveExistingInputState( oldInput: VMixInput | undefined, input: VMixInput, key: string, oldVMixState: VMixStateExtended ): PreAndPostTransitionCommands { - oldInput ??= this.getDefaultInputState(0) // or {} but we assume that a new input has all parameters default + oldInput ??= {} // if we just started controlling it (e.g. due to mappings change), we don't know anything about the input return this._resolveInputState(oldVMixState, oldInput, input, key) } @@ -421,6 +424,125 @@ export class VMixStateDiffer { }) } } + if (input.transform !== undefined && !_.isEqual(oldInput.transform, input.transform)) { + if (oldInput.transform === undefined || input.transform.zoom !== oldInput.transform.zoom) { + commands.push({ + command: { + command: VMixCommand.SET_ZOOM, + input: key, + value: input.transform.zoom, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if (oldInput.transform === undefined || input.transform.alpha !== oldInput.transform.alpha) { + commands.push({ + command: { + command: VMixCommand.SET_ALPHA, + input: key, + value: input.transform.alpha, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if (oldInput.transform === undefined || input.transform.panX !== oldInput.transform.panX) { + commands.push({ + command: { + command: VMixCommand.SET_PAN_X, + input: key, + value: input.transform.panX, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if (oldInput.transform === undefined || input.transform.panY !== oldInput.transform.panY) { + commands.push({ + command: { + command: VMixCommand.SET_PAN_Y, + input: key, + value: input.transform.panY, + }, + context: CommandContext.None, + timelineId: '', + }) + } + } + if (input.overlays !== undefined && !_.isEqual(oldInput.overlays, input.overlays)) { + for (const index of Object.keys(input.overlays)) { + if (oldInput.overlays === undefined || input.overlays[index] !== oldInput.overlays?.[index]) { + commands.push({ + command: { + command: VMixCommand.SET_INPUT_OVERLAY, + input: key, + value: input.overlays[Number(index)], + index: Number(index), + }, + context: CommandContext.None, + timelineId: '', + }) + } + } + for (const index of Object.keys(oldInput.overlays ?? {})) { + if (!input.overlays?.[index]) { + commands.push({ + command: { + command: VMixCommand.SET_INPUT_OVERLAY, + input: key, + value: '', + index: Number(index), + }, + context: CommandContext.None, + timelineId: '', + }) + } + } + } + if (input.playing !== undefined && oldInput.playing !== input.playing && input.playing) { + commands.push({ + command: { + command: VMixCommand.PLAY_INPUT, + input: input.name, + }, + context: CommandContext.None, + timelineId: '', + }) + } + return { preTransitionCommands, postTransitionCommands } + } + + private _resolveInputsAudioState( + oldVMixState: VMixStateExtended, + newVMixState: VMixStateExtended + ): ConcatArray { + const commands: Array = [] + for (const [key, input] of Object.entries(newVMixState.reportedState.existingInputsAudio)) { + this._resolveInputAudioState( + oldVMixState.reportedState.existingInputsAudio[key] ?? {}, // if we just started controlling it (e.g. due to mappings change), we don't know anything about the input + input, + commands, + key + ) + } + for (const [key, input] of Object.entries(newVMixState.reportedState.inputsAddedByUsAudio)) { + this._resolveInputAudioState( + oldVMixState.reportedState.inputsAddedByUsAudio[key] ?? this.getDefaultInputAudioState(key), // we assume that a new input has all parameters default + input, + commands, + key + ) + } + return commands + } + + private _resolveInputAudioState( + oldInput: VMixInputAudio, + input: VMixInputAudio, + commands: VMixStateCommandWithContext[], + key: string + ) { if (input.muted !== undefined && oldInput.muted !== input.muted && input.muted) { commands.push({ command: { @@ -476,7 +598,7 @@ export class VMixStateDiffer { } } if (input.audioBuses !== undefined && oldInput.audioBuses !== input.audioBuses) { - const oldBuses = (oldInput.audioBuses || '').split(',').filter((x) => x) + const oldBuses = (oldInput.audioBuses || 'M,A,B,C,D,E,F,G').split(',').filter((x) => x) const newBuses = input.audioBuses.split(',').filter((x) => x) _.difference(newBuses, oldBuses).forEach((bus) => { commands.push({ @@ -511,93 +633,6 @@ export class VMixStateDiffer { timelineId: '', }) } - if (input.transform !== undefined && !_.isEqual(oldInput.transform, input.transform)) { - if (oldInput.transform === undefined || input.transform.zoom !== oldInput.transform.zoom) { - commands.push({ - command: { - command: VMixCommand.SET_ZOOM, - input: key, - value: input.transform.zoom, - }, - context: CommandContext.None, - timelineId: '', - }) - } - if (oldInput.transform === undefined || input.transform.alpha !== oldInput.transform.alpha) { - commands.push({ - command: { - command: VMixCommand.SET_ALPHA, - input: key, - value: input.transform.alpha, - }, - context: CommandContext.None, - timelineId: '', - }) - } - if (oldInput.transform === undefined || input.transform.panX !== oldInput.transform.panX) { - commands.push({ - command: { - command: VMixCommand.SET_PAN_X, - input: key, - value: input.transform.panX, - }, - context: CommandContext.None, - timelineId: '', - }) - } - if (oldInput.transform === undefined || input.transform.panY !== oldInput.transform.panY) { - commands.push({ - command: { - command: VMixCommand.SET_PAN_Y, - input: key, - value: input.transform.panY, - }, - context: CommandContext.None, - timelineId: '', - }) - } - } - if (input.overlays !== undefined && !_.isEqual(oldInput.overlays, input.overlays)) { - for (const index of Object.keys(input.overlays)) { - if (input.overlays !== oldInput.overlays?.[index]) { - commands.push({ - command: { - command: VMixCommand.SET_INPUT_OVERLAY, - input: key, - value: input.overlays[Number(index)], - index: Number(index), - }, - context: CommandContext.None, - timelineId: '', - }) - } - } - for (const index of Object.keys(oldInput.overlays ?? {})) { - if (!input.overlays?.[index]) { - commands.push({ - command: { - command: VMixCommand.SET_INPUT_OVERLAY, - input: key, - value: '', - index: Number(index), - }, - context: CommandContext.None, - timelineId: '', - }) - } - } - } - if (input.playing !== undefined && oldInput.playing !== input.playing && input.playing) { - commands.push({ - command: { - command: VMixCommand.PLAY_INPUT, - input: input.name, - }, - context: CommandContext.None, - timelineId: '', - }) - } - return { preTransitionCommands, postTransitionCommands } } private _resolveAddedByUsInputState( @@ -663,9 +698,9 @@ export class VMixStateDiffer { newVMixState: VMixStateExtended ): Array { const commands: Array = [] - _.each(newVMixState.reportedState.overlays, (overlay, index) => { + newVMixState.reportedState.overlays.forEach((overlay, index) => { const oldOverlay = oldVMixState.reportedState.overlays[index] - if (oldOverlay?.input !== overlay.input) { + if (oldOverlay == null || oldOverlay?.input !== overlay.input) { if (overlay.input === undefined) { commands.push({ command: { diff --git a/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts index c58d7e748..339f0dc34 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts @@ -10,7 +10,7 @@ import { VMixTransition, VMixTransitionType, } from 'timeline-state-resolver-types' -import { TSR_INPUT_PREFIX, VMixInput, VMixState, VMixStateExtended } from './VMixStateDiffer' +import { TSR_INPUT_PREFIX, VMixInput, VMixInputAudio, VMixState, VMixStateExtended } from './VMixStateDiffer' import * as deepMerge from 'deepmerge' import _ = require('underscore') @@ -31,10 +31,14 @@ const mappingPriority: { [k in MappingVmixType]: number } = { export type MappingsVmix = Mappings +/** + * Converts timeline state, to a TSR representation + */ export class VMixTimelineStateConverter { constructor( private readonly getDefaultState: () => VMixStateExtended, - private readonly getDefaultInputState: (inputIndex: number | string | undefined) => VMixInput + private readonly getDefaultInputState: (inputIndex: number | string | undefined) => VMixInput, + private readonly getDefaultInputAudioState: (inputIndex: number | string | undefined) => VMixInputAudio ) {} getVMixStateFromTimelineState( @@ -76,13 +80,21 @@ export class VMixTimelineStateConverter { break case MappingVmixType.AudioChannel: if (content.type === TimelineContentTypeVMix.AUDIO) { - const vmixTlAudioPicked = _.pick(content, 'volume', 'balance', 'audioAuto', 'audioBuses', 'muted', 'fade') + const filteredVMixTlAudio = _.pick( + content, + 'volume', + 'balance', + 'audioAuto', + 'audioBuses', + 'muted', + 'fade' + ) if (mapping.options.index) { - deviceState.reportedState = this._modifyInput(deviceState, vmixTlAudioPicked, { + deviceState.reportedState = this._modifyInputAudio(deviceState, filteredVMixTlAudio, { key: mapping.options.index, }) } else if (mapping.options.inputLayer) { - deviceState.reportedState = this._modifyInput(deviceState, vmixTlAudioPicked, { + deviceState.reportedState = this._modifyInputAudio(deviceState, filteredVMixTlAudio, { layer: mapping.options.inputLayer, }) } @@ -127,7 +139,6 @@ export class VMixTimelineStateConverter { listFilePaths: content.listFilePaths, restart: content.restart, }, - { key: mapping.options.index, filePath: content.filePath }, layerName ) @@ -162,10 +173,10 @@ export class VMixTimelineStateConverter { deviceState: VMixStateExtended, newInput: VMixInput, input: { key?: string | number; layer?: string; filePath?: string }, - layerName?: string + layerName: string ): VMixState { let inputs = deviceState.reportedState.existingInputs - const newInputPicked = _.pick(newInput, (x) => !_.isUndefined(x)) + const filteredNewInput = _.pick(newInput, (x) => x !== undefined) let inputKey: string | number | undefined if (input.layer) { inputKey = deviceState.inputLayers[input.layer] @@ -177,10 +188,28 @@ export class VMixTimelineStateConverter { inputKey = input.key } if (inputKey) { - inputs[inputKey] = deepMerge(inputs[inputKey] ?? this.getDefaultInputState(inputKey), newInputPicked) - if (layerName) { - deviceState.inputLayers[layerName] = inputKey as string - } + inputs[inputKey] = deepMerge(inputs[inputKey] ?? this.getDefaultInputState(inputKey), filteredNewInput) + deviceState.inputLayers[layerName] = inputKey as string + } + return deviceState.reportedState + } + + private _modifyInputAudio( + deviceState: VMixStateExtended, + newInput: VMixInputAudio, + input: { key?: string | number; layer?: string } + ): VMixState { + let inputs = deviceState.reportedState.existingInputsAudio + const filteredNewInput = _.pick(newInput, (x) => x !== undefined) + let inputKey: string | number | undefined + if (input.layer) { + inputKey = deviceState.inputLayers[input.layer] + inputs = deviceState.reportedState.inputsAddedByUsAudio + } else { + inputKey = input.key + } + if (inputKey) { + inputs[inputKey] = deepMerge(inputs[inputKey] ?? this.getDefaultInputAudioState(inputKey), filteredNewInput) } return deviceState.reportedState } @@ -208,6 +237,17 @@ export class VMixTimelineStateConverter { private _fillStateWithMappingsDefaults(state: VMixStateExtended, mappings: MappingsVmix) { for (const mapping of Object.values>(mappings)) { switch (mapping.options.mappingType) { + case MappingVmixType.Program: + case MappingVmixType.Preview: { + const mixProgram = mapping.options.index || 1 + state.reportedState.mixes[mixProgram - 1] = { + number: mixProgram, + preview: undefined, + program: undefined, + transition: { effect: VMixTransitionType.Cut, duration: 0 }, + } + break + } case MappingVmixType.Input: if (mapping.options.index) { state.reportedState.existingInputs[mapping.options.index] = this.getDefaultInputState(mapping.options.index) @@ -215,7 +255,9 @@ export class VMixTimelineStateConverter { break case MappingVmixType.AudioChannel: if (mapping.options.index) { - state.reportedState.existingInputs[mapping.options.index] = this.getDefaultInputState(mapping.options.index) + state.reportedState.existingInputsAudio[mapping.options.index] = this.getDefaultInputAudioState( + mapping.options.index + ) } break case MappingVmixType.Recording: @@ -230,6 +272,12 @@ export class VMixTimelineStateConverter { case MappingVmixType.Output: state.outputs[mapping.options.index] = { source: 'Program' } break + case MappingVmixType.Overlay: + state.reportedState.overlays[mapping.options.index - 1] = { + number: mapping.options.index, + input: undefined, + } + break } } return state From 0a307fcad6f3bc6c3516c3f54da4aa9955b9d008 Mon Sep 17 00:00:00 2001 From: ianshade Date: Mon, 18 Dec 2023 23:03:10 +0100 Subject: [PATCH 4/6] chore(vMix): fix filename capitalization --- .../integrations/vmix/{vMixStateDiffer.ts => VMixStateDiffer.ts} | 0 ...MixTimelineStateConverter.ts => VMixTimelineStateConverter.ts} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename packages/timeline-state-resolver/src/integrations/vmix/{vMixStateDiffer.ts => VMixStateDiffer.ts} (100%) rename packages/timeline-state-resolver/src/integrations/vmix/{vMixTimelineStateConverter.ts => VMixTimelineStateConverter.ts} (100%) diff --git a/packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts b/packages/timeline-state-resolver/src/integrations/vmix/VMixStateDiffer.ts similarity index 100% rename from packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts rename to packages/timeline-state-resolver/src/integrations/vmix/VMixStateDiffer.ts diff --git a/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts b/packages/timeline-state-resolver/src/integrations/vmix/VMixTimelineStateConverter.ts similarity index 100% rename from packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts rename to packages/timeline-state-resolver/src/integrations/vmix/VMixTimelineStateConverter.ts From b7c0a0c3fe021c9648a2a2492572ef7fb00ef838 Mon Sep 17 00:00:00 2001 From: ianshade Date: Wed, 10 Jan 2024 14:01:55 +0100 Subject: [PATCH 5/6] chore: intermediate rename-step to achieve changing casing of filenames Explanation: Git is not good at changing casing of file names, so we'll first rewrite the name, then get back to the correct name with the proper casing --- .../vmix/{VMixPollingTimer.ts => WMixPollingTimer.ts} | 0 .../vmix/{VMixStateDiffer.ts => WMixStateDiffer.ts} | 0 ...ixStateSynchronizer.ts => WMixStateSynchronizer.ts} | 2 +- ...StateConverter.ts => WMixTimelineStateConverter.ts} | 2 +- .../{VMixXmlStateParser.ts => WMixXmlStateParser.ts} | 2 +- ...ixPollingTimer.spec.ts => WMixPollingTimer.spec.ts} | 2 +- ...VMixStateDiffer.spec.ts => WMixStateDiffer.spec.ts} | 2 +- ...chronizer.spec.ts => WMixStateSynchronizer.spec.ts} | 2 +- ...rter.spec.ts => WMixTimelineStateConverter.spec.ts} | 4 ++-- ...lStateParser.spec.ts => WMixXmlStateParser.spec.ts} | 4 ++-- .../src/integrations/vmix/__tests__/mockState.ts | 2 +- .../src/integrations/vmix/index.ts | 10 +++++----- 12 files changed, 16 insertions(+), 16 deletions(-) rename packages/timeline-state-resolver/src/integrations/vmix/{VMixPollingTimer.ts => WMixPollingTimer.ts} (100%) rename packages/timeline-state-resolver/src/integrations/vmix/{VMixStateDiffer.ts => WMixStateDiffer.ts} (100%) rename packages/timeline-state-resolver/src/integrations/vmix/{VMixStateSynchronizer.ts => WMixStateSynchronizer.ts} (97%) rename packages/timeline-state-resolver/src/integrations/vmix/{VMixTimelineStateConverter.ts => WMixTimelineStateConverter.ts} (99%) rename packages/timeline-state-resolver/src/integrations/vmix/{VMixXmlStateParser.ts => WMixXmlStateParser.ts} (99%) rename packages/timeline-state-resolver/src/integrations/vmix/__tests__/{VMixPollingTimer.spec.ts => WMixPollingTimer.spec.ts} (98%) rename packages/timeline-state-resolver/src/integrations/vmix/__tests__/{VMixStateDiffer.spec.ts => WMixStateDiffer.spec.ts} (95%) rename packages/timeline-state-resolver/src/integrations/vmix/__tests__/{VMixStateSynchronizer.spec.ts => WMixStateSynchronizer.spec.ts} (97%) rename packages/timeline-state-resolver/src/integrations/vmix/__tests__/{VMixTimelineStateConverter.spec.ts => WMixTimelineStateConverter.spec.ts} (97%) rename packages/timeline-state-resolver/src/integrations/vmix/__tests__/{VMixXmlStateParser.spec.ts => WMixXmlStateParser.spec.ts} (98%) diff --git a/packages/timeline-state-resolver/src/integrations/vmix/VMixPollingTimer.ts b/packages/timeline-state-resolver/src/integrations/vmix/WMixPollingTimer.ts similarity index 100% rename from packages/timeline-state-resolver/src/integrations/vmix/VMixPollingTimer.ts rename to packages/timeline-state-resolver/src/integrations/vmix/WMixPollingTimer.ts diff --git a/packages/timeline-state-resolver/src/integrations/vmix/VMixStateDiffer.ts b/packages/timeline-state-resolver/src/integrations/vmix/WMixStateDiffer.ts similarity index 100% rename from packages/timeline-state-resolver/src/integrations/vmix/VMixStateDiffer.ts rename to packages/timeline-state-resolver/src/integrations/vmix/WMixStateDiffer.ts diff --git a/packages/timeline-state-resolver/src/integrations/vmix/VMixStateSynchronizer.ts b/packages/timeline-state-resolver/src/integrations/vmix/WMixStateSynchronizer.ts similarity index 97% rename from packages/timeline-state-resolver/src/integrations/vmix/VMixStateSynchronizer.ts rename to packages/timeline-state-resolver/src/integrations/vmix/WMixStateSynchronizer.ts index 00823998d..4089d3019 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/VMixStateSynchronizer.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/WMixStateSynchronizer.ts @@ -1,4 +1,4 @@ -import { VMixInput, VMixState, VMixStateExtended } from './VMixStateDiffer' +import { VMixInput, VMixState, VMixStateExtended } from './WMixStateDiffer' import { EnforceableVMixInputStateKeys } from '.' import { VMixInputOverlays, VMixTransform } from 'timeline-state-resolver-types' diff --git a/packages/timeline-state-resolver/src/integrations/vmix/VMixTimelineStateConverter.ts b/packages/timeline-state-resolver/src/integrations/vmix/WMixTimelineStateConverter.ts similarity index 99% rename from packages/timeline-state-resolver/src/integrations/vmix/VMixTimelineStateConverter.ts rename to packages/timeline-state-resolver/src/integrations/vmix/WMixTimelineStateConverter.ts index 339f0dc34..069bd6040 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/VMixTimelineStateConverter.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/WMixTimelineStateConverter.ts @@ -10,7 +10,7 @@ import { VMixTransition, VMixTransitionType, } from 'timeline-state-resolver-types' -import { TSR_INPUT_PREFIX, VMixInput, VMixInputAudio, VMixState, VMixStateExtended } from './VMixStateDiffer' +import { TSR_INPUT_PREFIX, VMixInput, VMixInputAudio, VMixState, VMixStateExtended } from './WMixStateDiffer' import * as deepMerge from 'deepmerge' import _ = require('underscore') diff --git a/packages/timeline-state-resolver/src/integrations/vmix/VMixXmlStateParser.ts b/packages/timeline-state-resolver/src/integrations/vmix/WMixXmlStateParser.ts similarity index 99% rename from packages/timeline-state-resolver/src/integrations/vmix/VMixXmlStateParser.ts rename to packages/timeline-state-resolver/src/integrations/vmix/WMixXmlStateParser.ts index 7afb930fc..1b4539d43 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/VMixXmlStateParser.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/WMixXmlStateParser.ts @@ -1,5 +1,5 @@ import * as xml from 'xml-js' -import { TSR_INPUT_PREFIX, VMixInput, VMixInputAudio, VMixMix, VMixState } from './VMixStateDiffer' +import { TSR_INPUT_PREFIX, VMixInput, VMixInputAudio, VMixMix, VMixState } from './WMixStateDiffer' import { InferredPartialInputStateKeys } from './connection' import { VMixTransitionType } from 'timeline-state-resolver-types' diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixPollingTimer.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixPollingTimer.spec.ts similarity index 98% rename from packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixPollingTimer.spec.ts rename to packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixPollingTimer.spec.ts index eb49a197b..c7852a021 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixPollingTimer.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixPollingTimer.spec.ts @@ -1,4 +1,4 @@ -import { VMixPollingTimer } from '../VMixPollingTimer' +import { VMixPollingTimer } from '../WMixPollingTimer' describe('VMixPollingTimer', () => { beforeEach(() => { diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixStateDiffer.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixStateDiffer.spec.ts similarity index 95% rename from packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixStateDiffer.spec.ts rename to packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixStateDiffer.spec.ts index 797980679..e4a7630ef 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixStateDiffer.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixStateDiffer.spec.ts @@ -1,5 +1,5 @@ import { VMixCommand } from 'timeline-state-resolver-types' -import { VMixStateDiffer } from '../VMixStateDiffer' +import { VMixStateDiffer } from '../WMixStateDiffer' import { makeMockFullState } from './mockState' function createTestee(): VMixStateDiffer { diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixStateSynchronizer.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixStateSynchronizer.spec.ts similarity index 97% rename from packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixStateSynchronizer.spec.ts rename to packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixStateSynchronizer.spec.ts index 2478732bb..de0b6f9fb 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixStateSynchronizer.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixStateSynchronizer.spec.ts @@ -1,4 +1,4 @@ -import { VMixStateSynchronizer } from '../VMixStateSynchronizer' +import { VMixStateSynchronizer } from '../WMixStateSynchronizer' import { ADDED_INPUT_NAME_1, ADDED_INPUT_NAME_2, makeMockFullState, makeMockReportedState } from './mockState' describe('VMixStateSynchronizer', () => { diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixTimelineStateConverter.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixTimelineStateConverter.spec.ts similarity index 97% rename from packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixTimelineStateConverter.spec.ts rename to packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixTimelineStateConverter.spec.ts index 953722757..6319bbfdc 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixTimelineStateConverter.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixTimelineStateConverter.spec.ts @@ -9,8 +9,8 @@ import { TimelineContentVMixAny, VMixInputType, } from 'timeline-state-resolver-types' -import { VMixTimelineStateConverter } from '../VMixTimelineStateConverter' -import { VMixOutput, VMixStateDiffer } from '../VMixStateDiffer' +import { VMixTimelineStateConverter } from '../WMixTimelineStateConverter' +import { VMixOutput, VMixStateDiffer } from '../WMixStateDiffer' import { prefixAddedInput } from './mockState' function createTestee(): VMixTimelineStateConverter { diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixXmlStateParser.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixXmlStateParser.spec.ts similarity index 98% rename from packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixXmlStateParser.spec.ts rename to packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixXmlStateParser.spec.ts index 5f61e1de6..84fe903a1 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/VMixXmlStateParser.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixXmlStateParser.spec.ts @@ -1,6 +1,6 @@ import { VMixTransitionType } from 'timeline-state-resolver-types' -import { VMixState } from '../VMixStateDiffer' -import { VMixXmlStateParser } from '../VMixXmlStateParser' +import { VMixState } from '../WMixStateDiffer' +import { VMixXmlStateParser } from '../WMixXmlStateParser' import { makeMockVMixXmlState } from './vmixMock' import { prefixAddedInput } from './mockState' diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/mockState.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/mockState.ts index 806b9d814..16e7ad880 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/mockState.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/mockState.ts @@ -1,5 +1,5 @@ import { VMixTransitionType } from 'timeline-state-resolver-types' -import { TSR_INPUT_PREFIX, VMixState, VMixStateExtended } from '../VMixStateDiffer' +import { TSR_INPUT_PREFIX, VMixState, VMixStateExtended } from '../WMixStateDiffer' export const ADDED_INPUT_NAME_1 = `${TSR_INPUT_PREFIX}C:\\someVideo.mp4` export const ADDED_INPUT_NAME_2 = `${TSR_INPUT_PREFIX}C:\\anotherVideo.mp4` diff --git a/packages/timeline-state-resolver/src/integrations/vmix/index.ts b/packages/timeline-state-resolver/src/integrations/vmix/index.ts index 055138d09..48cb1fd8b 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/index.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/index.ts @@ -17,12 +17,12 @@ import { VmixActions, } from 'timeline-state-resolver-types' import { t } from '../../lib' -import { VMixState, VMixStateDiffer, VMixStateExtended } from './VMixStateDiffer' +import { VMixState, VMixStateDiffer, VMixStateExtended } from './WMixStateDiffer' import { CommandContext, VMixStateCommandWithContext } from './vMixCommands' -import { MappingsVmix, VMixTimelineStateConverter } from './VMixTimelineStateConverter' -import { VMixXmlStateParser } from './VMixXmlStateParser' -import { VMixPollingTimer } from './VMixPollingTimer' -import { VMixStateSynchronizer } from './VMixStateSynchronizer' +import { MappingsVmix, VMixTimelineStateConverter } from './WMixTimelineStateConverter' +import { VMixXmlStateParser } from './WMixXmlStateParser' +import { VMixPollingTimer } from './WMixPollingTimer' +import { VMixStateSynchronizer } from './WMixStateSynchronizer' /** * Default time, in milliseconds, for when we should poll vMix to query its actual state. From 5dc8fec3ad250a1b8974a74f4d0e729375860ed5 Mon Sep 17 00:00:00 2001 From: ianshade Date: Wed, 10 Jan 2024 14:02:45 +0100 Subject: [PATCH 6/6] chore: rename to achieve camelcasing of vMix filenames --- .../src/integrations/vmix/__tests__/mockState.ts | 2 +- ...ixPollingTimer.spec.ts => vMixPollingTimer.spec.ts} | 2 +- ...WMixStateDiffer.spec.ts => vMixStateDiffer.spec.ts} | 2 +- ...chronizer.spec.ts => vMixStateSynchronizer.spec.ts} | 2 +- ...rter.spec.ts => vMixTimelineStateConverter.spec.ts} | 4 ++-- ...lStateParser.spec.ts => vMixXmlStateParser.spec.ts} | 4 ++-- .../src/integrations/vmix/index.ts | 10 +++++----- .../vmix/{WMixPollingTimer.ts => vMixPollingTimer.ts} | 0 .../vmix/{WMixStateDiffer.ts => vMixStateDiffer.ts} | 0 ...ixStateSynchronizer.ts => vMixStateSynchronizer.ts} | 2 +- ...StateConverter.ts => vMixTimelineStateConverter.ts} | 2 +- .../{WMixXmlStateParser.ts => vMixXmlStateParser.ts} | 2 +- 12 files changed, 16 insertions(+), 16 deletions(-) rename packages/timeline-state-resolver/src/integrations/vmix/__tests__/{WMixPollingTimer.spec.ts => vMixPollingTimer.spec.ts} (98%) rename packages/timeline-state-resolver/src/integrations/vmix/__tests__/{WMixStateDiffer.spec.ts => vMixStateDiffer.spec.ts} (95%) rename packages/timeline-state-resolver/src/integrations/vmix/__tests__/{WMixStateSynchronizer.spec.ts => vMixStateSynchronizer.spec.ts} (97%) rename packages/timeline-state-resolver/src/integrations/vmix/__tests__/{WMixTimelineStateConverter.spec.ts => vMixTimelineStateConverter.spec.ts} (97%) rename packages/timeline-state-resolver/src/integrations/vmix/__tests__/{WMixXmlStateParser.spec.ts => vMixXmlStateParser.spec.ts} (98%) rename packages/timeline-state-resolver/src/integrations/vmix/{WMixPollingTimer.ts => vMixPollingTimer.ts} (100%) rename packages/timeline-state-resolver/src/integrations/vmix/{WMixStateDiffer.ts => vMixStateDiffer.ts} (100%) rename packages/timeline-state-resolver/src/integrations/vmix/{WMixStateSynchronizer.ts => vMixStateSynchronizer.ts} (97%) rename packages/timeline-state-resolver/src/integrations/vmix/{WMixTimelineStateConverter.ts => vMixTimelineStateConverter.ts} (99%) rename packages/timeline-state-resolver/src/integrations/vmix/{WMixXmlStateParser.ts => vMixXmlStateParser.ts} (99%) diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/mockState.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/mockState.ts index 16e7ad880..c51e1610c 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/mockState.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/mockState.ts @@ -1,5 +1,5 @@ import { VMixTransitionType } from 'timeline-state-resolver-types' -import { TSR_INPUT_PREFIX, VMixState, VMixStateExtended } from '../WMixStateDiffer' +import { TSR_INPUT_PREFIX, VMixState, VMixStateExtended } from '../vMixStateDiffer' export const ADDED_INPUT_NAME_1 = `${TSR_INPUT_PREFIX}C:\\someVideo.mp4` export const ADDED_INPUT_NAME_2 = `${TSR_INPUT_PREFIX}C:\\anotherVideo.mp4` diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixPollingTimer.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixPollingTimer.spec.ts similarity index 98% rename from packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixPollingTimer.spec.ts rename to packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixPollingTimer.spec.ts index c7852a021..ddfabbde4 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixPollingTimer.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixPollingTimer.spec.ts @@ -1,4 +1,4 @@ -import { VMixPollingTimer } from '../WMixPollingTimer' +import { VMixPollingTimer } from '../vMixPollingTimer' describe('VMixPollingTimer', () => { beforeEach(() => { diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixStateDiffer.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixStateDiffer.spec.ts similarity index 95% rename from packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixStateDiffer.spec.ts rename to packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixStateDiffer.spec.ts index e4a7630ef..6f5d21458 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixStateDiffer.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixStateDiffer.spec.ts @@ -1,5 +1,5 @@ import { VMixCommand } from 'timeline-state-resolver-types' -import { VMixStateDiffer } from '../WMixStateDiffer' +import { VMixStateDiffer } from '../vMixStateDiffer' import { makeMockFullState } from './mockState' function createTestee(): VMixStateDiffer { diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixStateSynchronizer.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixStateSynchronizer.spec.ts similarity index 97% rename from packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixStateSynchronizer.spec.ts rename to packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixStateSynchronizer.spec.ts index de0b6f9fb..646a65c12 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixStateSynchronizer.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixStateSynchronizer.spec.ts @@ -1,4 +1,4 @@ -import { VMixStateSynchronizer } from '../WMixStateSynchronizer' +import { VMixStateSynchronizer } from '../vMixStateSynchronizer' import { ADDED_INPUT_NAME_1, ADDED_INPUT_NAME_2, makeMockFullState, makeMockReportedState } from './mockState' describe('VMixStateSynchronizer', () => { diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixTimelineStateConverter.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixTimelineStateConverter.spec.ts similarity index 97% rename from packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixTimelineStateConverter.spec.ts rename to packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixTimelineStateConverter.spec.ts index 6319bbfdc..8dca2276b 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixTimelineStateConverter.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixTimelineStateConverter.spec.ts @@ -9,8 +9,8 @@ import { TimelineContentVMixAny, VMixInputType, } from 'timeline-state-resolver-types' -import { VMixTimelineStateConverter } from '../WMixTimelineStateConverter' -import { VMixOutput, VMixStateDiffer } from '../WMixStateDiffer' +import { VMixTimelineStateConverter } from '../vMixTimelineStateConverter' +import { VMixOutput, VMixStateDiffer } from '../vMixStateDiffer' import { prefixAddedInput } from './mockState' function createTestee(): VMixTimelineStateConverter { diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixXmlStateParser.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixXmlStateParser.spec.ts similarity index 98% rename from packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixXmlStateParser.spec.ts rename to packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixXmlStateParser.spec.ts index 84fe903a1..3c4d53aa3 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/WMixXmlStateParser.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixXmlStateParser.spec.ts @@ -1,6 +1,6 @@ import { VMixTransitionType } from 'timeline-state-resolver-types' -import { VMixState } from '../WMixStateDiffer' -import { VMixXmlStateParser } from '../WMixXmlStateParser' +import { VMixState } from '../vMixStateDiffer' +import { VMixXmlStateParser } from '../vMixXmlStateParser' import { makeMockVMixXmlState } from './vmixMock' import { prefixAddedInput } from './mockState' diff --git a/packages/timeline-state-resolver/src/integrations/vmix/index.ts b/packages/timeline-state-resolver/src/integrations/vmix/index.ts index 48cb1fd8b..fa49f4f75 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/index.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/index.ts @@ -17,12 +17,12 @@ import { VmixActions, } from 'timeline-state-resolver-types' import { t } from '../../lib' -import { VMixState, VMixStateDiffer, VMixStateExtended } from './WMixStateDiffer' +import { VMixState, VMixStateDiffer, VMixStateExtended } from './vMixStateDiffer' import { CommandContext, VMixStateCommandWithContext } from './vMixCommands' -import { MappingsVmix, VMixTimelineStateConverter } from './WMixTimelineStateConverter' -import { VMixXmlStateParser } from './WMixXmlStateParser' -import { VMixPollingTimer } from './WMixPollingTimer' -import { VMixStateSynchronizer } from './WMixStateSynchronizer' +import { MappingsVmix, VMixTimelineStateConverter } from './vMixTimelineStateConverter' +import { VMixXmlStateParser } from './vMixXmlStateParser' +import { VMixPollingTimer } from './vMixPollingTimer' +import { VMixStateSynchronizer } from './vMixStateSynchronizer' /** * Default time, in milliseconds, for when we should poll vMix to query its actual state. diff --git a/packages/timeline-state-resolver/src/integrations/vmix/WMixPollingTimer.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixPollingTimer.ts similarity index 100% rename from packages/timeline-state-resolver/src/integrations/vmix/WMixPollingTimer.ts rename to packages/timeline-state-resolver/src/integrations/vmix/vMixPollingTimer.ts diff --git a/packages/timeline-state-resolver/src/integrations/vmix/WMixStateDiffer.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts similarity index 100% rename from packages/timeline-state-resolver/src/integrations/vmix/WMixStateDiffer.ts rename to packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts diff --git a/packages/timeline-state-resolver/src/integrations/vmix/WMixStateSynchronizer.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixStateSynchronizer.ts similarity index 97% rename from packages/timeline-state-resolver/src/integrations/vmix/WMixStateSynchronizer.ts rename to packages/timeline-state-resolver/src/integrations/vmix/vMixStateSynchronizer.ts index 4089d3019..cb712b170 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/WMixStateSynchronizer.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/vMixStateSynchronizer.ts @@ -1,4 +1,4 @@ -import { VMixInput, VMixState, VMixStateExtended } from './WMixStateDiffer' +import { VMixInput, VMixState, VMixStateExtended } from './vMixStateDiffer' import { EnforceableVMixInputStateKeys } from '.' import { VMixInputOverlays, VMixTransform } from 'timeline-state-resolver-types' diff --git a/packages/timeline-state-resolver/src/integrations/vmix/WMixTimelineStateConverter.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts similarity index 99% rename from packages/timeline-state-resolver/src/integrations/vmix/WMixTimelineStateConverter.ts rename to packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts index 069bd6040..8c37fb7c6 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/WMixTimelineStateConverter.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts @@ -10,7 +10,7 @@ import { VMixTransition, VMixTransitionType, } from 'timeline-state-resolver-types' -import { TSR_INPUT_PREFIX, VMixInput, VMixInputAudio, VMixState, VMixStateExtended } from './WMixStateDiffer' +import { TSR_INPUT_PREFIX, VMixInput, VMixInputAudio, VMixState, VMixStateExtended } from './vMixStateDiffer' import * as deepMerge from 'deepmerge' import _ = require('underscore') diff --git a/packages/timeline-state-resolver/src/integrations/vmix/WMixXmlStateParser.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixXmlStateParser.ts similarity index 99% rename from packages/timeline-state-resolver/src/integrations/vmix/WMixXmlStateParser.ts rename to packages/timeline-state-resolver/src/integrations/vmix/vMixXmlStateParser.ts index 1b4539d43..e204dc6bb 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/WMixXmlStateParser.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/vMixXmlStateParser.ts @@ -1,5 +1,5 @@ import * as xml from 'xml-js' -import { TSR_INPUT_PREFIX, VMixInput, VMixInputAudio, VMixMix, VMixState } from './WMixStateDiffer' +import { TSR_INPUT_PREFIX, VMixInput, VMixInputAudio, VMixMix, VMixState } from './vMixStateDiffer' import { InferredPartialInputStateKeys } from './connection' import { VMixTransitionType } from 'timeline-state-resolver-types'