Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: A series of refactorings and fixes in vMix integration #315

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/timeline-state-resolver-types/src/vmix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
@@ -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
})
})
Loading
Loading