diff --git a/packages/timeline-state-resolver-types/src/integrations/vmix.ts b/packages/timeline-state-resolver-types/src/integrations/vmix.ts index 6ce54a6d1..1826497c9 100644 --- a/packages/timeline-state-resolver-types/src/integrations/vmix.ts +++ b/packages/timeline-state-resolver-types/src/integrations/vmix.ts @@ -34,7 +34,11 @@ export enum VMixCommand { STOP_EXTERNAL = 'STOP_EXTERNAL', OVERLAY_INPUT_IN = 'OVERLAY_INPUT_IN', OVERLAY_INPUT_OUT = 'OVERLAY_INPUT_OUT', - SET_INPUT_OVERLAY = 'SET_INPUT_OVERLAY', + SET_LAYER_INPUT = 'SET_LAYER_INPUT', + SET_LAYER_ZOOM = 'SET_LAYER_ZOOM', + SET_LAYER_PAN_X = 'SET_LAYER_PAN_X', + SET_LAYER_PAN_Y = 'SET_LAYER_PAN_Y', + SET_LAYER_CROP = 'SET_LAYER_CROP', SCRIPT_START = 'SCRIPT_START', SCRIPT_STOP = 'SCRIPT_STOP', SCRIPT_STOP_ALL = 'SCRIPT_STOP_ALL', @@ -166,9 +170,19 @@ export interface TimelineContentVMixInput extends TimelineContentVMixBase { transform?: VMixTransform - /** List of input (Multi View) overlays; indexes start from 1 */ + /** + * List of input (Multi View) overlays; indexes start from 1 + * @deprecated Use `layers` instead. If both `layers` and `overlays` are provided, `overlays` will be discarded + */ overlays?: VMixInputOverlays + /** + * List of input Layers. + * Indexes start from 1. + * Requires vMix 27+. + */ + layers?: VMixLayers + /** An array of file paths to load into a List input. Uses Windows-style path separators (\\). Only applies to List inputs. */ listFilePaths?: string[] @@ -221,10 +235,55 @@ export interface VMixTransform { alpha: number } +export interface VMixLayers { + [index: number]: VMixLayer +} + export interface VMixInputOverlays { [index: number]: number | string } +export interface VMixLayer { + input: string | number + + /** + * Horizontal pan (-2 - 2) + * 0 = centered, -2 = 100% to left, 2 = 100% to right + */ + panX?: number + /** + * Vertical pan (-2 - 2) + * 0 = centered, -2 = 100% to bottom, 2 = 100% to top + */ + panY?: number + + /** + * Scale (0 - 5) + */ + zoom?: number + + /** + * Left crop (0 - 1) + * 0 = No Crop, 1 = Full Crop + */ + cropLeft?: number + /** + * Top crop (0 - 1) + * 0 = No Crop, 1 = Full Crop + */ + cropTop?: number + /** + * Right crop (0 - 1) + * 1 = No Crop, 0 = Full Crop + */ + cropRight?: number + /** + * Bottom crop (0 - 1) + * 1 = No Crop, 0 = Full Crop + */ + cropBottom?: number +} + export interface VMixTransition { effect: VMixTransitionType diff --git a/packages/timeline-state-resolver/package.json b/packages/timeline-state-resolver/package.json index ec5563ba2..e36c795ed 100644 --- a/packages/timeline-state-resolver/package.json +++ b/packages/timeline-state-resolver/package.json @@ -135,6 +135,7 @@ "@types/simple-oauth2": "^5.0.7", "i18next-conv": "^13.1.1", "i18next-parser": "^6.6.0", + "jest-mock-extended": "^3.0.7", "json-schema-ref-parser": "^9.0.9", "json-schema-to-typescript": "^10.1.5", "vinyl-fs": "^3.0.3" 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 new file mode 100644 index 000000000..6c6201fa8 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/connection.spec.ts @@ -0,0 +1,95 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { mock } from 'jest-mock-extended' +import { VMixCommandSender, VMixConnection } from '../connection' +import { VMixCommand } from 'timeline-state-resolver-types' + +function createTestee() { + const mockConnection = mock() + const sender = new VMixCommandSender(mockConnection) + return { mockConnection, sender } +} + +describe('VMixCommandSender', () => { + it('sends layer input', async () => { + const { sender, mockConnection } = createTestee() + await sender.sendCommand({ + command: VMixCommand.SET_LAYER_INPUT, + index: 2, + input: 5, + value: 7, + }) + + expect(mockConnection.sendCommandFunction).toHaveBeenCalledTimes(1) + expect(mockConnection.sendCommandFunction).toHaveBeenLastCalledWith('SetMultiViewOverlay', { + input: 5, + value: '2,7', + }) + }) + + it('sends layer crop', async () => { + const { sender, mockConnection } = createTestee() + await sender.sendCommand({ + command: VMixCommand.SET_LAYER_CROP, + index: 2, + input: 5, + cropLeft: 0.1, + cropTop: 0.2, + cropRight: 0.3, + cropBottom: 0.4, + }) + + expect(mockConnection.sendCommandFunction).toHaveBeenCalledTimes(1) + expect(mockConnection.sendCommandFunction).toHaveBeenLastCalledWith('SetLayer2Crop', { + input: 5, + value: '0.1,0.2,0.3,0.4', + }) + }) + + it('sends layer zoom', async () => { + const { sender, mockConnection } = createTestee() + await sender.sendCommand({ + command: VMixCommand.SET_LAYER_ZOOM, + index: 3, + input: 6, + value: 1.5, + }) + + expect(mockConnection.sendCommandFunction).toHaveBeenCalledTimes(1) + expect(mockConnection.sendCommandFunction).toHaveBeenLastCalledWith('SetLayer3Zoom', { + input: 6, + value: 1.5, + }) + }) + + it('sends layer panX', async () => { + const { sender, mockConnection } = createTestee() + await sender.sendCommand({ + command: VMixCommand.SET_LAYER_PAN_X, + index: 3, + input: 6, + value: 1.5, + }) + + expect(mockConnection.sendCommandFunction).toHaveBeenCalledTimes(1) + expect(mockConnection.sendCommandFunction).toHaveBeenLastCalledWith('SetLayer3PanX', { + input: 6, + value: 1.5, + }) + }) + + it('sends layer panY', async () => { + const { sender, mockConnection } = createTestee() + await sender.sendCommand({ + command: VMixCommand.SET_LAYER_PAN_Y, + index: 3, + input: 6, + value: 1.5, + }) + + expect(mockConnection.sendCommandFunction).toHaveBeenCalledTimes(1) + expect(mockConnection.sendCommandFunction).toHaveBeenLastCalledWith('SetLayer3PanY', { + input: 6, + value: 1.5, + }) + }) +}) diff --git a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/mockState.ts b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/mockState.ts index c51e1610c..e6a054425 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/__tests__/mockState.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/__tests__/mockState.ts @@ -23,7 +23,7 @@ export function makeMockReportedState(): VMixState { zoom: 1, }, listFilePaths: undefined, - overlays: undefined, + layers: undefined, playing: true, }, '2': { @@ -40,7 +40,7 @@ export function makeMockReportedState(): VMixState { zoom: 1, }, listFilePaths: undefined, - overlays: undefined, + layers: undefined, playing: true, }, }, @@ -75,7 +75,7 @@ export function makeMockReportedState(): VMixState { zoom: 1, }, listFilePaths: undefined, - overlays: undefined, + layers: undefined, playing: true, name: ADDED_INPUT_NAME_1, }, @@ -93,7 +93,7 @@ export function makeMockReportedState(): VMixState { zoom: 1, }, listFilePaths: undefined, - overlays: undefined, + layers: undefined, playing: true, name: ADDED_INPUT_NAME_2, }, 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 ac62c24b4..99a98e695 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 @@ -32,4 +32,118 @@ describe('VMixStateDiffer', () => { expect(busCommands.length).toBe(7) // all but Master }) + + it('sets layer input when it starts to be controlled', () => { + 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'].layers = { + 2: { + input: 5, + }, + } + + const commands = differ.getCommandsToAchieveState(oldState, newState) + + expect(commands.length).toBe(1) + expect(commands[0].command).toMatchObject({ command: VMixCommand.SET_LAYER_INPUT, value: 5, index: 2 }) + }) + + it('sets layer zoom', () => { + const differ = createTestee() + + const oldState = makeMockFullState() + const newState = makeMockFullState() + + oldState.reportedState.existingInputs['99'] = differ.getDefaultInputState(99) + oldState.reportedState.existingInputs['99'].layers = { + 2: { + input: 5, + }, + } + + newState.reportedState.existingInputs['99'] = differ.getDefaultInputState(99) + newState.reportedState.existingInputs['99'].layers = { + 2: { + input: 5, + zoom: 1.5, + }, + } + + const commands = differ.getCommandsToAchieveState(oldState, newState) + + expect(commands.length).toBe(1) + expect(commands[0].command).toMatchObject({ command: VMixCommand.SET_LAYER_ZOOM, value: 1.5, index: 2 }) + }) + + it('sets layer pan', () => { + const differ = createTestee() + + const oldState = makeMockFullState() + const newState = makeMockFullState() + + oldState.reportedState.existingInputs['99'] = differ.getDefaultInputState(99) + oldState.reportedState.existingInputs['99'].layers = { + 2: { + input: 5, + }, + } + + newState.reportedState.existingInputs['99'] = differ.getDefaultInputState(99) + newState.reportedState.existingInputs['99'].layers = { + 2: { + input: 5, + panX: -1, + panY: 2, + }, + } + + const commands = differ.getCommandsToAchieveState(oldState, newState) + + expect(commands.length).toBe(2) + expect(commands[0].command).toMatchObject({ command: VMixCommand.SET_LAYER_PAN_X, value: -1, index: 2 }) + expect(commands[1].command).toMatchObject({ command: VMixCommand.SET_LAYER_PAN_Y, value: 2, index: 2 }) + }) + + it('sets layer crop', () => { + const differ = createTestee() + + const oldState = makeMockFullState() + const newState = makeMockFullState() + + oldState.reportedState.existingInputs['99'] = differ.getDefaultInputState(99) + oldState.reportedState.existingInputs['99'].layers = { + 2: { + input: 5, + }, + } + + newState.reportedState.existingInputs['99'] = differ.getDefaultInputState(99) + newState.reportedState.existingInputs['99'].layers = { + 2: { + input: 5, + cropLeft: 0.2, + cropRight: 0.7, + cropTop: 0.1, + cropBottom: 0.8, + }, + } + + const commands = differ.getCommandsToAchieveState(oldState, newState) + + expect(commands.length).toBe(1) + expect(commands[0].command).toMatchObject({ + command: VMixCommand.SET_LAYER_CROP, + index: 2, + cropLeft: 0.2, + cropRight: 0.7, + cropTop: 0.1, + cropBottom: 0.8, + }) + }) }) 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 3c4d53aa3..81edafb13 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 @@ -28,7 +28,7 @@ describe('VMixXmlStateParser', () => { zoom: 1, }, listFilePaths: undefined, - overlays: {}, + layers: {}, playing: true, }, '2': { @@ -45,7 +45,7 @@ describe('VMixXmlStateParser', () => { zoom: 1, }, listFilePaths: undefined, - overlays: {}, + layers: {}, playing: true, }, }, @@ -132,7 +132,7 @@ describe('VMixXmlStateParser', () => { zoom: 1, }, listFilePaths: undefined, - overlays: {}, + layers: {}, playing: true, }, }, @@ -161,7 +161,7 @@ describe('VMixXmlStateParser', () => { }, listFilePaths: undefined, name: prefixAddedInput('C:\\someVideo.mp4'), - overlays: {}, + layers: {}, playing: true, }, }, @@ -194,9 +194,49 @@ describe('VMixXmlStateParser', () => { expect(parsedState).toMatchObject>({ existingInputs: { '2': { - overlays: { - 3: 3, - 6: 1, + layers: { + 3: { input: 3 }, + 6: { input: 1 }, + }, + }, + }, + }) + }) + + it('parses input overlay position and position+crop', () => { + const parser = new VMixXmlStateParser() + + const parsedState = parser.parseVMixState( + makeMockVMixXmlState([ + '', + ` + + + + + + + +`, + '', + ]) + ) + + expect(parsedState).toMatchObject>({ + existingInputs: { + '2': { + layers: { + 3: { input: 3, panX: -0.673, panY: 0, zoom: 0.4, cropLeft: 0, cropTop: 0, cropBottom: 1, cropRight: 1 }, + 6: { + input: 1, + panX: -0.79, + panY: 0.134, + zoom: 0.208, + cropLeft: 0.1042, + cropTop: 0.1, + cropBottom: 0.7, + cropRight: 0.8958, + }, }, }, }, 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 f60fa3855..83efaf278 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 @@ -464,7 +464,7 @@ describe('vMix', () => { 11000, expect.objectContaining({ command: { - command: VMixCommand.SET_INPUT_OVERLAY, + command: VMixCommand.SET_LAYER_INPUT, input: prefixAddedInput('C:/videos/My Clip.mp4'), index: 1, value: 'G:/videos/My Other Clip.mp4', @@ -478,7 +478,7 @@ describe('vMix', () => { 11000, expect.objectContaining({ command: { - command: VMixCommand.SET_INPUT_OVERLAY, + command: VMixCommand.SET_LAYER_INPUT, input: prefixAddedInput('C:/videos/My Clip.mp4'), index: 3, value: 5, @@ -666,7 +666,7 @@ describe('vMix', () => { 11000, expect.objectContaining({ command: { - command: VMixCommand.SET_INPUT_OVERLAY, + command: VMixCommand.SET_LAYER_INPUT, input: '2', index: 1, value: 'G:/videos/My Other Clip.mp4', @@ -680,7 +680,7 @@ describe('vMix', () => { 11000, expect.objectContaining({ command: { - command: VMixCommand.SET_INPUT_OVERLAY, + command: VMixCommand.SET_LAYER_INPUT, input: '2', index: 3, value: 5, @@ -747,7 +747,7 @@ describe('vMix', () => { 16000, expect.objectContaining({ command: { - command: VMixCommand.SET_INPUT_OVERLAY, + command: VMixCommand.SET_LAYER_INPUT, input: '2', index: 1, value: '', @@ -761,7 +761,7 @@ describe('vMix', () => { 16000, expect.objectContaining({ command: { - command: VMixCommand.SET_INPUT_OVERLAY, + command: VMixCommand.SET_LAYER_INPUT, input: '2', index: 3, value: '', @@ -3616,7 +3616,7 @@ describe('vMix', () => { 11000, expect.objectContaining({ command: { - command: VMixCommand.SET_INPUT_OVERLAY, + command: VMixCommand.SET_LAYER_INPUT, input: '2', value: 3, index: 1, diff --git a/packages/timeline-state-resolver/src/integrations/vmix/connection.ts b/packages/timeline-state-resolver/src/integrations/vmix/connection.ts index aaa3e8fbf..67ebfed0d 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/connection.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/connection.ts @@ -29,7 +29,15 @@ export type ConnectionEvents = { */ export type InferredPartialInputStateKeys = 'filePath' | 'fade' | 'audioAuto' | 'restart' -export class BaseConnection extends EventEmitter { +interface SentCommandArgs { + input?: string | number + value?: string | number + extra?: string + duration?: number + mix?: number +} + +export class VMixConnection extends EventEmitter { private _socket?: Socket private _reconnectTimeout?: NodeJS.Timeout private _connected = false @@ -63,10 +71,7 @@ export class BaseConnection extends EventEmitter { return this._sendCommand('XML') } - public async sendCommandFunction( - func: string, - args: { input?: string | number; value?: string | number; extra?: string; duration?: number; mix?: number } - ): Promise { + public async sendCommandFunction(func: string, args: SentCommandArgs): Promise { const inp = args.input !== undefined ? `&Input=${args.input}` : '' const val = args.value !== undefined ? `&Value=${args.value}` : '' const dur = args.duration !== undefined ? `&Duration=${args.duration}` : '' @@ -155,7 +160,9 @@ export class BaseConnection extends EventEmitter { } } -export class VMixConnection extends BaseConnection { +export class VMixCommandSender { + constructor(private vMixConnection: VMixConnection) {} + public async sendCommand(command: VMixStateCommand): Promise { switch (command.command) { case VMixCommand.PREVIEW_INPUT: @@ -224,8 +231,23 @@ export class VMixConnection extends BaseConnection { return this.overlayInputIn(command.value, command.input) case VMixCommand.OVERLAY_INPUT_OUT: return this.overlayInputOut(command.value) - case VMixCommand.SET_INPUT_OVERLAY: - return this.setInputOverlay(command.input, command.index, command.value) + case VMixCommand.SET_LAYER_INPUT: + return this.setLayerInput(command.input, command.index, command.value) + case VMixCommand.SET_LAYER_CROP: + return this.setLayerCrop( + command.input, + command.index, + command.cropLeft, + command.cropTop, + command.cropRight, + command.cropBottom + ) + case VMixCommand.SET_LAYER_PAN_X: + return this.setLayerPanX(command.input, command.index, command.value) + case VMixCommand.SET_LAYER_PAN_Y: + return this.setLayerPanY(command.input, command.index, command.value) + case VMixCommand.SET_LAYER_ZOOM: + return this.setLayerZoom(command.input, command.index, command.value) case VMixCommand.SCRIPT_START: return this.scriptStart(command.value) case VMixCommand.SCRIPT_STOP: @@ -379,11 +401,36 @@ export class VMixConnection extends BaseConnection { return this.sendCommandFunction(`OverlayInput${name}Out`, {}) } - public async setInputOverlay(input: string | number, index: number, value: string | number): Promise { + public async setLayerInput(input: string | number, index: number, value: string | number): Promise { const val = `${index},${value}` + // note: this could probably be replaced by SetLayer, but let's keep it backwards compatible until SetMultiViewOverlay becomes deprecated return this.sendCommandFunction(`SetMultiViewOverlay`, { input, value: val }) } + public async setLayerCrop( + input: string | number, + index: number, + cropLeft: number, + cropTop: number, + cropRight: number, + cropBottom: number + ): Promise { + const value = `${cropLeft},${cropTop},${cropRight},${cropBottom}` + return this.sendCommandFunction(`SetLayer${index}Crop`, { input, value }) + } + + public async setLayerZoom(input: string | number, index: number, value: number): Promise { + return this.sendCommandFunction(`SetLayer${index}Zoom`, { input, value }) + } + + public async setLayerPanX(input: string | number, index: number, value: number): Promise { + return this.sendCommandFunction(`SetLayer${index}PanX`, { input, value }) + } + + public async setLayerPanY(input: string | number, index: number, value: number): Promise { + return this.sendCommandFunction(`SetLayer${index}PanY`, { input, value }) + } + public async scriptStart(value: string): Promise { return this.sendCommandFunction(`ScriptStart`, { value }) } @@ -419,4 +466,8 @@ export class VMixConnection extends BaseConnection { public async restart(input: string | number): Promise { return this.sendCommandFunction(`Restart`, { input }) } + + private async sendCommandFunction(func: string, args: SentCommandArgs) { + return this.vMixConnection.sendCommandFunction(func, args) + } } diff --git a/packages/timeline-state-resolver/src/integrations/vmix/index.ts b/packages/timeline-state-resolver/src/integrations/vmix/index.ts index 394520a68..7897cb60e 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/index.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/index.ts @@ -2,7 +2,7 @@ import * as _ from 'underscore' import { DeviceWithState, CommandWithContext, DeviceStatus, StatusCode } from './../../devices/device' import { DoOnTime, SendMode } from '../../devices/doOnTime' -import { VMixConnection } from './connection' +import { VMixCommandSender, VMixConnection } from './connection' import { DeviceType, DeviceOptionsVMix, @@ -54,7 +54,7 @@ export type CommandReceiver = ( layer: string }*/ -export type EnforceableVMixInputStateKeys = 'duration' | 'loop' | 'transform' | 'overlays' | 'listFilePaths' +export type EnforceableVMixInputStateKeys = 'duration' | 'loop' | 'transform' | 'layers' | 'listFilePaths' /** * This is a VMixDevice, it sends commands when it feels like it @@ -65,6 +65,8 @@ export class VMixDevice extends DeviceWithState { this._vMixConnection = new VMixConnection(options.host, options.port, false) + this._vMixCommandSender = new VMixCommandSender(this._vMixConnection) 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 @@ -327,7 +330,7 @@ export class VMixDevice extends DeviceWithState { const presetActionCheckResult = this._checkPresetAction() if (presetActionCheckResult) return presetActionCheckResult - await this._vMixConnection.lastPreset() + await this._vMixCommandSender.lastPreset() return { result: ActionExecutionResultCode.Ok, } @@ -336,7 +339,7 @@ export class VMixDevice extends DeviceWithState { const presetActionCheckResult = this._checkPresetAction(payload, true) if (presetActionCheckResult) return presetActionCheckResult - await this._vMixConnection.openPreset(payload.filename) + await this._vMixCommandSender.openPreset(payload.filename) return { result: ActionExecutionResultCode.Ok, } @@ -345,7 +348,7 @@ export class VMixDevice extends DeviceWithState { const presetActionCheckResult = this._checkPresetAction(payload, true) if (presetActionCheckResult) return presetActionCheckResult - await this._vMixConnection.savePreset(payload.filename) + await this._vMixCommandSender.savePreset(payload.filename) return { result: ActionExecutionResultCode.Ok, } @@ -407,7 +410,7 @@ export class VMixDevice extends DeviceWithState { + return this._vMixCommandSender.sendCommand(cmd.command).catch((error) => { this.emit('commandError', error, cwc) }) } diff --git a/packages/timeline-state-resolver/src/integrations/vmix/vMixCommands.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixCommands.ts index 83aad4f05..c9eb7da77 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/vMixCommands.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/vMixCommands.ts @@ -146,12 +146,39 @@ export interface VMixStateCommandOverlayInputOut extends VMixStateCommandBase { command: VMixCommand.OVERLAY_INPUT_OUT value: number } -export interface VMixStateCommandSetInputOverlay extends VMixStateCommandBase { - command: VMixCommand.SET_INPUT_OVERLAY +export interface VMixStateCommandSetLayerInput extends VMixStateCommandBase { + command: VMixCommand.SET_LAYER_INPUT input: string | number index: number value: string | number } +export interface VMixStateCommandSetLayerZoom extends VMixStateCommandBase { + command: VMixCommand.SET_LAYER_ZOOM + input: string | number + index: number + value: number +} +export class VMixStateCommandSetLayerPanX implements VMixStateCommandBase { + command: VMixCommand.SET_LAYER_PAN_X + input: string | number + index: number + value: number +} +export class VMixStateCommandSetLayerPanY implements VMixStateCommandBase { + command: VMixCommand.SET_LAYER_PAN_Y + input: string | number + index: number + value: number +} +export interface VMixStateCommandSetLayerCrop extends VMixStateCommandBase { + command: VMixCommand.SET_LAYER_CROP + input: string | number + index: number + cropLeft: number + cropTop: number + cropRight: number + cropBottom: number +} export interface VMixStateCommandScriptStart extends VMixStateCommandBase { command: VMixCommand.SCRIPT_START value: string @@ -210,7 +237,11 @@ export type VMixStateCommand = | VMixStateCommandStopExternal | VMixStateCommandOverlayInputIn | VMixStateCommandOverlayInputOut - | VMixStateCommandSetInputOverlay + | VMixStateCommandSetLayerInput + | VMixStateCommandSetLayerZoom + | VMixStateCommandSetLayerPanX + | VMixStateCommandSetLayerPanY + | VMixStateCommandSetLayerCrop | VMixStateCommandScriptStart | VMixStateCommandScriptStop | VMixStateCommandScriptStopAll diff --git a/packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts index f34ba9624..71948d452 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/vMixStateDiffer.ts @@ -1,10 +1,11 @@ import { VMixCommand, - VMixInputOverlays, + VMixLayers, VMixInputType, VMixTransform, VMixTransition, VMixTransitionType, + VMixLayer, } from 'timeline-state-resolver-types' import { CommandContext, VMixStateCommandWithContext } from './vMixCommands' import _ = require('underscore') @@ -74,7 +75,7 @@ export interface VMixInput { duration?: number loop?: boolean transform?: VMixTransform - overlays?: VMixInputOverlays + layers?: VMixLayers listFilePaths?: string[] restart?: boolean } @@ -193,7 +194,7 @@ export class VMixStateDiffer { panY: 0, alpha: 255, }, - overlays: {}, + layers: {}, } } @@ -480,26 +481,88 @@ export class VMixStateDiffer { }) } } - 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]) { + if (input.layers !== undefined && !_.isEqual(oldInput.layers, input.layers)) { + for (const [indexString, layer] of Object.entries(input.layers as Record)) { + const index = Number(indexString) + const oldLayer = oldInput.layers?.[index] + if (layer.input !== oldLayer?.input) { commands.push({ command: { - command: VMixCommand.SET_INPUT_OVERLAY, + command: VMixCommand.SET_LAYER_INPUT, input: key, - value: input.overlays[Number(index)], - index: Number(index), + value: layer.input, + index, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if (layer.panX !== undefined && layer.panX !== oldLayer?.panX) { + commands.push({ + command: { + command: VMixCommand.SET_LAYER_PAN_X, + input: key, + value: layer.panX, + index, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if (layer.panY !== undefined && layer.panY !== oldLayer?.panY) { + commands.push({ + command: { + command: VMixCommand.SET_LAYER_PAN_Y, + input: key, + value: layer.panY, + index, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if (layer.zoom !== undefined && layer.zoom !== oldLayer?.zoom) { + commands.push({ + command: { + command: VMixCommand.SET_LAYER_ZOOM, + input: key, + value: layer.zoom, + index, + }, + context: CommandContext.None, + timelineId: '', + }) + } + if ( + (layer.cropLeft !== undefined || + layer.cropTop !== undefined || + layer.cropRight !== undefined || + layer.cropBottom !== undefined) && + (layer.cropLeft !== oldLayer?.cropLeft || + layer.cropTop !== oldLayer?.cropTop || + layer.cropRight !== oldLayer?.cropRight || + layer.cropBottom !== oldLayer?.cropBottom) + ) { + commands.push({ + command: { + command: VMixCommand.SET_LAYER_CROP, + input: key, + cropLeft: layer.cropLeft ?? 0, + cropTop: layer.cropTop ?? 0, + cropRight: layer.cropRight ?? 1, + cropBottom: layer.cropBottom ?? 1, + index, }, context: CommandContext.None, timelineId: '', }) } } - for (const index of Object.keys(oldInput.overlays ?? {})) { - if (!input.overlays?.[index]) { + for (const index of Object.keys(oldInput.layers ?? {})) { + if (!input.layers?.[index]) { commands.push({ command: { - command: VMixCommand.SET_INPUT_OVERLAY, + command: VMixCommand.SET_LAYER_INPUT, input: key, value: '', index: Number(index), @@ -858,11 +921,11 @@ export class VMixStateDiffer { const pgmInput = state.reportedState.existingInputs[mix.program] ?? (state.reportedState.inputsAddedByUs[mix.program] as VMixInput | undefined) - if (!pgmInput || !pgmInput.overlays) continue + if (!pgmInput || !pgmInput.layers) 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) { + for (const layer of Object.keys(pgmInput.layers)) { + const layerInput = pgmInput.layers[layer as unknown as keyof VMixLayers] + if (layerInput.input === input.name || layerInput.input === 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 diff --git a/packages/timeline-state-resolver/src/integrations/vmix/vMixStateSynchronizer.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixStateSynchronizer.ts index cb712b170..11e66b931 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/vMixStateSynchronizer.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/vMixStateSynchronizer.ts @@ -1,6 +1,6 @@ import { VMixInput, VMixState, VMixStateExtended } from './vMixStateDiffer' import { EnforceableVMixInputStateKeys } from '.' -import { VMixInputOverlays, VMixTransform } from 'timeline-state-resolver-types' +import { VMixInputOverlays, VMixLayers, VMixTransform } from 'timeline-state-resolver-types' /** * Applies selected properties from the real state to allow retrying to achieve the state @@ -37,16 +37,16 @@ export class VMixStateSynchronizer { 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, + layers: realInputs[inputKey].layers, // 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 - )) { + for (const [key, value] of Object.entries< + string | number | boolean | VMixTransform | VMixLayers | VMixInputOverlays + >(cherryPickedRealState)) { expectedInputs[inputKey][key] = value } } diff --git a/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts index b70c7a68f..baf9b787d 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/vMixTimelineStateConverter.ts @@ -7,6 +7,8 @@ import { TSRTimelineContent, Timeline, TimelineContentTypeVMix, + VMixInputOverlays, + VMixLayers, VMixTransition, VMixTransitionType, } from 'timeline-state-resolver-types' @@ -136,7 +138,9 @@ export class VMixTimelineStateConverter { loop: content.loop, position: content.seek, transform: content.transform, - overlays: content.overlays, + layers: + content.layers ?? + (content.overlays ? this._convertDeprecatedInputOverlays(content.overlays) : undefined), listFilePaths: content.listFilePaths, restart: content.restart, }, @@ -287,4 +291,8 @@ export class VMixTimelineStateConverter { } return state } + + private _convertDeprecatedInputOverlays(overlays: VMixInputOverlays): VMixLayers { + return _.mapObject(overlays, (value: number | string) => ({ input: value })) + } } diff --git a/packages/timeline-state-resolver/src/integrations/vmix/vMixXmlStateParser.ts b/packages/timeline-state-resolver/src/integrations/vmix/vMixXmlStateParser.ts index e204dc6bb..e5ea9790b 100644 --- a/packages/timeline-state-resolver/src/integrations/vmix/vMixXmlStateParser.ts +++ b/packages/timeline-state-resolver/src/integrations/vmix/vMixXmlStateParser.ts @@ -33,12 +33,22 @@ export class VMixXmlStateParser { fixedListFilePaths = this.ensureArray(input['list']['item']).map((item) => item['_text']) } - const overlays: VMixInput['overlays'] = {} + const layers: VMixInput['layers'] = {} if (input['overlay'] != null) { - this.ensureArray(input['overlay']).forEach( - (item) => - (overlays[parseInt(item['_attributes']['index'], 10) + 1] = inputKeysToNumbers[item['_attributes']['key']]) - ) + this.ensureArray(input['overlay']).forEach((item) => { + const position = item['position']?.['_attributes'] + const crop = item['crop']?.['_attributes'] + layers[parseInt(item['_attributes']['index'], 10) + 1] = { + input: inputKeysToNumbers[item['_attributes']['key']], + zoom: Number(position?.['zoomX'] ?? 1), // assume that zoomX==zoomY because we can't control both + panX: Number(position?.['panX'] ?? 0), + panY: Number(position?.['panY'] ?? 0), + cropLeft: Number(crop?.['X1'] ?? 0), + cropTop: Number(crop?.['Y1'] ?? 0), + cropRight: Number(crop?.['X2'] ?? 1), + cropBottom: Number(crop?.['Y2'] ?? 1), + } + }) } const result: VMixInput = { @@ -52,12 +62,12 @@ export class VMixXmlStateParser { 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), + panX: Number(input['position']?.['_attributes']['panX'] ?? 0), + panY: Number(input['position']?.['_attributes']['panY'] ?? 0), alpha: -1, // unavailable - zoom: Number(input['position'] ? input['position']['_attributes']['zoomX'] || 1 : 1), // assume that zoomX==zoomY + zoom: Number(input['position']?.['_attributes']['zoomX'] ?? 1), // assume that zoomX==zoomY }, - overlays, + layers, listFilePaths: fixedListFilePaths!, } diff --git a/yarn.lock b/yarn.lock index 351f69633..51da11560 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6832,6 +6832,18 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"jest-mock-extended@npm:^3.0.7": + version: 3.0.7 + resolution: "jest-mock-extended@npm:3.0.7" + dependencies: + ts-essentials: ^10.0.0 + peerDependencies: + jest: ^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0 + typescript: ^3.0.0 || ^4.0.0 || ^5.0.0 + checksum: 59ab510934b0b66e0752c170b6e069f8c93a5b9de40ea2bd3e734f773a70be4b0c251451f8770e60c1c3754d5ddbd25dd1f55568a6379f396d109694d6d3ab79 + languageName: node + linkType: hard + "jest-mock@npm:^29.7.0": version: 29.7.0 resolution: "jest-mock@npm:29.7.0" @@ -11240,6 +11252,7 @@ asn1@evs-broadcast/node-asn1: hyperdeck-connection: 2.0.0 i18next-conv: ^13.1.1 i18next-parser: ^6.6.0 + jest-mock-extended: ^3.0.7 json-schema-ref-parser: ^9.0.9 json-schema-to-typescript: ^10.1.5 klona: ^2.0.6 @@ -11386,6 +11399,18 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"ts-essentials@npm:^10.0.0": + version: 10.0.1 + resolution: "ts-essentials@npm:10.0.1" + peerDependencies: + typescript: ">=4.5.0" + peerDependenciesMeta: + typescript: + optional: true + checksum: f70583c154b8f0bbed1f687b77dfe2fb18ccf5870ca6789077f817e64091c3c3c1d97e5098f4fdaac888a3320456416d2110e904c70aad3da2834e0572e5602f + languageName: node + linkType: hard + "ts-jest@npm:^29.1.2": version: 29.1.2 resolution: "ts-jest@npm:29.1.2"