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/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/__tests__/mockState.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/mockState.ts new file mode 100644 index 000000000..c51e1610c --- /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__/vMixPollingTimer.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixPollingTimer.spec.ts new file mode 100644 index 000000000..ddfabbde4 --- /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__/vMixStateDiffer.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixStateDiffer.spec.ts new file mode 100644 index 000000000..6f5d21458 --- /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 new file mode 100644 index 000000000..646a65c12 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixStateSynchronizer.spec.ts @@ -0,0 +1,78 @@ +import { VMixStateSynchronizer } from '../vMixStateSynchronizer' +import { ADDED_INPUT_NAME_1, ADDED_INPUT_NAME_2, makeMockFullState, makeMockReportedState } from './mockState' + +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].playing = false + 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].playing = false + 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__/vMixTimelineStateConverter.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixTimelineStateConverter.spec.ts new file mode 100644 index 000000000..8dca2276b --- /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 new file mode 100644 index 000000000..3c4d53aa3 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixXmlStateParser.spec.ts @@ -0,0 +1,205 @@ +import { VMixTransitionType } from 'timeline-state-resolver-types' +import { VMixState } from '../vMixStateDiffer' +import { VMixXmlStateParser } from '../vMixXmlStateParser' +import { makeMockVMixXmlState } from './vmixMock' +import { prefixAddedInput } from './mockState' + +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, + transform: { + alpha: -1, + panX: 0, + panY: 0, + zoom: 1, + }, + listFilePaths: undefined, + overlays: {}, + 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: {}, + 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 }, + { 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, + transform: { + alpha: -1, + panX: 0, + panY: 0, + zoom: 1, + }, + listFilePaths: undefined, + overlays: {}, + playing: true, + }, + }, + existingInputsAudio: { + '1': { + muted: false, + volume: 100, + balance: 0, + audioBuses: 'M', + solo: false, + }, + }, + inputsAddedByUs: { + [prefixAddedInput('C:\\someVideo.mp4')]: { + number: 2, + type: 'Video', + state: 'Running', + position: 0, + duration: 0, + loop: false, + transform: { + alpha: -1, + panX: 0, + panY: 0, + zoom: 1, + }, + listFilePaths: 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__/vmix.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vmix.spec.ts index 64fbaea04..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 @@ -24,9 +24,11 @@ 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' +import { prefixAddedInput } from './mockState' const orgSetTimeout = setTimeout @@ -164,7 +166,7 @@ describe('vMix', () => { command: { command: VMixCommand.SET_INPUT_NAME, input: 'My Clip.mp4', - value: 'C:/videos/My Clip.mp4', + value: prefixAddedInput('C:/videos/My Clip.mp4'), }, }), CommandContext.None, @@ -181,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=' + prefixAddedInput('C:/videos/My Clip.mp4')) ) clearMocks() @@ -197,7 +199,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.PLAY_INPUT, - input: 'C:/videos/My Clip.mp4', + input: prefixAddedInput('C:/videos/My Clip.mp4'), }, }), CommandContext.None, @@ -205,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=' + prefixAddedInput('C:/videos/My Clip.mp4')) + ) clearMocks() commandReceiver0.mockClear() @@ -219,7 +225,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.REMOVE_INPUT, - input: 'C:/videos/My Clip.mp4', + input: prefixAddedInput('C:/videos/My Clip.mp4'), }, }), CommandContext.None, @@ -227,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=' + prefixAddedInput('C:/videos/My Clip.mp4')) + ) await myConductor.destroy() @@ -332,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, @@ -345,7 +355,7 @@ describe('vMix', () => { command: { command: VMixCommand.SET_INPUT_NAME, input: 'My Clip.mp4', - value: 'C:/videos/My Clip.mp4', + value: prefixAddedInput('C:/videos/My Clip.mp4'), }, }), CommandContext.None, @@ -362,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=' + prefixAddedInput('C:/videos/My Clip.mp4')) ) clearMocks() @@ -378,7 +388,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.SET_POSITION, - input: 'C:/videos/My Clip.mp4', + input: prefixAddedInput('C:/videos/My Clip.mp4'), value: 10000, }, }), @@ -391,7 +401,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.LOOP_ON, - input: 'C:/videos/My Clip.mp4', + input: prefixAddedInput('C:/videos/My Clip.mp4'), }, }), CommandContext.None, @@ -403,7 +413,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.SET_ZOOM, - input: 'C:/videos/My Clip.mp4', + input: prefixAddedInput('C:/videos/My Clip.mp4'), value: 0.5, }, }), @@ -416,7 +426,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.SET_ALPHA, - input: 'C:/videos/My Clip.mp4', + input: prefixAddedInput('C:/videos/My Clip.mp4'), value: 123, }, }), @@ -429,7 +439,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.SET_PAN_X, - input: 'C:/videos/My Clip.mp4', + input: prefixAddedInput('C:/videos/My Clip.mp4'), value: 0.3, }, }), @@ -442,7 +452,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.SET_PAN_Y, - input: 'C:/videos/My Clip.mp4', + input: prefixAddedInput('C:/videos/My Clip.mp4'), value: 1.2, }, }), @@ -455,7 +465,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.SET_INPUT_OVERLAY, - input: 'C:/videos/My Clip.mp4', + input: prefixAddedInput('C:/videos/My Clip.mp4'), index: 1, value: 'G:/videos/My Other Clip.mp4', }, @@ -469,7 +479,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.SET_INPUT_OVERLAY, - input: 'C:/videos/My Clip.mp4', + input: prefixAddedInput('C:/videos/My Clip.mp4'), index: 3, value: 5, }, @@ -483,7 +493,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.PLAY_INPUT, - input: 'C:/videos/My Clip.mp4', + input: prefixAddedInput('C:/videos/My Clip.mp4'), }, }), CommandContext.None, @@ -495,40 +505,50 @@ describe('vMix', () => { expect(onFunction).toHaveBeenNthCalledWith( 1, 'SetPosition', - expect.stringContaining('Input=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=' + prefixAddedInput('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=' + prefixAddedInput('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=' + prefixAddedInput('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=' + prefixAddedInput('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=' + prefixAddedInput('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=' + prefixAddedInput('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=' + prefixAddedInput('C:/videos/My Clip.mp4') + '&Value=3,5') + ) + expect(onFunction).toHaveBeenNthCalledWith( + 9, + 'Play', + expect.stringContaining('Input=' + prefixAddedInput('C:/videos/My Clip.mp4')) ) - expect(onFunction).toHaveBeenNthCalledWith(9, 'Play', expect.stringContaining('Input=C:/videos/My Clip.mp4')) await myConductor.destroy() @@ -917,7 +937,7 @@ describe('vMix', () => { command: { command: VMixCommand.SET_INPUT_NAME, input: 'My Clip.mp4', - value: 'C:/videos/My Clip.mp4', + value: prefixAddedInput('C:/videos/My Clip.mp4'), }, }), CommandContext.None, @@ -934,7 +954,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=' + prefixAddedInput('C:/videos/My Clip.mp4')) ) clearMocks() @@ -950,7 +970,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.TRANSITION, - input: 'C:/videos/My Clip.mp4', + input: prefixAddedInput('C:/videos/My Clip.mp4'), duration: 0, effect: VMixTransitionType.Cut, mix: 0, @@ -965,7 +985,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.AUDIO_VOLUME, - input: 'C:/videos/My Clip.mp4', + input: prefixAddedInput('C:/videos/My Clip.mp4'), value: 25, fade: 0, }, @@ -979,12 +999,12 @@ describe('vMix', () => { expect(onFunction).toHaveBeenNthCalledWith( 1, 'Cut', - expect.stringContaining('Input=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=C:/videos/My Clip.mp4&Value=25') + expect.stringContaining('Input=' + prefixAddedInput('C:/videos/My Clip.mp4') + '&Value=25') ) clearMocks() @@ -1013,7 +1033,7 @@ describe('vMix', () => { command: { command: VMixCommand.SET_INPUT_NAME, input: 'My Other Clip.mp4', - value: 'G:/videos/My Other Clip.mp4', + value: prefixAddedInput('G:/videos/My Other Clip.mp4'), }, }), CommandContext.None, @@ -1025,7 +1045,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.TRANSITION, - input: 'G:/videos/My Other Clip.mp4', + input: prefixAddedInput('G:/videos/My Other Clip.mp4'), duration: 0, effect: VMixTransitionType.Cut, mix: 0, @@ -1040,7 +1060,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.AUDIO_VOLUME, - input: 'G:/videos/My Other Clip.mp4', + input: prefixAddedInput('G:/videos/My Other Clip.mp4'), value: 25, fade: 0, }, @@ -1054,7 +1074,7 @@ describe('vMix', () => { expect.objectContaining({ command: { command: VMixCommand.REMOVE_INPUT, - input: 'C:/videos/My Clip.mp4', + input: prefixAddedInput('C:/videos/My Clip.mp4'), }, }), CommandContext.None, @@ -1071,19 +1091,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=' + prefixAddedInput('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=' + prefixAddedInput('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=' + prefixAddedInput('G:/videos/My Other Clip.mp4') + '&Value=25') + ) + expect(onFunction).toHaveBeenNthCalledWith( + 5, + 'RemoveInput', + expect.stringContaining('Input=' + prefixAddedInput('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 c7ebbf45d..07b7d570a 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/connection.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/connection.ts @@ -1,9 +1,7 @@ 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 { VMixCommand } from 'timeline-state-resolver-types' +import { VMixStateCommand } from './vMixCommands' const VMIX_DEFAULT_TCP_PORT = 8099 const RESPONSE_REGEX = /^(?\w+)\s+(?OK|ER|\d+)(\s+(?.*))?/i @@ -20,7 +18,6 @@ export type ConnectionEvents = { connected: [] disconnected: [] initialized: [] - stateChanged: [state: VMixState] error: [error: Error] } @@ -211,18 +208,14 @@ 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') } } } -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: @@ -310,116 +303,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 }) } @@ -597,221 +480,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..fa49f4f75 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/index.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/index.ts @@ -1,27 +1,15 @@ 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 { Response, VMixConnection } from './connection' import { DeviceType, DeviceOptionsVMix, VMixOptions, Mappings, - TimelineContentTypeVMix, - VMixCommand, - VMixTransition, - VMixTransitionType, - VMixInputType, - VMixTransform, - VMixInputOverlays, - MappingVmixType, - SomeMappingVmix, Timeline, TSRTimelineContent, - Mapping, ActionExecutionResult, ActionExecutionResultCode, OpenPresetPayload, @@ -29,6 +17,12 @@ import { VmixActions, } from 'timeline-state-resolver-types' import { t } from '../../lib' +import { VMixState, VMixStateDiffer, VMixStateExtended } from './vMixStateDiffer' +import { CommandContext, VMixStateCommandWithContext } from './vMixCommands' +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. @@ -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 */ @@ -90,18 +60,25 @@ 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,46 +90,77 @@ 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((commands: VMixStateCommandWithContext[]) => + this._addToQueue(commands, this.getCurrentTime()) + ) + + this._timelineStateConverter = new VMixTimelineStateConverter( + () => this._stateDiffer.getDefaultState(), + (inputNumber: number) => this._stateDiffer.getDefaultInputState(inputNumber), + (inputNumber: number) => this._stateDiffer.getDefaultInputAudioState(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.emitDebug('connected') + this._pollingTimer?.start() + this._requestVMixState('VMix init') }) - this._vmix.on('disconnected', () => { + this._vMixConnection.on('disconnected', () => { this._setConnected(false) + this._pollingTimer?.stop() + this.emitDebug('disconnected') }) - 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.emitDebug(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()) } @@ -165,124 +173,39 @@ 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, - } - - // Shallow merging is sufficient. - for (const [key, value] of Object.entries( - cherryPickedRealState - )) { - expectedState.reportedState.inputs[inputKey][key] = value - } - } - - this.setState(expectedState, time) + const oldState: VMixStateExtended = (this.getStateBefore(time) ?? { state: this._stateDiffer.getDefaultState() }) + .state + oldState.reportedState = realState + this.setState(oldState, time) this.emit('resetResolver') } - private _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: {}, - } - } + /** + * 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 - 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 - } + expectedState = this._stateSynchronizer.applyRealState(expectedState, realState) - private _getDefaultState(): VMixStateExtended { - return { - reportedState: { - version: '', - edition: '', - fixedInputsCount: 0, - inputs: this._getDefaultInputsState(this._vmix.state.fixedInputsCount), - 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: [], - } + this.setState(expectedState, time) + this.emit('resetResolver') } /** Called by the Conductor a bit before a .handleState is called */ prepareForHandleState(newStateTime: number) { // clear any queued commands later than this time: - this._doOnTime.clearQueueNowAndAfter(newStateTime + 0.1) - this.cleanUpStates(0, newStateTime + 0.1) + this._doOnTime.clearQueueNowAndAfter(newStateTime) + this.cleanUpStates(0, newStateTime) } handleState(newState: Timeline.TimelineState, newMappings: Mappings) { @@ -293,13 +216,17 @@ 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 +383,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, @@ -1262,8 +395,8 @@ 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, @@ -1272,151 +405,15 @@ export class VMixDevice extends DeviceWithState { + return this._vMixConnection.sendCommand(cmd.command).catch((error) => { this.emit('commandError', error, cwc) }) } /** - * 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 - } - - /** - * Polls vMix's XML status endpoint, which will change our tracked state based on the response. - */ - 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)) - } -} - -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**. + * Request vMix's XML status. */ - reportedState: VMixState - outputs: { - External2: VMixOutput - - '2': VMixOutput - '3': VMixOutput - '4': VMixOutput - - Fullscreen: VMixOutput - Fullscreen2: VMixOutput + private _requestVMixState(context: string) { + this._vMixConnection.requestVMixState().catch((e) => this.emit('error', context, e)) } - 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/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/vMixStateDiffer.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts new file mode 100644 index 000000000..0cac0840f --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts @@ -0,0 +1,910 @@ +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') + +/** 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: VMixOutputsState + inputLayers: { [key: string]: string } + runningScripts: string[] +} + +export interface VMixState { + version: string + edition: string // TODO: Enuum, need list of available editions: Trial + existingInputs: { [key: string]: VMixInput } + existingInputsAudio: { [key: string]: VMixInputAudio } + inputsAddedByUs: { [key: string]: VMixInput } + inputsAddedByUsAudio: { [key: string]: VMixInputAudio } + overlays: VMixOverlay[] + mixes: VMixMix[] + fadeToBlack: boolean + faderPosition?: number + 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 + 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 + transform?: VMixTransform + overlays?: VMixInputOverlays + listFilePaths?: string[] + restart?: boolean +} + +export interface VMixInputAudio { + number?: number + muted?: boolean + volume?: number + balance?: number + fade?: number + solo?: boolean + audioBuses?: string + audioAuto?: 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 +} + +interface PreAndPostTransitionCommands { + preTransitionCommands: Array + postTransitionCommands: Array +} + +export class VMixStateDiffer { + constructor(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._resolveInputsAudioState(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._resolveAddedByUsInputsRemovalState(oldVMixState.reportedState, newVMixState.reportedState) + ) + commands = commands.concat(this._resolveScriptsState(oldVMixState, newVMixState)) + + return commands + } + + getDefaultState(): VMixStateExtended { + return { + reportedState: { + version: '', + edition: '', + existingInputs: {}, + existingInputsAudio: {}, + inputsAddedByUs: {}, + inputsAddedByUsAudio: {}, + overlays: [], + mixes: [], + fadeToBlack: false, + faderPosition: 0, + recording: undefined, + external: undefined, + streaming: undefined, + playlist: false, + multiCorder: false, + fullscreen: false, + audio: [], + }, + outputs: { + '2': undefined, + '3': undefined, + '4': undefined, + External2: undefined, + Fullscreen: undefined, + Fullscreen2: undefined, + }, + inputLayers: {}, + runningScripts: [], + } + } + + getDefaultInputState(inputNumber: number | string | undefined): VMixInput { + return { + number: Number(inputNumber) || undefined, + position: 0, + loop: false, + playing: false, + transform: { + zoom: 1, + panX: 0, + panY: 0, + alpha: 255, + }, + overlays: {}, + } + } + + 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 = [] + newVMixState.reportedState.mixes.forEach((_mix, i) => { + /** + * 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 + 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 + ): PreAndPostTransitionCommands { + const preTransitionCommands: Array = [] + const postTransitionCommands: Array = [] + _.map(newVMixState.reportedState.existingInputs, (input, key) => + this._resolveExistingInputState(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 _resolveExistingInputState( + oldInput: VMixInput | undefined, + input: VMixInput, + key: string, + oldVMixState: VMixStateExtended + ): PreAndPostTransitionCommands { + 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) + } + + 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.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.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: { + 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 || '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({ + 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: '', + }) + } + } + + 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.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.inputsAddedByUs[input].name || input, + }, + context: CommandContext.None, + timelineId: '', + }) + } + ) + return commands + } + + private _resolveOverlaysState( + oldVMixState: VMixStateExtended, + newVMixState: VMixStateExtended + ): Array { + const commands: Array = [] + newVMixState.reportedState.overlays.forEach((overlay, index) => { + const oldOverlay = oldVMixState.reportedState.overlays[index] + if (oldOverlay == null || 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: VMixState, newVMixState: VMixState): Array { + const commands: Array = [] + if (newVMixState.recording != null && oldVMixState.recording !== newVMixState.recording) { + if (newVMixState.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: VMixState, newVMixState: VMixState): Array { + const commands: Array = [] + if (newVMixState.streaming != null && oldVMixState.streaming !== newVMixState.streaming) { + if (newVMixState.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: VMixState, newVMixState: VMixState): Array { + const commands: Array = [] + if (newVMixState.external != null && oldVMixState.external !== newVMixState.external) { + if (newVMixState.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 = [] + 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 (output != null && !_.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.existingInputs[mix.program] ?? + (state.reportedState.inputsAddedByUs[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 != 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 + } + } + + return false + } + + private _getFilename(filePath: string) { + return path.basename(filePath) + } +} 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..cb712b170 --- /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/vMixTimelineStateConverter.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts new file mode 100644 index 000000000..8c37fb7c6 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts @@ -0,0 +1,285 @@ +import { + DeviceType, + Mapping, + MappingVmixType, + Mappings, + SomeMappingVmix, + TSRTimelineContent, + Timeline, + TimelineContentTypeVMix, + VMixTransition, + VMixTransitionType, +} from 'timeline-state-resolver-types' +import { TSR_INPUT_PREFIX, VMixInput, VMixInputAudio, VMixState, 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 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 getDefaultInputAudioState: (inputIndex: number | string | undefined) => VMixInputAudio + ) {} + + getVMixStateFromTimelineState( + state: Timeline.TimelineState, + mappings: MappingsVmix + ): VMixStateExtended { + 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], + })).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 filteredVMixTlAudio = _.pick( + content, + 'volume', + 'balance', + 'audioAuto', + 'audioBuses', + 'muted', + 'fade' + ) + if (mapping.options.index) { + deviceState.reportedState = this._modifyInputAudio(deviceState, filteredVMixTlAudio, { + key: mapping.options.index, + }) + } else if (mapping.options.inputLayer) { + deviceState.reportedState = this._modifyInputAudio(deviceState, filteredVMixTlAudio, { + 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 = 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, filePath: 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 + } + + private _modifyInput( + deviceState: VMixStateExtended, + newInput: VMixInput, + input: { key?: string | number; layer?: string; filePath?: string }, + layerName: string + ): VMixState { + let inputs = deviceState.reportedState.existingInputs + const filteredNewInput = _.pick(newInput, (x) => x !== undefined) + 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 + } + if (inputKey) { + 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 + } + + private _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 + } + } + + 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) + } + break + case MappingVmixType.AudioChannel: + if (mapping.options.index) { + state.reportedState.existingInputsAudio[mapping.options.index] = this.getDefaultInputAudioState( + 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 + case MappingVmixType.Overlay: + state.reportedState.overlays[mapping.options.index - 1] = { + number: mapping.options.index, + input: undefined, + } + break + } + } + return state + } +} 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..e204dc6bb --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/vmix/vMixXmlStateParser.ts @@ -0,0 +1,134 @@ +import * as xml from 'xml-js' +import { TSR_INPUT_PREFIX, VMixInput, VMixInputAudio, VMixMix, VMixState } from './vMixStateDiffer' +import { InferredPartialInputStateKeys } from './connection' +import { VMixTransitionType } from 'timeline-state-resolver-types' + +/** + * Parses the state incoming from vMix into a TSR representation + */ +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 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' && input['list']['item'] != null) { + fixedListFilePaths = this.ensureArray(input['list']['item']).map((item) => item['_text']) + } + + 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: inputNumber, + 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', + + 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, + 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[title] = result + inputsAddedByUsAudio[title] = resultAudio + } else { + 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 + } + } + + // For what lies ahead I apologise - Tom + return { + version: xmlState['vmix']['version']['_text'], + edition: xmlState['vmix']['edition']['_text'], + existingInputs, + existingInputsAudio, + inputsAddedByUs, + inputsAddedByUsAudio, + 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']), + }, + ], + } + } + + private ensureArray(value: T[] | T): T[] { + return Array.isArray(value) ? value : [value] + } +}