Skip to content

Commit

Permalink
Merge pull request #315 from tv2norge-collab/contribute/EAV-142/fix-v…
Browse files Browse the repository at this point in the history
…mix-media-reload-retrying-release50

refactor: A series of refactorings and fixes in vMix integration
  • Loading branch information
jesperstarkar authored Jan 10, 2024
2 parents 899c194 + 5dc8fec commit fdb9a08
Show file tree
Hide file tree
Showing 19 changed files with 2,654 additions and 1,610 deletions.
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
22 changes: 22 additions & 0 deletions packages/timeline-state-resolver/src/integrations/vmix/README.md
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

0 comments on commit fdb9a08

Please sign in to comment.