From 2d08c7c14a53fa5c32b50e829f233c9f2a20654c Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Thu, 27 Jun 2024 10:41:46 +0100 Subject: [PATCH] feat: refactor singular-live device SOFIE-2492 (#337) --- .../timeline-state-resolver/src/conductor.ts | 14 +- .../__tests__/singularLive.spec.ts | 123 ++++---- .../src/integrations/singularLive/index.ts | 285 +++++++----------- .../src/service/devices.ts | 8 + 4 files changed, 166 insertions(+), 264 deletions(-) diff --git a/packages/timeline-state-resolver/src/conductor.ts b/packages/timeline-state-resolver/src/conductor.ts index b8e97d779..0be3bdd07 100644 --- a/packages/timeline-state-resolver/src/conductor.ts +++ b/packages/timeline-state-resolver/src/conductor.ts @@ -36,6 +36,7 @@ import { DeviceOptionsSofieChef, DeviceOptionsPharos, DeviceOptionsTriCaster, + DeviceOptionsSingularLive, } from 'timeline-state-resolver-types' import { DoOnTime } from './devices/doOnTime' @@ -47,7 +48,6 @@ import { DeviceContainer } from './devices/deviceContainer' import { CasparCGDevice, DeviceOptionsCasparCGInternal } from './integrations/casparCG' import { SisyfosMessageDevice, DeviceOptionsSisyfosInternal } from './integrations/sisyfos' -import { SingularLiveDevice, DeviceOptionsSingularLiveInternal } from './integrations/singularLive' import { VMixDevice, DeviceOptionsVMixInternal } from './integrations/vmix' import { VizMSEDevice, DeviceOptionsVizMSEInternal } from './integrations/vizMSE' import { DeviceOptionsMultiOSCInternal, MultiOSCMessageDevice } from './integrations/multiOsc' @@ -527,15 +527,6 @@ export class Conductor extends EventEmitter { getCurrentTime, threadedClassOptions ) - case DeviceType.SINGULAR_LIVE: - return DeviceContainer.create( - '../../dist/integrations/singularLive/index.js', - 'SingularLiveDevice', - deviceId, - deviceOptions, - getCurrentTime, - threadedClassOptions - ) case DeviceType.VMIX: return DeviceContainer.create( '../../dist/integrations/vmix/index.js', @@ -565,6 +556,7 @@ export class Conductor extends EventEmitter { case DeviceType.PANASONIC_PTZ: case DeviceType.PHAROS: case DeviceType.SHOTOKU: + case DeviceType.SINGULAR_LIVE: case DeviceType.SOFIE_CHEF: case DeviceType.TCPSEND: case DeviceType.TELEMETRICS: @@ -1487,7 +1479,7 @@ export type DeviceOptionsAnyInternal = | DeviceOptionsSisyfosInternal | DeviceOptionsSofieChef | DeviceOptionsQuantel - | DeviceOptionsSingularLiveInternal + | DeviceOptionsSingularLive | DeviceOptionsVMixInternal | DeviceOptionsShotoku | DeviceOptionsVizMSEInternal diff --git a/packages/timeline-state-resolver/src/integrations/singularLive/__tests__/singularLive.spec.ts b/packages/timeline-state-resolver/src/integrations/singularLive/__tests__/singularLive.spec.ts index 27c46970a..258324956 100644 --- a/packages/timeline-state-resolver/src/integrations/singularLive/__tests__/singularLive.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/singularLive/__tests__/singularLive.spec.ts @@ -1,5 +1,4 @@ -import { Conductor } from '../../../conductor' -import { SingularLiveDevice } from '..' +import { SingularLiveControlNodeCommandContent, SingularLiveDevice } from '..' import { SomeMappingSingularLive, Mapping, @@ -8,21 +7,11 @@ import { TimelineContentTypeSingularLive, MappingSingularLiveType, } from 'timeline-state-resolver-types' -import { MockTime } from '../../../__tests__/mockTime' -import { ThreadedClass } from 'threadedclass' -import { getMockCall } from '../../../__tests__/lib' +import { getDeviceContext } from '../../__tests__/testlib' +import { makeTimelineObjectResolved } from '../../../__mocks__/objects' -// let nowActual = Date.now() describe('Singular.Live', () => { - const mockTime = new MockTime() - beforeEach(() => { - mockTime.init() - }) - test('POST message', async () => { - const commandReceiver0: any = jest.fn(async () => { - return Promise.resolve() - }) const myLayerMapping0: Mapping = { device: DeviceType.SINGULAR_LIVE, deviceId: 'mySingular', @@ -35,68 +24,62 @@ describe('Singular.Live', () => { myLayer0: myLayerMapping0, } - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) - await myConductor.init() - await myConductor.addDevice('mySingular', { - type: DeviceType.SINGULAR_LIVE, - options: { - accessToken: 'DUMMY_TOKEN', - }, - commandReceiver: commandReceiver0, - }) - myConductor.setTimelineAndMappings([], myLayerMapping) - await mockTime.advanceTimeToTicks(10100) - - const deviceContainer = myConductor.getDevice('mySingular') - const device = deviceContainer!.device as ThreadedClass + const device = new SingularLiveDevice(getDeviceContext()) - // Check that no commands has been scheduled: - expect(await device.queue).toHaveLength(0) - - myConductor.setTimelineAndMappings([ + const deviceState = device.convertTimelineStateToDeviceState( { - id: 'obj0', - enable: { - start: mockTime.now + 1000, // in 1 second - duration: 2000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.SINGULAR_LIVE, - type: TimelineContentTypeSingularLive.COMPOSITION, - controlNode: { - state: 'In', - payload: { - Name: 'Thomas', - Title: 'Foreperson', + time: 1000, + layers: { + myLayer0: makeTimelineObjectResolved({ + id: 'obj0', + enable: { + start: 1000, + duration: 2000, }, - }, + layer: 'myLayer0', + content: { + deviceType: DeviceType.SINGULAR_LIVE, + type: TimelineContentTypeSingularLive.COMPOSITION, + controlNode: { + state: 'In', + payload: { + Name: 'Thomas', + Title: 'Foreperson', + }, + }, + }, + }), }, + nextEvents: [], }, - ]) - await mockTime.advanceTimeToTicks(10990) - expect(commandReceiver0).toHaveBeenCalledTimes(0) - await mockTime.advanceTimeToTicks(11100) + myLayerMapping + ) - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(commandReceiver0).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - subCompositionName: 'Lower Third', - state: 'In', - payload: { - Name: 'Thomas', - Title: 'Foreperson', - }, - }), - expect.anything(), - expect.stringContaining('obj0') + const commands = device.diffStates(undefined, deviceState, myLayerMapping, 1000) + expect(commands).toHaveLength(1) + expect(commands[0].command.content).toEqual({ + subCompositionName: 'Lower Third', + state: 'In', + payload: { + Name: 'Thomas', + Title: 'Foreperson', + }, + }) + + const deviceState2 = device.convertTimelineStateToDeviceState( + { + time: 2000, + layers: {}, + nextEvents: [], + }, + myLayerMapping ) - expect(getMockCall(commandReceiver0, 0, 2)).toMatch(/added/) // context - await mockTime.advanceTimeToTicks(16000) - expect(commandReceiver0).toHaveBeenCalledTimes(2) + + const commands2 = device.diffStates(deviceState, deviceState2, myLayerMapping, 2000) + expect(commands2).toHaveLength(1) + expect(commands2[0].command.content).toEqual({ + subCompositionName: 'Lower Third', + state: 'Out', + }) }) }) diff --git a/packages/timeline-state-resolver/src/integrations/singularLive/index.ts b/packages/timeline-state-resolver/src/integrations/singularLive/index.ts index 3a2a1688a..7fbb2ef3a 100644 --- a/packages/timeline-state-resolver/src/integrations/singularLive/index.ts +++ b/packages/timeline-state-resolver/src/integrations/singularLive/index.ts @@ -1,30 +1,21 @@ import * as _ from 'underscore' -import { DeviceWithState, CommandWithContext, DeviceStatus, StatusCode } from './../../devices/device' import { DeviceType, SingularLiveOptions, TimelineContentTypeSingularLive, SomeMappingSingularLive, - DeviceOptionsSingularLive, SingularCompositionControlNode, Mappings, TSRTimelineContent, Timeline, Mapping, + DeviceStatus, + StatusCode, + ActionExecutionResult, } from 'timeline-state-resolver-types' -import { DoOnTime, SendMode } from '../../devices/doOnTime' import got from 'got' import { literal } from '../../lib' - -export interface DeviceOptionsSingularLiveInternal extends DeviceOptionsSingularLive { - commandReceiver?: CommandReceiver -} -export type CommandReceiver = ( - time: number, - cmd: SingularLiveCommandContent, - context: CommandContext, - timelineObjId: string -) => Promise +import { CommandWithContext, Device } from '../../service/device' export interface SingularLiveControlNodeCommandContent extends SingularLiveCommandContent { state?: string @@ -35,14 +26,17 @@ export interface SingularLiveCommandContent { subCompositionName: string } +export interface SingularLiveCommandContext { + command: Command + context: string + timelineObjId: string +} + interface Command { commandName: 'added' | 'changed' | 'removed' - content: SingularLiveCommandContent - context: CommandContext - timelineObjId: string + content: SingularLiveCommandContent | SingularLiveControlNodeCommandContent layer: string } -export type CommandContext = string export interface SingularComposition { timelineObjId: string @@ -60,107 +54,50 @@ const SINGULAR_LIVE_API = 'https://app.singular.live/apiv2/controlapps/' /** * This is a Singular.Live device, it talks to a Singular.Live App Instance using an Access Token */ -export class SingularLiveDevice extends DeviceWithState { - // private _makeReadyCommands: SingularLiveCommandContent[] - private _accessToken: string | undefined - private _doOnTime: DoOnTime - private _deviceStatus: DeviceStatus = { - statusCode: StatusCode.GOOD, - messages: [], - active: this.isActive, - } +export class SingularLiveDevice extends Device { + readonly actions: { + [id: string]: (id: string, payload?: Record) => Promise + } = {} - private _commandReceiver: CommandReceiver = this._defaultCommandReceiver.bind(this) + private _accessToken: string | undefined - constructor( - deviceId: string, - deviceOptions: DeviceOptionsSingularLiveInternal, - getCurrentTime: () => Promise - ) { - super(deviceId, deviceOptions, getCurrentTime) - if (deviceOptions.options) { - if (deviceOptions.commandReceiver) this._commandReceiver = deviceOptions.commandReceiver - } - this._doOnTime = new DoOnTime( - () => { - return this.getCurrentTime() - }, - SendMode.IN_ORDER, - this._deviceOptions - ) - this.handleDoOnTime(this._doOnTime, 'SingularLive') - } async init(initOptions: SingularLiveOptions): Promise { - // this._makeReadyCommands = options.makeReadyCommands || [] this._accessToken = initOptions.accessToken || '' if (!this._accessToken) throw new Error('Singular.Live bad connection option: accessToken. An accessToken is required.') - return Promise.resolve(true) // This device doesn't have any initialization procedure + return true // This device doesn't have any initialization procedure } - /** Called by the Conductor a bit before a .handleState is called */ - prepareForHandleState(newStateTime: number) { - // clear any queued commands later than this time: - this._doOnTime.clearQueueNowAndAfter(newStateTime) - this.cleanUpStates(0, newStateTime) - } - handleState(newState: Timeline.TimelineState, newMappings: Mappings) { - super.onHandleState(newState, newMappings) - // Handle this new state, at the point in time specified - - const previousStateTime = Math.max(this.getCurrentTime(), newState.time) - const oldSingularState: SingularLiveState = ( - this.getStateBefore(previousStateTime) || { state: { compositions: {} } } - ).state - - const newSingularState = this.convertStateToSingularLive(newState, newMappings) - - const commandsToAchieveState: Array = this._diffStates(oldSingularState, newSingularState) - // clear any queued commands later than this time: - this._doOnTime.clearQueueNowAndAfter(previousStateTime) - // add the new commands to the queue: - this._addToQueue(commandsToAchieveState, newState.time) + // TODO - // store the new state, for later use: - this.setState(newSingularState, newState.time) - } - clearFuture(clearAfterTime: number) { - // Clear any scheduled commands after this time - this._doOnTime.clearQueueAfter(clearAfterTime) - } async terminate() { - this._doOnTime.dispose() - } - getStatus(): DeviceStatus { - // Good, since this device has no status, really - return this._deviceStatus - } - async makeReady(_okToDestroyStuff?: boolean): Promise { - // if (okToDestroyStuff && this._makeReadyCommands && this._makeReadyCommands.length > 0) { - // const time = this.getCurrentTime() - // _.each(this._makeReadyCommands, (cmd: SingularLiveCommandContent) => { - // // add the new commands to the queue: - // this._doOnTime.queue(time, undefined, (cmd: SingularLiveCommandContent) => { - // return this._commandReceiver(time, cmd, 'makeReady', '') - // }, cmd) - // }) - // } + // Nothing to do } - get canConnect(): boolean { - return false + getStatus(): Omit { + // Good, since this device has no status, really + return { + statusCode: StatusCode.GOOD, + messages: [], + } } get connected(): boolean { + // Doesn't support connection status return false } + private _getDefaultState(): SingularLiveState { return { compositions: {}, } } - convertStateToSingularLive(state: Timeline.TimelineState, newMappings: Mappings) { + + convertTimelineStateToDeviceState( + state: Timeline.TimelineState, + newMappings: Mappings + ): SingularLiveState { // convert the timeline state into something we can use // (won't even use this.mapping) const singularState: SingularLiveState = this._getDefaultState() @@ -170,7 +107,6 @@ export class SingularLiveDevice extends DeviceWithState, time: number) { - _.each(commandsToAchieveState, (cmd: Command) => { - // add the new commands to the queue: - this._doOnTime.queue( - time, - undefined, - async (cmd: Command) => { - return this._commandReceiver(time, cmd.content, cmd.context, cmd.timelineObjId) - }, - cmd - ) - }) - } + /** * Compares the new timeline-state with the old one, and generates commands to account for the difference */ - private _diffStates( - oldSingularLiveState: SingularLiveState, - newSingularLiveState: SingularLiveState - ): Array { - const commands: Array = [] + diffStates( + oldSingularLiveState: SingularLiveState | undefined, + newSingularLiveState: SingularLiveState, + _mappings: Mappings, + _time: number + ): SingularLiveCommandContext[] { + const commands: Array = [] _.each(newSingularLiveState.compositions, (composition: SingularComposition, compositionName: string) => { - const oldComposition = oldSingularLiveState.compositions[compositionName] + const oldComposition = oldSingularLiveState?.compositions?.[compositionName] if (!oldComposition) { // added! commands.push({ timelineObjId: composition.timelineObjId, - commandName: 'added', - content: literal({ - subCompositionName: compositionName, - state: composition.controlNode.state, - payload: composition.controlNode.payload, - }), context: `added: ${composition.timelineObjId}`, - layer: compositionName, + command: { + commandName: 'added', + content: literal({ + subCompositionName: compositionName, + state: composition.controlNode.state, + payload: composition.controlNode.payload, + }), + layer: compositionName, + }, }) } else { // changed? @@ -241,83 +157,86 @@ export class SingularLiveDevice extends DeviceWithState({ - subCompositionName: compositionName, - state: composition.controlNode.state, - payload: composition.controlNode.payload, - }), context: `changed: ${composition.timelineObjId} (previously: ${oldComposition.timelineObjId})`, - layer: compositionName, + command: { + commandName: 'changed', + content: literal({ + subCompositionName: compositionName, + state: composition.controlNode.state, + payload: composition.controlNode.payload, + }), + layer: compositionName, + }, }) } } }) // removed - _.each(oldSingularLiveState.compositions, (composition: SingularComposition, compositionName) => { - const newComposition = newSingularLiveState.compositions[compositionName] - if (!newComposition) { - // removed! - commands.push({ - timelineObjId: composition.timelineObjId, - commandName: 'removed', - content: literal({ - subCompositionName: compositionName, - state: 'Out', - }), - context: `removed: ${composition.timelineObjId}`, - layer: compositionName, - }) - } - }) + if (oldSingularLiveState) { + _.each(oldSingularLiveState.compositions, (composition: SingularComposition, compositionName) => { + const newComposition = newSingularLiveState.compositions[compositionName] + if (!newComposition) { + // removed! + commands.push({ + timelineObjId: composition.timelineObjId, + context: `removed: ${composition.timelineObjId}`, + command: { + commandName: 'removed', + content: literal({ + subCompositionName: compositionName, + state: 'Out', + }), + layer: compositionName, + }, + }) + } + }) + } + return commands .sort((a, b) => - (a.content as any).state && !(b.content as any).state + (a.command.content as any).state && !(b.command.content as any).state ? 1 - : !(a.content as any).state && (b.content as any).state + : !(a.command.content as any).state && (b.command.content as any).state ? -1 : 0 ) - .sort((a, b) => a.layer.localeCompare(b.layer)) + .sort((a, b) => a.command.layer.localeCompare(b.command.layer)) } - private async _defaultCommandReceiver( - _time: number, - cmd: SingularLiveCommandContent, - context: CommandContext, - timelineObjId: string - ): Promise { + + async sendCommand({ command, context, timelineObjId }: SingularLiveCommandContext): Promise { const cwc: CommandWithContext = { context, - command: cmd, + command, timelineObjId, } - this.emitDebug(cwc) + this.context.logger.debug(cwc) const url = SINGULAR_LIVE_API + this._accessToken + '/control' - return new Promise((resolve, reject) => { - got - .patch(url, { json: [cmd] }) + try { + await got + .patch(url, { json: [command.content] }) .then((response) => { if (response.statusCode === 200) { - this.emitDebug( - `SingularLive: ${cmd.subCompositionName}: Good statuscode response on url "${url}": ${response.statusCode} (${context})` + this.context.logger.debug( + `SingularLive: ${command.content.subCompositionName}: Good statuscode response on url "${url}": ${response.statusCode} (${context})` ) - resolve() } else { - this.emit( - 'warning', - `SingularLive: ${cmd.subCompositionName}: Bad statuscode response on url "${url}": ${response.statusCode} (${context})` + this.context.logger.warning( + `SingularLive: ${command.content.subCompositionName}: Bad statuscode response on url "${url}": ${response.statusCode} (${context})` ) - resolve() } }) .catch((error) => { - this.emit('error', `SingularLive.response error ${cmd.subCompositionName} (${context}`, error) - reject(error) + this.context.logger.error( + `SingularLive.response error ${command.content.subCompositionName} (${context}`, + error + ) + throw error }) - }).catch((error) => { - this.emit('commandError', error, cwc) - }) + } catch (error: any) { + this.context.commandError(error, cwc) + } } } diff --git a/packages/timeline-state-resolver/src/service/devices.ts b/packages/timeline-state-resolver/src/service/devices.ts index 5de2a3e75..19c07b6ad 100644 --- a/packages/timeline-state-resolver/src/service/devices.ts +++ b/packages/timeline-state-resolver/src/service/devices.ts @@ -16,6 +16,7 @@ import { SofieChefDevice } from '../integrations/sofieChef' import { PharosDevice } from '../integrations/pharos' import { TelemetricsDevice } from '../integrations/telemetrics' import { TriCasterDevice } from '../integrations/tricaster' +import { SingularLiveDevice } from '../integrations/singularLive' export interface DeviceEntry { deviceClass: new (context: DeviceContextAPI) => Device @@ -36,6 +37,7 @@ export type ImplementedServiceDeviceTypes = | DeviceType.PANASONIC_PTZ | DeviceType.PHAROS | DeviceType.SHOTOKU + | DeviceType.SINGULAR_LIVE | DeviceType.SOFIE_CHEF | DeviceType.TCPSEND | DeviceType.TELEMETRICS @@ -116,6 +118,12 @@ export const DevicesDict: Record = { deviceName: (deviceId: string) => 'SHOTOKU' + deviceId, executionMode: () => 'salvo', }, + [DeviceType.SINGULAR_LIVE]: { + deviceClass: SingularLiveDevice, + canConnect: false, + deviceName: (deviceId: string) => 'Singular.Live ' + deviceId, + executionMode: () => 'sequential', + }, [DeviceType.TCPSEND]: { deviceClass: TcpSendDevice, canConnect: true,