diff --git a/packages/timeline-state-resolver-types/src/integrations/vmix.ts b/packages/timeline-state-resolver-types/src/integrations/vmix.ts
index 1513b89dd..f01424f2a 100644
--- a/packages/timeline-state-resolver-types/src/integrations/vmix.ts
+++ b/packages/timeline-state-resolver-types/src/integrations/vmix.ts
@@ -45,6 +45,7 @@ export enum VMixCommand {
LIST_ADD = 'LIST_ADD',
LIST_REMOVE_ALL = 'LIST_REMOVE_ALL',
RESTART_INPUT = 'RESTART_INPUT',
+ SET_TEXT = 'SET_TEXT',
BROWSER_NAVIGATE = 'BROWSER_NAVIGATE',
SELECT_INDEX = 'SELECT_INDEX',
}
@@ -191,6 +192,11 @@ export interface TimelineContentVMixInput extends TimelineContentVMixBase {
/** If media should start from the beginning or resume from where it left off */
restart?: boolean
+ /**
+ * Titles (GT): Sets the values of text fields by name
+ */
+ text?: VMixText
+
/** The URL for Browser input */
url?: string
@@ -255,6 +261,10 @@ export interface VMixInputOverlays {
[index: number]: number | string
}
+export interface VMixText {
+ [index: string]: string
+}
+
export interface VMixLayer {
input: string | number
diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/connection.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/connection.spec.ts
index 873eeb147..50a10a25e 100644
--- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/connection.spec.ts
+++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/connection.spec.ts
@@ -93,6 +93,23 @@ describe('VMixCommandSender', () => {
})
})
+ it('sets text', async () => {
+ const { sender, mockConnection } = createTestee()
+ await sender.sendCommand({
+ command: VMixCommand.SET_TEXT,
+ input: 5,
+ value: 'Foo',
+ fieldName: 'myTitle.Text',
+ })
+
+ expect(mockConnection.sendCommandFunction).toHaveBeenCalledTimes(1)
+ expect(mockConnection.sendCommandFunction).toHaveBeenLastCalledWith('SetText', {
+ input: 5,
+ value: 'Foo',
+ selectedName: 'myTitle.Text',
+ })
+ })
+
it('sends url', async () => {
const { sender, mockConnection } = createTestee()
await sender.sendCommand({
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
index 2b3f95dc0..cb6ce6366 100644
--- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixStateDiffer.spec.ts
+++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixStateDiffer.spec.ts
@@ -147,6 +147,140 @@ describe('VMixStateDiffer', () => {
})
})
+ it('sets text', () => {
+ const differ = createTestee()
+
+ const oldState = makeMockFullState()
+ const newState = makeMockFullState()
+
+ oldState.reportedState.existingInputs['99'] = differ.getDefaultInputState(99)
+
+ newState.reportedState.existingInputs['99'] = differ.getDefaultInputState(99)
+
+ newState.reportedState.existingInputs['99'].text = {
+ 'myTitle.Text': 'SomeValue',
+ }
+
+ const commands = differ.getCommandsToAchieveState(Date.now(), oldState, newState)
+
+ expect(commands.length).toBe(1)
+ expect(commands[0].command).toMatchObject({
+ command: VMixCommand.SET_TEXT,
+ input: '99',
+ value: 'SomeValue',
+ fieldName: 'myTitle.Text',
+ })
+ })
+
+ it('sets multiple texts', () => {
+ const differ = createTestee()
+
+ const oldState = makeMockFullState()
+ const newState = makeMockFullState()
+
+ oldState.reportedState.existingInputs['99'] = differ.getDefaultInputState(99)
+
+ newState.reportedState.existingInputs['99'] = differ.getDefaultInputState(99)
+
+ newState.reportedState.existingInputs['99'].text = {
+ 'myTitle.Text': 'SomeValue',
+ 'myTitle.Foo': 'Bar',
+ }
+
+ const commands = differ.getCommandsToAchieveState(Date.now(), oldState, newState)
+
+ expect(commands.length).toBe(2)
+ expect(commands[0].command).toMatchObject({
+ command: VMixCommand.SET_TEXT,
+ input: '99',
+ value: 'SomeValue',
+ fieldName: 'myTitle.Text',
+ })
+ expect(commands[1].command).toMatchObject({
+ command: VMixCommand.SET_TEXT,
+ input: '99',
+ value: 'Bar',
+ fieldName: 'myTitle.Foo',
+ })
+ })
+
+ it('does not unset text', () => {
+ // it would have to be explicitly set to an empty string on the timeline
+ const differ = createTestee()
+
+ const oldState = makeMockFullState()
+ const newState = makeMockFullState()
+
+ oldState.reportedState.existingInputs['99'] = differ.getDefaultInputState(99)
+ oldState.reportedState.existingInputs['99'].text = {
+ 'myTitle.Text': 'SomeValue',
+ 'myTitle.Foo': 'Bar',
+ }
+
+ newState.reportedState.existingInputs['99'] = differ.getDefaultInputState(99)
+ newState.reportedState.existingInputs['99'].text = {
+ 'myTitle.Foo': 'Bar',
+ }
+
+ const commands = differ.getCommandsToAchieveState(Date.now(), oldState, newState)
+
+ expect(commands.length).toBe(0)
+ })
+
+ it('updates text', () => {
+ const differ = createTestee()
+
+ const oldState = makeMockFullState()
+ const newState = makeMockFullState()
+
+ oldState.reportedState.existingInputs['99'] = differ.getDefaultInputState(99)
+ oldState.reportedState.existingInputs['99'].text = {
+ 'myTitle.Text': 'SomeValue',
+ }
+
+ newState.reportedState.existingInputs['99'] = differ.getDefaultInputState(99)
+ newState.reportedState.existingInputs['99'].text = {
+ 'myTitle.Text': 'Bar',
+ }
+
+ const commands = differ.getCommandsToAchieveState(Date.now(), oldState, newState)
+
+ expect(commands.length).toBe(1)
+ expect(commands[0].command).toMatchObject({
+ command: VMixCommand.SET_TEXT,
+ input: '99',
+ value: 'Bar',
+ fieldName: 'myTitle.Text',
+ })
+ })
+
+ it('updates text to an empty string', () => {
+ const differ = createTestee()
+
+ const oldState = makeMockFullState()
+ const newState = makeMockFullState()
+
+ oldState.reportedState.existingInputs['99'] = differ.getDefaultInputState(99)
+ oldState.reportedState.existingInputs['99'].text = {
+ 'myTitle.Text': 'SomeValue',
+ }
+
+ newState.reportedState.existingInputs['99'] = differ.getDefaultInputState(99)
+ newState.reportedState.existingInputs['99'].text = {
+ 'myTitle.Text': '',
+ }
+
+ const commands = differ.getCommandsToAchieveState(Date.now(), oldState, newState)
+
+ expect(commands.length).toBe(1)
+ expect(commands[0].command).toMatchObject({
+ command: VMixCommand.SET_TEXT,
+ input: '99',
+ value: '',
+ fieldName: 'myTitle.Text',
+ })
+ })
+
it('sets browser url', () => {
const differ = createTestee()
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
index 72e59eddc..b1ba717b5 100644
--- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixTimelineStateConverter.spec.ts
+++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixTimelineStateConverter.spec.ts
@@ -137,6 +137,27 @@ describe('VMixTimelineStateConverter', () => {
expect(result.reportedState.inputsAddedByUsAudio[prefixAddedInput(filePath)]).toBeUndefined()
})
+ it('supports text', () => {
+ const converter = createTestee()
+ const text = { 'myTitle.Text': 'SomeValue', 'myTitle.Foo': 'Bar' }
+ const result = converter.getVMixStateFromTimelineState(
+ wrapInTimelineState({
+ inp0: wrapInTimelineObject('inp0', {
+ deviceType: DeviceType.VMIX,
+ text,
+ type: TimelineContentTypeVMix.INPUT,
+ }),
+ }),
+ {
+ inp0: wrapInMapping({
+ mappingType: MappingVmixType.Input,
+ index: '1',
+ }),
+ }
+ )
+ expect(result.reportedState.existingInputs['1'].text).toEqual(text)
+ })
+
it('allows overriding transitions in usual layer order', () => {
const converter = createTestee()
const result = converter.getVMixStateFromTimelineState(
diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixXmlStateParser.spec.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixXmlStateParser.spec.ts
index 81edafb13..9b974d7e0 100644
--- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixXmlStateParser.spec.ts
+++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/vMixXmlStateParser.spec.ts
@@ -242,4 +242,31 @@ describe('VMixXmlStateParser', () => {
},
})
})
+
+ it('parses text (titles)', () => {
+ const parser = new VMixXmlStateParser()
+
+ const parsedState = parser.parseVMixState(
+ makeMockVMixXmlState([
+ '',
+ `
+ gfx.gtzip
+ SomeText
+ Foo
+`,
+ '',
+ ])
+ )
+
+ expect(parsedState).toMatchObject>({
+ existingInputs: {
+ '2': {
+ text: {
+ 'TextBlock1.Text': 'SomeText',
+ 'AnotherBlock.Text': 'Foo',
+ },
+ },
+ },
+ })
+ })
})
diff --git a/packages/timeline-state-resolver/src/integrations/vmix/connection.ts b/packages/timeline-state-resolver/src/integrations/vmix/connection.ts
index 8d49a452f..7ee80451c 100644
--- a/packages/timeline-state-resolver/src/integrations/vmix/connection.ts
+++ b/packages/timeline-state-resolver/src/integrations/vmix/connection.ts
@@ -21,20 +21,13 @@ export type ConnectionEvents = {
error: [error: Error]
}
-/**
- * This TSR integration polls the state of vMix and merges that into our last-known state.
- * However, not all state properties can be retried from vMix's API.
- * Therefore, there are some properties that we must "carry over" from our last-known state, every time.
- * These are those property keys for the Input state objects.
- */
-export type InferredPartialInputStateKeys = 'filePath' | 'fade' | 'audioAuto' | 'restart'
-
interface SentCommandArgs {
input?: string | number
value?: string | number
extra?: string
duration?: number
mix?: number
+ selectedName?: string
}
export class VMixConnection extends EventEmitter {
@@ -76,9 +69,10 @@ export class VMixConnection extends EventEmitter {
const val = args.value !== undefined ? `&Value=${args.value}` : ''
const dur = args.duration !== undefined ? `&Duration=${args.duration}` : ''
const mix = args.mix !== undefined ? `&Mix=${args.mix}` : ''
+ const selectedName = args.selectedName !== undefined ? `&SelectedName=${args.selectedName}` : ''
const ext = args.extra !== undefined ? args.extra : ''
- const queryString = `${inp}${val}${dur}${mix}${ext}`.slice(1) // remove the first &
+ const queryString = `${inp}${val}${dur}${mix}${ext}${selectedName}`.slice(1) // remove the first &
let command = `FUNCTION ${func}`
if (queryString) {
@@ -260,6 +254,8 @@ export class VMixCommandSender {
return this.listRemoveAll(command.input)
case VMixCommand.RESTART_INPUT:
return this.restart(command.input)
+ case VMixCommand.SET_TEXT:
+ return this.setText(command.input, command.value, command.fieldName)
case VMixCommand.BROWSER_NAVIGATE:
return this.browserNavigate(command.input, command.value)
case VMixCommand.SELECT_INDEX:
@@ -471,6 +467,10 @@ export class VMixCommandSender {
return this.sendCommandFunction(`Restart`, { input })
}
+ public async setText(input: string | number, value: string, fieldName: string): Promise {
+ return this.sendCommandFunction(`SetText`, { input, value, selectedName: fieldName })
+ }
+
public async browserNavigate(input: string | number, value: string): Promise {
return this.sendCommandFunction(`BrowserNavigate`, { input, value: encodeURIComponent(value) })
}
diff --git a/packages/timeline-state-resolver/src/integrations/vmix/vMixCommands.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixCommands.ts
index b67894118..446067d36 100644
--- a/packages/timeline-state-resolver/src/integrations/vmix/vMixCommands.ts
+++ b/packages/timeline-state-resolver/src/integrations/vmix/vMixCommands.ts
@@ -203,6 +203,12 @@ export interface VMixStateCommandRestart extends VMixStateCommandBase {
command: VMixCommand.RESTART_INPUT
input: string | number
}
+export interface VMixStateCommandSetText extends VMixStateCommandBase {
+ command: VMixCommand.SET_TEXT
+ input: string | number
+ fieldName: string
+ value: string
+}
export interface VMixStateCommandBrowserNavigate extends VMixStateCommandBase {
command: VMixCommand.BROWSER_NAVIGATE
input: string | number
@@ -258,6 +264,7 @@ export type VMixStateCommand =
| VMixStateCommandListAdd
| VMixStateCommandListRemoveAll
| VMixStateCommandRestart
+ | VMixStateCommandSetText
| VMixStateCommandBrowserNavigate
| VMixStateCommanSelectIndex
diff --git a/packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts
index 4f10911c6..86cd41b6b 100644
--- a/packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts
+++ b/packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts
@@ -6,6 +6,7 @@ import {
VMixTransition,
VMixTransitionType,
VMixLayer,
+ VMixText,
} from 'timeline-state-resolver-types'
import { CommandContext, VMixStateCommandWithContext } from './vMixCommands'
import _ = require('underscore')
@@ -78,6 +79,7 @@ export interface VMixInput {
layers?: VMixLayers
listFilePaths?: string[]
restart?: boolean
+ text?: VMixText
url?: string
index?: number
}
@@ -585,6 +587,22 @@ export class VMixStateDiffer {
timelineId: '',
})
}
+ if (input.text !== undefined) {
+ for (const [fieldName, value] of Object.entries(input.text)) {
+ if (oldInput?.text?.[fieldName] !== value) {
+ commands.push({
+ command: {
+ command: VMixCommand.SET_TEXT,
+ input: key,
+ value,
+ fieldName,
+ },
+ context: CommandContext.None,
+ timelineId: '',
+ })
+ }
+ }
+ }
if (input.url !== undefined && oldInput.url !== input.url) {
commands.push({
command: {
diff --git a/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts
index 7b6a1b896..faecbc444 100644
--- a/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts
+++ b/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts
@@ -148,6 +148,7 @@ export class VMixTimelineStateConverter {
(content.overlays ? this._convertDeprecatedInputOverlays(content.overlays) : undefined),
listFilePaths: content.listFilePaths,
restart: content.restart,
+ text: content.text,
url: content.url,
index: content.index,
},
diff --git a/packages/timeline-state-resolver/src/integrations/vmix/vMixXmlStateParser.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixXmlStateParser.ts
index e5ea9790b..c2c69a308 100644
--- a/packages/timeline-state-resolver/src/integrations/vmix/vMixXmlStateParser.ts
+++ b/packages/timeline-state-resolver/src/integrations/vmix/vMixXmlStateParser.ts
@@ -1,6 +1,5 @@
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'
/**
@@ -19,11 +18,11 @@ export class VMixXmlStateParser {
const inputsAddedByUsAudio: Record = {}
const inputKeysToNumbers: Record = {}
- for (const input of xmlState['vmix']['inputs']['input'] as Omit[]) {
+ for (const input of xmlState['vmix']['inputs']['input']) {
inputKeysToNumbers[input['_attributes']['key']] = Number(input['_attributes']['number'])
}
- for (const input of xmlState['vmix']['inputs']['input'] as Omit[]) {
+ for (const input of xmlState['vmix']['inputs']['input']) {
const title = input['_attributes']['title'] as string
const inputNumber = Number(input['_attributes']['number'])
const isAddedByUs = title.startsWith(TSR_INPUT_PREFIX)
@@ -51,6 +50,14 @@ export class VMixXmlStateParser {
})
}
+ let text: VMixInput['text'] = undefined
+ if (input['text'] != null) {
+ this.ensureArray(input['text']).forEach((item) => {
+ text = text ?? {}
+ text[item['_attributes']['name']] = item['_text']
+ })
+ }
+
const result: VMixInput = {
number: inputNumber,
type: input['_attributes']['type'],
@@ -69,6 +76,7 @@ export class VMixXmlStateParser {
},
layers,
listFilePaths: fixedListFilePaths!,
+ text,
}
const resultAudio = {