From 41c1f9312d74403a65cfcc2b04ba12a6392d6511 Mon Sep 17 00:00:00 2001 From: ianshade Date: Wed, 13 Nov 2024 13:19:37 +0100 Subject: [PATCH 1/2] feat(EAV-411): support setting text in vMix titles --- .../src/integrations/vmix.ts | 10 ++ .../vmix/__tests__/connection.spec.ts | 17 +++ .../vmix/__tests__/vMixStateDiffer.spec.ts | 134 ++++++++++++++++++ .../vMixTimelineStateConverter.spec.ts | 21 +++ .../vmix/__tests__/vMixXmlStateParser.spec.ts | 27 ++++ .../src/integrations/vmix/connection.ts | 18 +-- .../src/integrations/vmix/vMixCommands.ts | 7 + .../src/integrations/vmix/vMixStateDiffer.ts | 18 +++ .../vmix/vMixTimelineStateConverter.ts | 1 + .../integrations/vmix/vMixXmlStateParser.ts | 14 +- 10 files changed, 255 insertions(+), 12 deletions(-) diff --git a/packages/timeline-state-resolver-types/src/integrations/vmix.ts b/packages/timeline-state-resolver-types/src/integrations/vmix.ts index 1826497c9..569ff1910 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', } export type TimelineContentVMixAny = @@ -188,6 +189,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 } export interface TimelineContentVMixOutput extends TimelineContentVMixBase { @@ -243,6 +249,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 6c6201fa8..52788eb4b 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 @@ -92,4 +92,21 @@ describe('VMixCommandSender', () => { value: 1.5, }) }) + + 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', + }) + }) }) 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 f2782ceb9..48baf6919 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 @@ -146,4 +146,138 @@ describe('VMixStateDiffer', () => { cropBottom: 0.8, }) }) + + 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', + }) + }) }) 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 66a25af2a..4faf80885 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 @@ -136,6 +136,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) + }) + // 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() 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 67ebfed0d..cec7c0c02 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.mix !== 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) default: throw new Error(`vmixAPI: Command ${((command || {}) as any).command} not implemented`) } @@ -467,6 +463,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 }) + } + private async sendCommandFunction(func: string, args: SentCommandArgs) { return this.vMixConnection.sendCommandFunction(func, args) } diff --git a/packages/timeline-state-resolver/src/integrations/vmix/vMixCommands.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixCommands.ts index 9c67d1c21..f4fbaa80c 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 type VMixStateCommand = | VMixStateCommandPreviewInput | VMixStateCommandTransition @@ -248,6 +254,7 @@ export type VMixStateCommand = | VMixStateCommandListAdd | VMixStateCommandListRemoveAll | VMixStateCommandRestart + | VMixStateCommandSetText export enum CommandContext { None = 'none', diff --git a/packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts index 71948d452..966f01b2d 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 } export interface VMixInputAudio { @@ -583,6 +585,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: '', + }) + } + } + } return { preTransitionCommands, postTransitionCommands } } diff --git a/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts index baf9b787d..8d44ab71b 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts @@ -143,6 +143,7 @@ export class VMixTimelineStateConverter { (content.overlays ? this._convertDeprecatedInputOverlays(content.overlays) : undefined), listFilePaths: content.listFilePaths, restart: content.restart, + text: content.text, }, { key: mapping.options.index, filePath: content.filePath }, layerName 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 = { From 3c56693d39eae92f41c66f567eca3a7730cab6ac Mon Sep 17 00:00:00 2001 From: ianshade Date: Wed, 13 Nov 2024 19:34:32 +0100 Subject: [PATCH 2/2] fix(EAV-411): SelectedName parameter support --- .../timeline-state-resolver/src/integrations/vmix/connection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/timeline-state-resolver/src/integrations/vmix/connection.ts b/packages/timeline-state-resolver/src/integrations/vmix/connection.ts index cec7c0c02..da0005912 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/connection.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/connection.ts @@ -69,7 +69,7 @@ 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.mix !== undefined ? `&SelectedName=${args.selectedName}` : '' + const selectedName = args.selectedName !== undefined ? `&SelectedName=${args.selectedName}` : '' const ext = args.extra !== undefined ? args.extra : '' const queryString = `${inp}${val}${dur}${mix}${ext}${selectedName}`.slice(1) // remove the first &