From 7f6e619e1284c3ff7e3cf9ad21e578018e19edd0 Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Fri, 8 Sep 2023 15:41:40 +0200 Subject: [PATCH 01/14] feat: convert quantel to state handler --- .../timeline-state-resolver/src/conductor.ts | 16 +- .../quantel/__tests__/quantel.spec.ts | 2696 +++++------------ .../src/integrations/quantel/diff.ts | 255 ++ .../src/integrations/quantel/index.ts | 697 +---- .../src/integrations/quantel/state.ts | 150 + .../src/service/devices.ts | 8 + 6 files changed, 1261 insertions(+), 2561 deletions(-) create mode 100644 packages/timeline-state-resolver/src/integrations/quantel/diff.ts create mode 100644 packages/timeline-state-resolver/src/integrations/quantel/state.ts diff --git a/packages/timeline-state-resolver/src/conductor.ts b/packages/timeline-state-resolver/src/conductor.ts index fc414c59b..9e1c6a8d3 100644 --- a/packages/timeline-state-resolver/src/conductor.ts +++ b/packages/timeline-state-resolver/src/conductor.ts @@ -28,6 +28,7 @@ import { DeviceOptionsAbstract, DeviceOptionsAtem, DeviceOptionsTCPSend, + DeviceOptionsQuantel, } from 'timeline-state-resolver-types' import { DoOnTime } from './devices/doOnTime' @@ -42,7 +43,6 @@ import { LawoDevice, DeviceOptionsLawoInternal } from './integrations/lawo' import { PanasonicPtzDevice, DeviceOptionsPanasonicPTZInternal } from './integrations/panasonicPTZ' import { HyperdeckDevice, DeviceOptionsHyperdeckInternal } from './integrations/hyperdeck' import { PharosDevice, DeviceOptionsPharosInternal } from './integrations/pharos' -import { QuantelDevice, DeviceOptionsQuantelInternal } from './integrations/quantel' import { SisyfosMessageDevice, DeviceOptionsSisyfosInternal } from './integrations/sisyfos' import { SingularLiveDevice, DeviceOptionsSingularLiveInternal } from './integrations/singularLive' import { VMixDevice, DeviceOptionsVMixInternal } from './integrations/vmix' @@ -546,15 +546,6 @@ export class Conductor extends EventEmitter { getCurrentTime, threadedClassOptions ) - case DeviceType.QUANTEL: - return DeviceContainer.create( - '../../dist/integrations/quantel/index.js', - 'QuantelDevice', - deviceId, - deviceOptions, - getCurrentTime, - threadedClassOptions - ) case DeviceType.SISYFOS: return DeviceContainer.create( '../../dist/integrations/sisyfos/index.js', @@ -642,7 +633,8 @@ export class Conductor extends EventEmitter { case DeviceType.HTTPWATCHER: case DeviceType.OSC: case DeviceType.SHOTOKU: - case DeviceType.TCPSEND: { + case DeviceType.TCPSEND: + case DeviceType.QUANTEL: { ensureIsImplementedAsService(deviceOptions.type) // presumably this device is implemented in the new service handler @@ -1559,7 +1551,7 @@ export type DeviceOptionsAnyInternal = | DeviceOptionsMultiOSCInternal | DeviceOptionsSisyfosInternal | DeviceOptionsSofieChefInternal - | DeviceOptionsQuantelInternal + | DeviceOptionsQuantel | DeviceOptionsSingularLiveInternal | DeviceOptionsVMixInternal | DeviceOptionsShotoku diff --git a/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantel.spec.ts b/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantel.spec.ts index eb362d7f4..9de92cac6 100644 --- a/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantel.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantel.spec.ts @@ -1,2002 +1,798 @@ -import { setupQuantelGatewayMock } from './quantelGatewayMock' -import { Conductor } from '../../../conductor' +/* eslint-disable jest/expect-expect */ import { - Mappings, DeviceType, - Mapping, - SomeMappingQuantel, - QuantelTransitionType, MappingQuantelType, + Mappings, + QuantelControlMode, + SomeMappingQuantel, + Timeline, + TimelineContentQuantelAny, + TSRTimelineContent, } from 'timeline-state-resolver-types' +import { QuantelCommandWithContext, QuantelDevice } from '..' +import { QuantelCommandType, QuantelState } from '../types' +import { setupQuantelGatewayMock } from './quantelGatewayMock' import { MockTime } from '../../../__tests__/mockTime' -import { ThreadedClass } from 'threadedclass' -import { QuantelDevice } from '..' -import { QuantelCommandType } from '../types' -import '../../../__tests__/lib' -const orgSetTimeout = setTimeout - -async function t(p: Promise, mockTime, advanceTime = 50): Promise { - orgSetTimeout(() => { - mockTime.advanceTimeTicks(advanceTime) - }, 1) - return p +async function getInitialisedQuantelDevice(clearMock?: jest.Mock) { + const dev = new QuantelDevice() + await dev.init({ + gatewayUrl: 'localhost:3000', + ISAUrlMaster: 'myISA:8000', + zoneId: undefined, // fallback to 'default' + serverId: 1100, + }) + if (clearMock) { + clearMock.mockClear() + } + return dev } -/** Accepted deviance, accepted deviance in command timing during testing */ -const ADEV = 30 +describe('Quantel Device', () => { + const { onRequest } = setupQuantelGatewayMock() -describe('Quantel', () => { - const { quantelServer, onRequest } = setupQuantelGatewayMock() - - function clearMocks() { + beforeEach(() => { onRequest.mockClear() - } - async function setupDefaultQuantelDeviceForTest() { - let device: any = undefined - const commandReceiver0: any = jest.fn((...args) => { - // pipe through the command - return device._defaultCommandReceiver(...args) - }) - - const myLayerMapping0: Mapping = { - device: DeviceType.QUANTEL, - deviceId: 'myQuantel', - - options: { - mappingType: MappingQuantelType.Port, - portId: 'my_port', - channelId: 2, - // keyChannelID: number - // mode?: QuantelControlMode - }, - } - const myLayerMapping: Mappings = { - myLayer0: myLayerMapping0, - } - - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) - const errorHandler = jest.fn((...args) => console.log('Error in device', ...args)) - myConductor.on('error', errorHandler) - await myConductor.init() - - await t( - myConductor.addDevice('myQuantel', { - type: DeviceType.QUANTEL, - options: { - // host: '127.0.0.1' - - gatewayUrl: 'localhost:3000', - ISAUrlMaster: 'myISA:8000', - zoneId: undefined, // fallback to 'default' - serverId: 1100, - }, - commandReceiver: commandReceiver0, - }), - mockTime - ) - - const deviceContainer = myConductor.getDevice('myQuantel') - device = deviceContainer!.device as ThreadedClass - const deviceErrorHandler = jest.fn((...args) => console.log('Error in device', ...args)) - device.on('error', deviceErrorHandler) - device.on('commandError', deviceErrorHandler) - - myConductor.setTimelineAndMappings([], myLayerMapping) - - expect(mockTime.getCurrentTime()).toEqual(10000) - await mockTime.advanceTimeToTicks(10100) - - expect(commandReceiver0).toHaveBeenCalledTimes(2) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - expect.toBeCloseTo(10000, ADEV), - expect.objectContaining({ - type: QuantelCommandType.SETUPPORT, - time: 10005, // Because it was so close to currentTime, otherwise 9000 - }), - expect.any(String), - expect.any(String) - ) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 2, - expect.toBeCloseTo(10000, ADEV), - expect.objectContaining({ - type: QuantelCommandType.CLEARCLIP, - time: 10005, - }), - expect.any(String), - expect.any(String) - ) - - // Connect to ISA - expect(onRequest).toHaveBeenNthCalledWith(1, 'post', 'http://localhost:3000/connect/myISA%3A8000') - // get initial server info - expect(onRequest).toHaveBeenNthCalledWith(2, 'get', 'http://localhost:3000/default/server') - - // Set up port: - // get server info - expect(onRequest).toHaveBeenNthCalledWith(3, 'get', 'http://localhost:3000/default/server') - // get port info - expect(onRequest).toHaveBeenNthCalledWith(4, 'get', 'http://localhost:3000/default/server/1100/port/my_port') - // create new port and assign to channel - expect(onRequest).toHaveBeenNthCalledWith( - 5, - 'put', - 'http://localhost:3000/default/server/1100/port/my_port/channel/2' - ) - // Reset the port - expect(onRequest).toHaveBeenNthCalledWith(6, 'post', 'http://localhost:3000/default/server/1100/port/my_port/reset') - - clearMocks() - commandReceiver0.mockClear() + }) - // Check that no commands has been scheduled: - expect(await device.queue).toHaveLength(0) + describe('convertTimelineStateToDeviceState', () => { + async function compareState( + tlState: Timeline.TimelineState, + mappings: Mappings, + expDevState: QuantelState + ) { + const device = await getInitialisedQuantelDevice() - quantelServer.ignoreConnectivityCheck = true + const actualState = device.convertTimelineStateToDeviceState(tlState, mappings) - return { - commandReceiver0, - myConductor, - errorHandler, - deviceErrorHandler, - device, + expect(actualState).toEqual(expDevState) } - } - - const mockTime = new MockTime() - beforeEach(() => { - mockTime.init() - clearMocks() - quantelServer.ignoreConnectivityCheck = false - quantelServer.ports = {} - }) - test('Play and stop', async () => { - const { commandReceiver0, myConductor, errorHandler, deviceErrorHandler } = await setupDefaultQuantelDeviceForTest() - - myConductor.setTimelineAndMappings([ - { - id: 'video0', - enable: { - start: 11000, - duration: 5000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.QUANTEL, - - title: 'myClip0', - - // seek?: number - // inPoint?: number - // length?: number - // pauseTime?: number - // playing?: boolean - // noStarttime?: boolean - }, - }, - ]) - // Time to preload the clip - await mockTime.advanceTimeToTicks(10990) + test('convert empty state', async () => { + await compareState(createTimelineState({}), {}, { time: 10, port: {} }) + }) - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 10155, - expect.objectContaining({ - type: QuantelCommandType.LOADCLIPFRAGMENTS, - clip: expect.objectContaining({ - title: 'myClip0', + test('convert 1 layer', async () => { + await compareState( + createTimelineState({ + layer0: { + id: 'obj0', + content: { + deviceType: DeviceType.QUANTEL, + + title: 'myClip0', + }, + instance: { + originalStart: 10, + }, + }, }), - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(6) - - // Search for and get clip info: - expect(onRequest).toHaveBeenNthCalledWith(1, 'get', expect.stringContaining('/default/clip?Title=%22myClip0%22')) - expect(onRequest).toHaveBeenNthCalledWith(2, 'get', expect.stringContaining('/default/clip/1337')) - // Fetch fragments: - expect(onRequest).toHaveBeenNthCalledWith(3, 'get', expect.stringContaining('clip/1337/fragments')) - // get port info - expect(onRequest).toHaveBeenNthCalledWith(4, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Load fragments - expect(onRequest).toHaveBeenNthCalledWith(5, 'post', expect.stringContaining('port/my_port/fragments')) - // Prepare jump: - expect(onRequest).toHaveBeenNthCalledWith(6, 'put', expect.stringContaining('port/my_port/jump?offset=0')) - - clearMocks() - commandReceiver0.mockClear() - - // Time to start playing - await mockTime.advanceTimeToTicks(11300) - - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 11000, - expect.objectContaining({ - type: QuantelCommandType.PLAYCLIP, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(3) - - // Trigger Jump - // expect(onRequest).toHaveBeenNthCalledWith(1, 'post', expect.stringContaining('/default/server/1100/port/my_port/trigger/JUMP')) - // Trigger play - expect(onRequest).toHaveBeenNthCalledWith( - 1, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/START') - ) - // Check that play worked - expect(onRequest).toHaveBeenNthCalledWith(2, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Plan to stop at end of clip - expect(onRequest).toHaveBeenNthCalledWith( - 3, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/STOP?offset=1999') - ) - - clearMocks() - commandReceiver0.mockClear() - - // Time to stop playing - await mockTime.advanceTimeToTicks(16050) - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 16000, - expect.objectContaining({ - type: QuantelCommandType.CLEARCLIP, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(1) - // Clear port from clip (reset port) - expect(onRequest).toHaveBeenNthCalledWith(1, 'post', expect.stringContaining('port/my_port/reset')) - - await myConductor.destroy() - - expect(errorHandler).toHaveBeenCalledTimes(0) - expect(deviceErrorHandler).toHaveBeenCalledTimes(0) - }) - test('Play and stop, using clip guid', async () => { - const { commandReceiver0, myConductor, errorHandler, deviceErrorHandler } = await setupDefaultQuantelDeviceForTest() - - myConductor.setTimelineAndMappings([ - { - id: 'video0', - enable: { - start: 11000, - duration: 5000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.QUANTEL, - - guid: 'abcdef872832832a2b932c97d9b2eb9', - - // seek?: number - // inPoint?: number - // length?: number - // pauseTime?: number - // playing?: boolean - // noStarttime?: boolean - }, - }, - ]) - // Time to preload the clip - await mockTime.advanceTimeToTicks(10990) - - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 10155, - expect.objectContaining({ - type: QuantelCommandType.LOADCLIPFRAGMENTS, - }), - expect.any(String), - expect.any(String) - ) - - // expect(onRequest).toHaveBeenCalledTimes(6) - - // Search for and get clip info: - expect(onRequest).toHaveBeenNthCalledWith( - 1, - 'get', - expect.stringContaining('/default/clip?ClipGUID=%22abcdef872832832a2b932c97d9b2eb9%22') - ) - expect(onRequest).toHaveBeenNthCalledWith(2, 'get', expect.stringContaining('/default/clip/1337')) - // Fetch fragments: - expect(onRequest).toHaveBeenNthCalledWith(3, 'get', expect.stringContaining('clip/1337/fragments')) - // get port info - expect(onRequest).toHaveBeenNthCalledWith(4, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Load fragments - expect(onRequest).toHaveBeenNthCalledWith(5, 'post', expect.stringContaining('port/my_port/fragments')) - // Prepare jump: - expect(onRequest).toHaveBeenNthCalledWith(6, 'put', expect.stringContaining('port/my_port/jump?offset=')) - - clearMocks() - commandReceiver0.mockClear() - - // Time to start playing - await mockTime.advanceTimeToTicks(11300) - - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 11000, - expect.objectContaining({ - type: QuantelCommandType.PLAYCLIP, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(3) - - // Trigger Jump - // expect(onRequest).toHaveBeenNthCalledWith(1, 'post', expect.stringContaining('/default/server/1100/port/my_port/trigger/JUMP')) - // Trigger play - expect(onRequest).toHaveBeenNthCalledWith( - 1, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/START') - ) - // Check that play worked - expect(onRequest).toHaveBeenNthCalledWith(2, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Plan to stop at end of clip - expect(onRequest).toHaveBeenNthCalledWith( - 3, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/STOP?offset=1999') - ) - - clearMocks() - commandReceiver0.mockClear() - - // Time to stop playing - await mockTime.advanceTimeToTicks(16050) - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 16000, - expect.objectContaining({ - type: QuantelCommandType.CLEARCLIP, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(1) - // Clear port from clip (reset port) - expect(onRequest).toHaveBeenNthCalledWith(1, 'post', expect.stringContaining('port/my_port/reset')) - - await myConductor.destroy() - expect(errorHandler).toHaveBeenCalledTimes(0) - expect(deviceErrorHandler).toHaveBeenCalledTimes(0) - }) - test('Play, seek and re-use clip', async () => { - const { commandReceiver0, myConductor, errorHandler, deviceErrorHandler, device } = - await setupDefaultQuantelDeviceForTest() - - clearMocks() - commandReceiver0.mockClear() - - await mockTime.advanceTimeToTicks(15000) - - // Check that no commands has been scheduled: - expect(await device.queue).toHaveLength(0) - - myConductor.setTimelineAndMappings([ - { - id: 'video0', - enable: { - start: 15000, // now - duration: 1000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.QUANTEL, - - title: 'Test0', - - // seek?: number - // inPoint?: number - // length?: number - // pauseTime?: number - // playing?: boolean - // noStarttime?: boolean - }, - }, - { - id: 'video1', - enable: { - start: 15200, - duration: 300, // 15500 - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.QUANTEL, - - title: 'myClip0', - }, - }, - { - id: 'video0_again', // it should be possible to remove this object when the timeline hass added support for instance.contentStart - enable: { - start: '#video1.end', // 15500 - end: 16000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.QUANTEL, - - title: 'Test0', - - inPoint: 500, - }, - }, - ]) - // What's going to happen: - // 15000: clip Test0 starts playing - // 15200: clip myClip starts playing (replaces old clip) - // 15500: clip Test0 resumes playing - // 16000: port is cleared - - await mockTime.advanceTimeToTicks(15150) - expect(commandReceiver0).toHaveBeenCalledTimes(3) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 15005, - expect.objectContaining({ - type: QuantelCommandType.LOADCLIPFRAGMENTS, - time: 15005, - }), - expect.any(String), - expect.any(String) - ) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 2, - 15010, - expect.objectContaining({ - type: QuantelCommandType.PLAYCLIP, - time: 15005, - }), - expect.any(String), - expect.any(String) - ) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 3, - 15070, - expect.objectContaining({ - type: QuantelCommandType.LOADCLIPFRAGMENTS, - time: 15055, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(14) - // Search for and get clip info: - expect(onRequest).toHaveBeenNthCalledWith(1, 'get', expect.stringContaining('/default/clip?Title=%22Test0%22')) - expect(onRequest).toHaveBeenNthCalledWith(2, 'get', expect.stringContaining('/default/clip/2')) - // Fetch fragments: - expect(onRequest).toHaveBeenNthCalledWith(3, 'get', expect.stringContaining('clip/2/fragments')) - // get port info - expect(onRequest).toHaveBeenNthCalledWith(4, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Load fragments - expect(onRequest).toHaveBeenNthCalledWith(5, 'post', expect.stringContaining('port/my_port/fragments')) - - // Note: These are skipped since the playhead is already there: - /* - // Prepare jump - expect(onRequest).toHaveBeenNthCalledWith(6, 'put', expect.stringContaining('port/my_port/jump?offset=0')) - // Trigger Jump - expect(onRequest).toHaveBeenNthCalledWith(6, 'post', expect.stringContaining('/default/server/1100/port/my_port/trigger/JUMP')) - */ - - // Trigger play - expect(onRequest).toHaveBeenNthCalledWith( - 6, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/START') - ) - // Check that play worked - expect(onRequest).toHaveBeenNthCalledWith(7, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Plan to stop at end of clip - expect(onRequest).toHaveBeenNthCalledWith( - 8, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/STOP?offset=999') - ) - - // Search for and get clip info: - expect(onRequest).toHaveBeenNthCalledWith(9, 'get', expect.stringContaining('/default/clip?Title=%22myClip0%22')) - expect(onRequest).toHaveBeenNthCalledWith(10, 'get', expect.stringContaining('/default/clip/1337')) - // Fetch fragments: - expect(onRequest).toHaveBeenNthCalledWith(11, 'get', expect.stringContaining('clip/1337/fragments')) - // get port info - expect(onRequest).toHaveBeenNthCalledWith(12, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Load fragments - expect(onRequest).toHaveBeenNthCalledWith(13, 'post', expect.stringContaining('port/my_port/fragments?offset=1000')) - // Prepare jump - expect(onRequest).toHaveBeenNthCalledWith(14, 'put', expect.stringContaining('port/my_port/jump?offset=1000')) - - clearMocks() - commandReceiver0.mockClear() - // Time to start playing - await mockTime.advanceTimeToTicks(15300) - - expect(commandReceiver0).toHaveBeenCalledTimes(2) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 15200, - expect.objectContaining({ - type: QuantelCommandType.PLAYCLIP, - }), - expect.any(String), - expect.any(String) - ) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 2, - 15265, - expect.objectContaining({ - type: QuantelCommandType.LOADCLIPFRAGMENTS, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(9) - // Trigger Jump - expect(onRequest).toHaveBeenNthCalledWith( - 1, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/JUMP') - ) - // Trigger play - expect(onRequest).toHaveBeenNthCalledWith( - 2, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/START') - ) - // Check that play worked - expect(onRequest).toHaveBeenNthCalledWith(3, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Plan to stop at end of clip - expect(onRequest).toHaveBeenNthCalledWith( - 4, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/STOP?offset=2999') - ) - - // Load next clip: - - // Search for and get clip info: - // expect(onRequest).toHaveBeenNthCalledWith(4, 'get', expect.stringContaining('/default/clip?Title=%22myClip0%22')) // already have this info - expect(onRequest).toHaveBeenNthCalledWith(5, 'get', expect.stringContaining('/default/clip/2')) - // Fetch fragments: - expect(onRequest).toHaveBeenNthCalledWith(6, 'get', expect.stringContaining('clip/2/fragments/13-1000')) - // get port info - expect(onRequest).toHaveBeenNthCalledWith(7, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Load fragments - expect(onRequest).toHaveBeenNthCalledWith(8, 'post', expect.stringContaining('port/my_port/fragments?offset=3000')) - // Prepare jump - expect(onRequest).toHaveBeenNthCalledWith(9, 'put', expect.stringContaining('port/my_port/jump?offset=3000')) - - clearMocks() - commandReceiver0.mockClear() - // Time to start playing - await mockTime.advanceTimeToTicks(15700) - - expect(onRequest).toHaveBeenCalledTimes(4) - - // Trigger Jump - expect(onRequest).toHaveBeenNthCalledWith( - 1, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/JUMP') - ) - // Trigger play - expect(onRequest).toHaveBeenNthCalledWith( - 2, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/START') - ) - // Check that play worked - expect(onRequest).toHaveBeenNthCalledWith(3, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Plan to stop at end of clip - expect(onRequest).toHaveBeenNthCalledWith( - 4, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/STOP?offset=3986') - ) - - clearMocks() - commandReceiver0.mockClear() - - // Time to stop playing - await mockTime.advanceTimeToTicks(16050) - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 16000, - expect.objectContaining({ - type: QuantelCommandType.CLEARCLIP, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(1) - // Clear port from clip (reset port) - expect(onRequest).toHaveBeenNthCalledWith(1, 'post', expect.stringContaining('port/my_port/reset')) - - await myConductor.destroy() - expect(errorHandler).toHaveBeenCalledTimes(0) - expect(deviceErrorHandler).toHaveBeenCalledTimes(0) - }) - test('outTransition to clear', async () => { - const { commandReceiver0, myConductor, errorHandler, deviceErrorHandler } = await setupDefaultQuantelDeviceForTest() - - myConductor.setTimelineAndMappings([ - { - id: 'video0', - enable: { - start: 11000, - duration: 2000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.QUANTEL, - - title: 'myClip0', - outTransition: { - type: QuantelTransitionType.DELAY, - delay: 1000, // ms + { + layer0: { + device: DeviceType.QUANTEL, + deviceId: 'myQuantel', + + options: { + mappingType: MappingQuantelType.Port, + portId: 'my_port', + channelId: 2, + }, }, }, - }, - ]) - // Time to preload the clip - await mockTime.advanceTimeToTicks(10990) - - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 10155, - expect.objectContaining({ - type: QuantelCommandType.LOADCLIPFRAGMENTS, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(6) - - // Search for and get clip info: - expect(onRequest).toHaveBeenNthCalledWith(1, 'get', expect.stringContaining('/default/clip?Title=%22myClip0%22')) - expect(onRequest).toHaveBeenNthCalledWith(2, 'get', expect.stringContaining('/default/clip/1337')) - // Fetch fragments: - expect(onRequest).toHaveBeenNthCalledWith(3, 'get', expect.stringContaining('clip/1337/fragments')) - // get port info - expect(onRequest).toHaveBeenNthCalledWith(4, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Load fragments - expect(onRequest).toHaveBeenNthCalledWith(5, 'post', expect.stringContaining('port/my_port/fragments')) - // Prepare jump: - expect(onRequest).toHaveBeenNthCalledWith(6, 'put', expect.stringContaining('port/my_port/jump?offset=')) - - clearMocks() - commandReceiver0.mockClear() - - // Time to start playing - await mockTime.advanceTimeToTicks(11300) - - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 11000, - expect.objectContaining({ - type: QuantelCommandType.PLAYCLIP, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(3) - - // Trigger Jump - // expect(onRequest).toHaveBeenNthCalledWith(1, 'post', expect.stringContaining('/default/server/1100/port/my_port/trigger/JUMP')) - // Trigger play - expect(onRequest).toHaveBeenNthCalledWith( - 1, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/START') - ) - // Check that play worked - expect(onRequest).toHaveBeenNthCalledWith(2, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Plan to stop at end of clip - expect(onRequest).toHaveBeenNthCalledWith( - 3, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/STOP?offset=1999') - ) - - clearMocks() - commandReceiver0.mockClear() - - // Time to stop playing - await mockTime.advanceTimeToTicks(13050) - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 13000, - expect.objectContaining({ - type: QuantelCommandType.CLEARCLIP, - transition: { - type: QuantelTransitionType.DELAY, - delay: 1000, - }, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(0) // because of the outTransition - - await mockTime.advanceTimeToTicks(14050) - - expect(onRequest).toHaveBeenCalledTimes(1) - // Clear port from clip (reset port) - expect(onRequest).toHaveBeenNthCalledWith(1, 'post', expect.stringContaining('port/my_port/reset')) - - await myConductor.destroy() - - expect(errorHandler).toHaveBeenCalledTimes(0) - expect(deviceErrorHandler).toHaveBeenCalledTimes(0) - }) - test('outTransition to notOnAir', async () => { - const { commandReceiver0, myConductor, errorHandler, deviceErrorHandler, device } = - await setupDefaultQuantelDeviceForTest() - - // Check that no commands has been scheduled: - expect(await device.queue).toHaveLength(0) - - myConductor.setTimelineAndMappings([ - { - id: 'video0', - enable: { - start: 11000, - duration: 2000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.QUANTEL, - - title: 'myClip0', - outTransition: { - type: QuantelTransitionType.DELAY, - delay: 1000, // ms + { + time: 10, + port: { + my_port: { + timelineObjId: 'obj0', + mode: QuantelControlMode.QUALITY, + notOnAir: false, + lookahead: false, + clip: { + title: 'myClip0', + playTime: 10, + playing: true, + }, + channels: [2], + }, }, - }, - }, - { - id: 'video1', - enable: { - start: '#video0.end', // 13000 - duration: 2000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.QUANTEL, - title: 'Test0', - notOnAir: true, - // pauseTime: 13000, - noStarttime: true, - playing: false, - }, - }, - ]) - // Time to preload the clip - await mockTime.advanceTimeToTicks(10990) - - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 10155, - expect.objectContaining({ - type: QuantelCommandType.LOADCLIPFRAGMENTS, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(6) - - // Search for and get clip info: - expect(onRequest).toHaveBeenNthCalledWith(1, 'get', expect.stringContaining('/default/clip?Title=%22myClip0%22')) - expect(onRequest).toHaveBeenNthCalledWith(2, 'get', expect.stringContaining('/default/clip/1337')) - // Fetch fragments: - expect(onRequest).toHaveBeenNthCalledWith(3, 'get', expect.stringContaining('clip/1337/fragments')) - // get port info - expect(onRequest).toHaveBeenNthCalledWith(4, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Load fragments - expect(onRequest).toHaveBeenNthCalledWith(5, 'post', expect.stringContaining('port/my_port/fragments')) - // Prepare jump: - expect(onRequest).toHaveBeenNthCalledWith(6, 'put', expect.stringContaining('port/my_port/jump?offset=0')) - - clearMocks() - commandReceiver0.mockClear() - - // Time to start playing - await mockTime.advanceTimeToTicks(11300) - - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 11000, - expect.objectContaining({ - type: QuantelCommandType.PLAYCLIP, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(3) - - // Trigger Jump - // expect(onRequest).toHaveBeenNthCalledWith(1, 'post', expect.stringContaining('/default/server/1100/port/my_port/trigger/JUMP')) - // Trigger play - expect(onRequest).toHaveBeenNthCalledWith( - 1, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/START') - ) - // Check that play worked - expect(onRequest).toHaveBeenNthCalledWith(2, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Plan to stop at end of clip - expect(onRequest).toHaveBeenNthCalledWith( - 3, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/STOP?offset=1999') - ) - - clearMocks() - commandReceiver0.mockClear() - - // Time to stop playing - - await mockTime.advanceTimeToTicks(13050) - - expect(commandReceiver0).toHaveBeenCalledTimes(2) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 12000, - expect.objectContaining({ - type: QuantelCommandType.LOADCLIPFRAGMENTS, - }), - expect.any(String), - expect.any(String) - ) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 2, - 13000, - expect.objectContaining({ - type: QuantelCommandType.PAUSECLIP, - transition: { - type: QuantelTransitionType.DELAY, - delay: 1000, - }, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(6) // because of the outTransition of #video0 - - // Search for and get clip info: - expect(onRequest).toHaveBeenNthCalledWith(1, 'get', expect.stringContaining('/default/clip?Title=%22Test0%22')) - expect(onRequest).toHaveBeenNthCalledWith(2, 'get', expect.stringContaining('/default/clip/2')) - // Fetch fragments: - expect(onRequest).toHaveBeenNthCalledWith(3, 'get', expect.stringContaining('clip/2/fragments')) - // get port info - expect(onRequest).toHaveBeenNthCalledWith(4, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Load fragments - expect(onRequest).toHaveBeenNthCalledWith(5, 'post', expect.stringContaining('port/my_port/fragments')) - // Prepare jump: - expect(onRequest).toHaveBeenNthCalledWith(6, 'put', expect.stringContaining('port/my_port/jump?offset=2000')) - - clearMocks() - commandReceiver0.mockClear() - await mockTime.advanceTimeToTicks(14050) - expect(onRequest).toHaveBeenCalledTimes(2) - // Trigger STOP - expect(onRequest).toHaveBeenNthCalledWith( - 1, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/STOP') - ) - // Trigger JUMP - expect(onRequest).toHaveBeenNthCalledWith( - 2, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/JUMP') - ) - - clearMocks() - commandReceiver0.mockClear() - - await mockTime.advanceTimeToTicks(16050) - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - expect.toBeCloseTo(15000, 5), - expect.objectContaining({ - type: QuantelCommandType.CLEARCLIP, - }), - expect.any(String), - expect.any(String) - ) - expect(onRequest).toHaveBeenCalledTimes(1) - // Clear port from clip (reset port) - expect(onRequest).toHaveBeenNthCalledWith( - 1, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/reset') - ) - - await myConductor.destroy() - - expect(errorHandler).toHaveBeenCalledTimes(0) - expect(deviceErrorHandler).toHaveBeenCalledTimes(0) - }) - test('outTransition not tun for lookaheads', async () => { - const { commandReceiver0, myConductor, errorHandler, deviceErrorHandler, device } = - await setupDefaultQuantelDeviceForTest() - - // Check that no commands has been scheduled: - expect(await device.queue).toHaveLength(0) - - myConductor.setTimelineAndMappings([ - { - id: 'video0', - enable: { - start: 11000, - duration: 2000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.QUANTEL, + } + ) + }) - title: 'myClip0', - notOnAir: true, - outTransition: { - type: QuantelTransitionType.DELAY, - delay: 1000, // ms + test('convert 2 layers for 1 port', async () => { + await compareState( + createTimelineState({ + layer0: { + id: 'obj0', + content: { + deviceType: DeviceType.QUANTEL, + + title: 'myClip0', + }, + instance: { + originalStart: 10, + }, + }, + layer1: { + id: 'obj1', + content: { + deviceType: DeviceType.QUANTEL, + + title: 'myClip1', + }, + instance: { + originalStart: 10, + }, }, - }, - }, - { - id: 'video1', - enable: { - start: '#video0.end', // 13000 - duration: 2000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.QUANTEL, - title: 'Test0', - notOnAir: true, - // pauseTime: 13000, - noStarttime: true, - playing: false, - }, - }, - ]) - // Time to preload the clip - await mockTime.advanceTimeToTicks(10990) - - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 10155, - expect.objectContaining({ - type: QuantelCommandType.LOADCLIPFRAGMENTS, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(6) - - // Search for and get clip info: - expect(onRequest).toHaveBeenNthCalledWith(1, 'get', expect.stringContaining('/default/clip?Title=%22myClip0%22')) - expect(onRequest).toHaveBeenNthCalledWith(2, 'get', expect.stringContaining('/default/clip/1337')) - // Fetch fragments: - expect(onRequest).toHaveBeenNthCalledWith(3, 'get', expect.stringContaining('clip/1337/fragments')) - // get port info - expect(onRequest).toHaveBeenNthCalledWith(4, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Load fragments - expect(onRequest).toHaveBeenNthCalledWith(5, 'post', expect.stringContaining('port/my_port/fragments')) - // Prepare jump: - expect(onRequest).toHaveBeenNthCalledWith(6, 'put', expect.stringContaining('port/my_port/jump?offset=0')) - - clearMocks() - commandReceiver0.mockClear() - - // Time to start playing - await mockTime.advanceTimeToTicks(11300) - - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 11000, - expect.objectContaining({ - type: QuantelCommandType.PLAYCLIP, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(3) - - // Trigger Jump - // expect(onRequest).toHaveBeenNthCalledWith(1, 'post', expect.stringContaining('/default/server/1100/port/my_port/trigger/JUMP')) - // Trigger play - expect(onRequest).toHaveBeenNthCalledWith( - 1, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/START') - ) - // Check that play worked - expect(onRequest).toHaveBeenNthCalledWith(2, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Plan to stop at end of clip - expect(onRequest).toHaveBeenNthCalledWith( - 3, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/STOP?offset=1999') - ) - - clearMocks() - commandReceiver0.mockClear() - - // Time to stop playing - - await mockTime.advanceTimeToTicks(13050) - - expect(commandReceiver0).toHaveBeenCalledTimes(2) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 12000, - expect.objectContaining({ - type: QuantelCommandType.LOADCLIPFRAGMENTS, - }), - expect.any(String), - expect.any(String) - ) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 2, - 13000, - expect.objectContaining({ - type: QuantelCommandType.PAUSECLIP, - transition: undefined, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(8) // because of no outTransition of #video0 - - // Search for and get clip info: - expect(onRequest).toHaveBeenNthCalledWith(1, 'get', expect.stringContaining('/default/clip?Title=%22Test0%22')) - expect(onRequest).toHaveBeenNthCalledWith(2, 'get', expect.stringContaining('/default/clip/2')) - // Fetch fragments: - expect(onRequest).toHaveBeenNthCalledWith(3, 'get', expect.stringContaining('clip/2/fragments')) - // get port info - expect(onRequest).toHaveBeenNthCalledWith(4, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Load fragments - expect(onRequest).toHaveBeenNthCalledWith(5, 'post', expect.stringContaining('port/my_port/fragments')) - // Prepare jump: - expect(onRequest).toHaveBeenNthCalledWith(6, 'put', expect.stringContaining('port/my_port/jump?offset=2000')) - // Trigger STOP - expect(onRequest).toHaveBeenNthCalledWith( - 7, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/STOP') - ) - // Trigger JUMP - expect(onRequest).toHaveBeenNthCalledWith( - 8, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/JUMP') - ) - - clearMocks() - commandReceiver0.mockClear() - - await mockTime.advanceTimeToTicks(16050) - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - expect.toBeCloseTo(15000, 5), - expect.objectContaining({ - type: QuantelCommandType.CLEARCLIP, - }), - expect.any(String), - expect.any(String) - ) - expect(onRequest).toHaveBeenCalledTimes(1) - // Clear port from clip (reset port) - expect(onRequest).toHaveBeenNthCalledWith( - 1, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/reset') - ) - - await myConductor.destroy() - - expect(errorHandler).toHaveBeenCalledTimes(0) - expect(deviceErrorHandler).toHaveBeenCalledTimes(0) - }) - test('Play, then pause', async () => { - const { commandReceiver0, myConductor, errorHandler, deviceErrorHandler } = await setupDefaultQuantelDeviceForTest() - - myConductor.setTimelineAndMappings([ - { - id: 'video0', - enable: { - start: 11000, - duration: 20000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.QUANTEL, - title: 'myClip0', - }, - }, - ]) - // Time to preload the clip - await mockTime.advanceTimeToTicks(10990) - - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 10155, - expect.objectContaining({ - type: QuantelCommandType.LOADCLIPFRAGMENTS, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(6) - - // Search for and get clip info: - expect(onRequest).toHaveBeenNthCalledWith(1, 'get', expect.stringContaining('/default/clip?Title=%22myClip0%22')) - expect(onRequest).toHaveBeenNthCalledWith(2, 'get', expect.stringContaining('/default/clip/1337')) - // Fetch fragments: - expect(onRequest).toHaveBeenNthCalledWith(3, 'get', expect.stringContaining('clip/1337/fragments')) - // get port info - expect(onRequest).toHaveBeenNthCalledWith(4, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Load fragments - expect(onRequest).toHaveBeenNthCalledWith(5, 'post', expect.stringContaining('port/my_port/fragments')) - // Prepare jump: - expect(onRequest).toHaveBeenNthCalledWith(6, 'put', expect.stringContaining('port/my_port/jump?offset=0')) - - clearMocks() - commandReceiver0.mockClear() - - // Time to start playing - await mockTime.advanceTimeToTicks(11300) - - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 11000, - expect.objectContaining({ - type: QuantelCommandType.PLAYCLIP, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(3) - - // Trigger Jump - // expect(onRequest).toHaveBeenNthCalledWith(1, 'post', expect.stringContaining('/default/server/1100/port/my_port/trigger/JUMP')) - // Trigger play - expect(onRequest).toHaveBeenNthCalledWith( - 1, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/START') - ) - // Check that play worked - expect(onRequest).toHaveBeenNthCalledWith(2, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Plan to stop at end of clip - expect(onRequest).toHaveBeenNthCalledWith( - 3, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/STOP?offset=1999') - ) - - clearMocks() - commandReceiver0.mockClear() - - await mockTime.advanceTimeToTicks(14000) - - // Add a lookahead of the same clip - myConductor.setTimelineAndMappings([ - { - id: 'video0', - enable: { - start: 11000, - duration: 20000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.QUANTEL, - - title: 'myClip0', - }, - }, - { - id: 'lookahead_video0', - enable: { - while: 1, - }, - content: { - deviceType: DeviceType.QUANTEL, - title: 'myClip0', - }, - layer: 'lookahead_myLayer0', - isLookahead: true, - lookaheadForLayer: 'myLayer0', - }, - ]) - - await mockTime.advanceTimeTicks(1000) - // There should be no changes - expect(commandReceiver0).toHaveBeenCalledTimes(0) - expect(onRequest).toHaveBeenCalledTimes(0) - - clearMocks() - commandReceiver0.mockClear() - - await mockTime.advanceTimeToTicks(20000) - - myConductor.setTimelineAndMappings([ - { - id: 'lookahead_video0', - enable: { - while: 1, - }, - content: { - deviceType: DeviceType.QUANTEL, - title: 'myClip0', - }, - layer: 'lookahead_myLayer0', - isLookahead: true, - lookaheadForLayer: 'myLayer0', - }, - ]) - - // Should seek to the beginning - await mockTime.advanceTimeTicks(1000) - expect(commandReceiver0).toHaveBeenCalledTimes(2) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 20005, - expect.objectContaining({ - type: QuantelCommandType.LOADCLIPFRAGMENTS, - clip: expect.objectContaining({ - title: 'myClip0', }), - }), - expect.any(String), - expect.any(String) - ) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 2, - 20010, - expect.objectContaining({ - type: QuantelCommandType.PAUSECLIP, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(4) - // Lookup clip id - expect(onRequest).toHaveBeenNthCalledWith(1, 'get', expect.stringContaining('default/clip/1337')) - // Prepare jump: - expect(onRequest).toHaveBeenNthCalledWith(2, 'put', expect.stringContaining('port/my_port/jump?offset=0')) - // Stop playing: - expect(onRequest).toHaveBeenNthCalledWith(3, 'post', expect.stringContaining('port/my_port/trigger/STOP')) - // // Trigger jump: - expect(onRequest).toHaveBeenNthCalledWith(4, 'post', expect.stringContaining('port/my_port/trigger/JUMP')) - - await myConductor.destroy() - - expect(errorHandler).toHaveBeenCalledTimes(0) - expect(deviceErrorHandler).toHaveBeenCalledTimes(0) - }) - test('Play, then handle lookahead', async () => { - const { commandReceiver0, myConductor, errorHandler, deviceErrorHandler } = await setupDefaultQuantelDeviceForTest() - - // Play a video - myConductor.setTimelineAndMappings([ - { - id: 'video0', - enable: { - start: 11000, - duration: 20000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.QUANTEL, - title: 'myClip0', - }, - }, - ]) - - // >> Skipping the tests to see that the clip started playing, that's covered in other tests << - - await mockTime.advanceTimeToTicks(14000) - clearMocks() - commandReceiver0.mockClear() - - // Add a lookahead of another clip - myConductor.setTimelineAndMappings([ - { - id: 'video0', - enable: { - start: 11000, - duration: 20000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.QUANTEL, - - title: 'myClip0', - }, - }, - { - id: 'lookahead_video1', - enable: { - while: 1, - }, - content: { - deviceType: DeviceType.QUANTEL, - title: 'myClip1', + { + layer0: { + device: DeviceType.QUANTEL, + deviceId: 'myQuantel', + + options: { + mappingType: MappingQuantelType.Port, + portId: 'my_port', + channelId: 2, + }, + }, + layer1: { + device: DeviceType.QUANTEL, + deviceId: 'myQuantel', + + options: { + mappingType: MappingQuantelType.Port, + portId: 'my_port', + channelId: 3, + }, + }, }, - layer: 'lookahead_myLayer0', - isLookahead: true, - lookaheadForLayer: 'myLayer0', - }, - ]) - - await mockTime.advanceTimeTicks(1000) - - // The lookahead-clip should be preloaded: - - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 14005, - expect.objectContaining({ - type: QuantelCommandType.LOADCLIPFRAGMENTS, - clip: expect.objectContaining({ - title: 'myClip1', + { + time: 10, + port: { + my_port: { + timelineObjId: 'obj1', + mode: QuantelControlMode.QUALITY, + notOnAir: false, + lookahead: false, + clip: { + title: 'myClip1', + playTime: 10, + playing: true, + }, + channels: [2, 3], + }, + }, + } + ) + }) + test('convert empty layer + 1 lookahaed', async () => { + await compareState( + createTimelineState({ + layer0_lookahead: { + id: 'obj1', + content: { + deviceType: DeviceType.QUANTEL, + + title: 'myClip1', + }, + instance: { + originalStart: 10, + }, + isLookahead: true, + lookaheadForLayer: 'layer0', + }, }), - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(5) - - // Find the clip - expect(onRequest).toHaveBeenNthCalledWith(1, 'get', expect.stringContaining('/default/clip?Title=%22myClip1%22')) - expect(onRequest).toHaveBeenNthCalledWith(2, 'get', expect.stringContaining('/default/clip/1338')) - // Fetch fragments: - expect(onRequest).toHaveBeenNthCalledWith(3, 'get', expect.stringContaining('clip/1338/fragments')) - // get port info - expect(onRequest).toHaveBeenNthCalledWith(4, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Load fragments - expect(onRequest).toHaveBeenNthCalledWith(5, 'post', expect.stringContaining('port/my_port/fragments')) - - clearMocks() - commandReceiver0.mockClear() - await mockTime.advanceTimeTicks(1000) - expect(commandReceiver0).toHaveBeenCalledTimes(0) - expect(onRequest).toHaveBeenCalledTimes(0) - - // Remove the original clip, leaving the lookahead: - myConductor.setTimelineAndMappings([ - { - id: 'lookahead_video1', - enable: { - while: 1, - }, - content: { - deviceType: DeviceType.QUANTEL, - title: 'myClip1', + { + layer0: { + device: DeviceType.QUANTEL, + deviceId: 'myQuantel', + + options: { + mappingType: MappingQuantelType.Port, + portId: 'my_port', + channelId: 2, + }, + }, }, - layer: 'lookahead_myLayer0', - isLookahead: true, - lookaheadForLayer: 'myLayer0', - }, - ]) - - // Should seek to the beginning of the lookahead-clip: - - await mockTime.advanceTimeTicks(1000) - - expect(commandReceiver0).toHaveBeenCalledTimes(2) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 16005, - expect.objectContaining({ - type: QuantelCommandType.LOADCLIPFRAGMENTS, - clip: expect.objectContaining({ - title: 'myClip1', + { + time: 10, + port: { + my_port: { + timelineObjId: 'obj1', + mode: QuantelControlMode.QUALITY, + notOnAir: true, + lookahead: true, + lookaheadClip: { + timelineObjId: 'obj1', + title: 'myClip1', + }, + clip: { + title: 'myClip1', + playing: false, + playTime: null, + }, + channels: [2], + }, + }, + } + ) + }) + test('convert 1 layer + 1 lookahaed', async () => { + await compareState( + createTimelineState({ + layer0: { + id: 'obj0', + content: { + deviceType: DeviceType.QUANTEL, + + title: 'myClip0', + }, + instance: { + originalStart: 10, + }, + }, + layer0_lookahead: { + id: 'obj1', + content: { + deviceType: DeviceType.QUANTEL, + + title: 'myClip1', + }, + instance: { + originalStart: 10, + }, + isLookahead: true, + lookaheadForLayer: 'layer0', + }, }), - }), - expect.any(String), - expect.any(String) - ) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 2, - 16010, - expect.objectContaining({ - type: QuantelCommandType.PAUSECLIP, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(4) - // Lookup clip id - expect(onRequest).toHaveBeenNthCalledWith(1, 'get', expect.stringContaining('default/clip/1338')) - // Prepare jump: - expect(onRequest).toHaveBeenNthCalledWith(2, 'put', expect.stringContaining('port/my_port/jump?offset=2000')) - // Stop playing: - expect(onRequest).toHaveBeenNthCalledWith(3, 'post', expect.stringContaining('port/my_port/trigger/STOP')) - // // Trigger jump: - expect(onRequest).toHaveBeenNthCalledWith(4, 'post', expect.stringContaining('port/my_port/trigger/JUMP')) - - clearMocks() - commandReceiver0.mockClear() - await mockTime.advanceTimeToTicks(20000) - expect(commandReceiver0).toHaveBeenCalledTimes(0) - expect(onRequest).toHaveBeenCalledTimes(0) - - // Start playing the lookahead - myConductor.setTimelineAndMappings([ - { - id: 'video1', - enable: { - start: 20000, // now - duration: 5000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.QUANTEL, - - title: 'myClip1', + { + layer0: { + device: DeviceType.QUANTEL, + deviceId: 'myQuantel', + + options: { + mappingType: MappingQuantelType.Port, + portId: 'my_port', + channelId: 2, + }, + }, }, - }, - ]) - - // Should start playing the clip: - - await mockTime.advanceTimeTicks(1000) - - expect(commandReceiver0).toHaveBeenCalledTimes(2) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 20005, - expect.objectContaining({ - type: QuantelCommandType.LOADCLIPFRAGMENTS, - clip: expect.objectContaining({ - title: 'myClip1', - }), - }), - expect.any(String), - expect.any(String) - ) - - expect(commandReceiver0).toHaveBeenNthCalledWith( - 2, - 20010, - expect.objectContaining({ - type: QuantelCommandType.PLAYCLIP, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(4) - // Lookup clip id - expect(onRequest).toHaveBeenNthCalledWith(1, 'get', expect.stringContaining('default/clip/1338')) - // Start playing: - expect(onRequest).toHaveBeenNthCalledWith(2, 'post', expect.stringContaining('port/my_port/trigger/START')) - // Check that play worked - expect(onRequest).toHaveBeenNthCalledWith(3, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Stop playing at the last frame - expect(onRequest).toHaveBeenNthCalledWith( - 4, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/STOP?offset=3233') - ) - - await myConductor.destroy() - - expect(errorHandler).toHaveBeenCalledTimes(0) - expect(deviceErrorHandler).toHaveBeenCalledTimes(0) + { + time: 10, + port: { + my_port: { + timelineObjId: 'obj0', + mode: QuantelControlMode.QUALITY, + notOnAir: false, + lookahead: false, + lookaheadClip: { + timelineObjId: 'obj1', + title: 'myClip1', + }, + clip: { + title: 'myClip0', + playTime: 10, + playing: true, + }, + channels: [2], + }, + }, + } + ) + }) }) - test('Lookahead, then play, pause, seek, clear', async () => { - const { commandReceiver0, myConductor, errorHandler, deviceErrorHandler } = await setupDefaultQuantelDeviceForTest() - - // Start by having a lookahead - myConductor.setTimelineAndMappings([ - { - id: 'lookahead_video0', - enable: { - while: 1, - }, - content: { - deviceType: DeviceType.QUANTEL, - title: 'myClip0', - }, - layer: 'lookahead_myLayer0', - isLookahead: true, - lookaheadForLayer: 'myLayer0', - }, - ]) - - await mockTime.advanceTimeTicks(1000) - expect(commandReceiver0).toHaveBeenCalledTimes(2) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 10105, - expect.objectContaining({ - type: QuantelCommandType.LOADCLIPFRAGMENTS, - clip: expect.objectContaining({ - title: 'myClip0', - }), - }), - expect.any(String), - expect.any(String) - ) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 2, - 10110, - expect.objectContaining({ - type: QuantelCommandType.PAUSECLIP, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(8) + describe('diffState', () => { + async function compareStates( + oldDevState: QuantelState, + newDevState: QuantelState, + expCommands: QuantelCommandWithContext[] + ) { + const device = await getInitialisedQuantelDevice() - // Search for and get clip info: - expect(onRequest).toHaveBeenNthCalledWith(1, 'get', expect.stringContaining('/default/clip?Title=%22myClip0%22')) - expect(onRequest).toHaveBeenNthCalledWith(2, 'get', expect.stringContaining('/default/clip/1337')) - // Fetch fragments: - expect(onRequest).toHaveBeenNthCalledWith(3, 'get', expect.stringContaining('clip/1337/fragments')) - // get port info - expect(onRequest).toHaveBeenNthCalledWith(4, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Load fragments - expect(onRequest).toHaveBeenNthCalledWith(5, 'post', expect.stringContaining('port/my_port/fragments')) - // Prepare jump: - expect(onRequest).toHaveBeenNthCalledWith(6, 'put', expect.stringContaining('port/my_port/jump?offset=0')) - expect(onRequest).toHaveBeenNthCalledWith(7, 'post', expect.stringContaining('port/my_port/trigger/STOP')) - expect(onRequest).toHaveBeenNthCalledWith(8, 'post', expect.stringContaining('port/my_port/trigger/JUMP')) + const commands = device.diffStates(oldDevState, newDevState) - clearMocks() - commandReceiver0.mockClear() + expect(commands).toEqual(expCommands) + } - await mockTime.advanceTimeToTicks(15000) - expect(commandReceiver0).toHaveBeenCalledTimes(0) - expect(onRequest).toHaveBeenCalledTimes(0) + test('Empty states', async () => { + await compareStates({ time: 10, port: {} }, { time: 10, port: {} }, []) + }) - // Start playing the former lookahead - myConductor.setTimelineAndMappings([ - { - id: 'video0', - enable: { - start: 15000, - duration: 20000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.QUANTEL, - title: 'myClip0', + test('Set up port', async () => { + jest + await compareStates( + { time: 1000, port: {} }, + { + time: 3000, + port: { + port0: { + timelineObjId: 'obj0', + mode: QuantelControlMode.QUALITY, + notOnAir: false, + lookahead: false, + channels: [2], + }, + }, }, - }, - ]) - - await mockTime.advanceTimeTicks(1000) - - expect(commandReceiver0).toHaveBeenCalledTimes(2) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 15005, - expect.objectContaining({ - type: QuantelCommandType.LOADCLIPFRAGMENTS, - }), - expect.any(String), - expect.any(String) - ) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 2, - 15010, - expect.objectContaining({ - type: QuantelCommandType.PLAYCLIP, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(4) - - expect(onRequest).toHaveBeenNthCalledWith(1, 'get', expect.stringContaining('/default/clip/1337')) - // Start playing: - expect(onRequest).toHaveBeenNthCalledWith(2, 'post', expect.stringContaining('port/my_port/trigger/START')) - // Check that play worked - expect(onRequest).toHaveBeenNthCalledWith(3, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Plan to stop at end of clip - expect(onRequest).toHaveBeenNthCalledWith( - 4, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/STOP?offset=1999') - ) - - clearMocks() - commandReceiver0.mockClear() - - await mockTime.advanceTimeToTicks(20000) - expect(commandReceiver0).toHaveBeenCalledTimes(0) - expect(onRequest).toHaveBeenCalledTimes(0) + [ + { + command: { + type: QuantelCommandType.SETUPPORT, + time: 2990, + portId: 'port0', + timelineObjId: 'obj0', + channel: 2, + }, + context: 'Old state did not have port', + tlObjId: 'obj0', + }, + { + command: { + type: QuantelCommandType.CLEARCLIP, + time: 3000, + portId: 'port0', + timelineObjId: 'obj0', + fromLookahead: false, + transition: undefined, + }, + context: 'New clip is empty', + tlObjId: 'obj0', + }, + ] + ) + }) - // Pause the video: - myConductor.setTimelineAndMappings([ - { - id: 'video0', - enable: { - start: 15000, - duration: 20000, + test('Load', async () => { + await compareStates( + { + time: 1000, + port: { + port0: { + timelineObjId: 'obj0', + mode: QuantelControlMode.QUALITY, + notOnAir: false, + lookahead: false, + channels: [2], + }, + }, }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.QUANTEL, - - title: 'myClip0', - - playing: false, - pauseTime: 20000, // Pausing now, we're 5 seconds in + { + time: 3000, + port: { + port0: { + timelineObjId: 'obj1', + mode: QuantelControlMode.QUALITY, + notOnAir: false, + lookahead: false, + clip: { + title: 'test0', + playing: false, + playTime: null, + }, + channels: [2], + }, + }, }, - }, - ]) - - // Should pause the clip: - // Note: In a future implementation we could be smarter and know that we're - // already on the right frame, and just pause the clip. Currently we're always going to seek. - - await mockTime.advanceTimeTicks(1000) - - expect(commandReceiver0).toHaveBeenCalledTimes(2) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 20005, - expect.objectContaining({ - type: QuantelCommandType.LOADCLIPFRAGMENTS, - clip: expect.objectContaining({ - title: 'myClip0', - }), - }), - expect.any(String), - expect.any(String) - ) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 2, - 20010, - expect.objectContaining({ - type: QuantelCommandType.PAUSECLIP, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(4) - // Lookup clip id - expect(onRequest).toHaveBeenNthCalledWith(1, 'get', expect.stringContaining('default/clip/1337')) - // Prepare jump: - expect(onRequest).toHaveBeenNthCalledWith(2, 'put', expect.stringContaining('port/my_port/jump?offset=125')) // 5 seconds * 25fps = 125 - // Stop playing: - expect(onRequest).toHaveBeenNthCalledWith(3, 'post', expect.stringContaining('port/my_port/trigger/STOP')) - // // Trigger jump: - expect(onRequest).toHaveBeenNthCalledWith(4, 'post', expect.stringContaining('port/my_port/trigger/JUMP')) - - clearMocks() - commandReceiver0.mockClear() - await mockTime.advanceTimeToTicks(25000) - expect(commandReceiver0).toHaveBeenCalledTimes(0) - expect(onRequest).toHaveBeenCalledTimes(0) + [ + { + command: { + type: QuantelCommandType.LOADCLIPFRAGMENTS, + time: 2990, + portId: 'port0', + timelineObjId: 'obj1', + fromLookahead: false, + clip: { + title: 'test0', + playing: false, + playTime: null, + }, + timeOfPlay: 3000, + allowedToPrepareJump: true, + }, + context: 'Load from current state', + tlObjId: 'obj1', + }, + { + command: { + type: QuantelCommandType.PAUSECLIP, + time: 3000, + portId: 'port0', + timelineObjId: 'obj1', + fromLookahead: false, + clip: { + title: 'test0', + playing: false, + playTime: null, + }, + mode: QuantelControlMode.QUALITY, + transition: undefined, + }, + context: 'New clip is paused', + tlObjId: 'obj1', + }, + ] + ) + }) - // Start playing the clip again: - myConductor.setTimelineAndMappings([ - { - id: 'video0', - enable: { - start: 15000, - duration: 20000, + test('Load & play', async () => { + await compareStates( + { + time: 2000, + port: { + port0: { + timelineObjId: 'obj0', + mode: QuantelControlMode.QUALITY, + notOnAir: false, + lookahead: false, + clip: { + title: 'test0', + playing: false, + playTime: null, + }, + channels: [2], + }, + }, }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.QUANTEL, - - title: 'myClip0', - - playing: true, - pauseTime: 20000, // We previously paused at 20000 + { + time: 3000, + port: { + port0: { + timelineObjId: 'obj1', + mode: QuantelControlMode.QUALITY, + notOnAir: false, + lookahead: false, + clip: { + title: 'test0', + playing: true, + playTime: 3000, + }, + channels: [2], + }, + }, }, - }, - ]) - - // Should start playing: - - await mockTime.advanceTimeTicks(1000) - - expect(commandReceiver0).toHaveBeenCalledTimes(2) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 25005, - expect.objectContaining({ - type: QuantelCommandType.LOADCLIPFRAGMENTS, - clip: expect.objectContaining({ - title: 'myClip0', - }), - }), - expect.any(String), - expect.any(String) - ) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 2, - 25010, - expect.objectContaining({ - type: QuantelCommandType.PLAYCLIP, - }), - expect.any(String), - expect.any(String) - ) - - // Note: since we're already on the correct frame, no seeking is needed, just start playing right away - - expect(onRequest).toHaveBeenCalledTimes(4) - // Lookup clip id - expect(onRequest).toHaveBeenNthCalledWith(1, 'get', expect.stringContaining('/default/clip/1337')) - // Start playing: - expect(onRequest).toHaveBeenNthCalledWith(2, 'post', expect.stringContaining('port/my_port/trigger/START')) - // Check that play worked - expect(onRequest).toHaveBeenNthCalledWith(3, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Plan to stop at end of clip - expect(onRequest).toHaveBeenNthCalledWith( - 4, - 'post', - expect.stringContaining('/default/server/1100/port/my_port/trigger/STOP?offset=1999') - ) - - clearMocks() - commandReceiver0.mockClear() - await mockTime.advanceTimeToTicks(30000) - expect(commandReceiver0).toHaveBeenCalledTimes(0) - expect(onRequest).toHaveBeenCalledTimes(0) - - // Stop playing - myConductor.setTimelineAndMappings([]) - - // Time to stop playing - await mockTime.advanceTimeTicks(100) - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 30005, - expect.objectContaining({ - type: QuantelCommandType.CLEARCLIP, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(1) - // Clear port from clip (reset port) - expect(onRequest).toHaveBeenNthCalledWith(1, 'post', expect.stringContaining('port/my_port/reset')) - - await myConductor.destroy() - - expect(errorHandler).toHaveBeenCalledTimes(0) - expect(deviceErrorHandler).toHaveBeenCalledTimes(0) - }) - test('Preload fragments from non-existing clip (retry)', async () => { - const { commandReceiver0, myConductor, errorHandler, deviceErrorHandler } = await setupDefaultQuantelDeviceForTest() - - quantelServer.noClipsFound = true + [ + { + command: { + type: QuantelCommandType.LOADCLIPFRAGMENTS, + time: 2990, + portId: 'port0', + timelineObjId: 'obj1', + fromLookahead: false, + clip: { + title: 'test0', + playing: true, + playTime: 3000, + }, + timeOfPlay: 3000, + allowedToPrepareJump: true, + }, + context: 'Load from current state', + tlObjId: 'obj1', + }, + { + command: { + type: QuantelCommandType.PLAYCLIP, + time: 3000, + portId: 'port0', + timelineObjId: 'obj1', + fromLookahead: false, + clip: { + title: 'test0', + playing: true, + playTime: 3000, + }, + mode: QuantelControlMode.QUALITY, + transition: undefined, + }, + context: 'New clip is playing', + tlObjId: 'obj1', + }, + ] + ) + }) - myConductor.setTimelineAndMappings([ - { - id: 'video0', - enable: { - while: 1, + test('Play after play', async () => { + await compareStates( + { + time: 2000, + port: { + port0: { + timelineObjId: 'obj0', + mode: QuantelControlMode.QUALITY, + notOnAir: false, + lookahead: false, + clip: { + title: 'test0', + playing: true, + playTime: 2000, + }, + channels: [2], + }, + }, }, - content: { - deviceType: DeviceType.QUANTEL, - title: 'myClip0', + { + time: 3000, + port: { + port0: { + timelineObjId: 'obj1', + mode: QuantelControlMode.QUALITY, + notOnAir: false, + lookahead: false, + clip: { + title: 'test1', + playing: true, + playTime: 3000, + }, + channels: [2], + }, + }, }, - layer: 'lookahead_myLayer0', - isLookahead: true, - lookaheadForLayer: 'myLayer0', - }, - ]) - // Time to preload the clip - await mockTime.advanceTimeToTicks(10990) - - expect(commandReceiver0).toHaveBeenCalledTimes(2) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 10105, - expect.objectContaining({ - type: QuantelCommandType.LOADCLIPFRAGMENTS, - clip: expect.objectContaining({ - title: 'myClip0', - }), - }), - expect.any(String), - expect.any(String) - ) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 2, - 10110, - expect.objectContaining({ - type: QuantelCommandType.PAUSECLIP, - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(2) - - // Search for and get clip info: - expect(onRequest).toHaveBeenNthCalledWith(1, 'get', expect.stringContaining('/default/clip?Title=%22myClip0%22')) - expect(onRequest).toHaveBeenNthCalledWith(2, 'get', expect.stringContaining('/default/clip?Title=%22myClip0%22')) - - clearMocks() - commandReceiver0.mockClear() - await mockTime.advanceTimeTicks(25000) - - // there should have been two more attempts: - expect(onRequest).toHaveBeenCalledTimes(2) - expect(onRequest).toHaveBeenNthCalledWith(1, 'get', expect.stringContaining('/default/clip?Title=%22myClip0%22')) - expect(onRequest).toHaveBeenNthCalledWith(2, 'get', expect.stringContaining('/default/clip?Title=%22myClip0%22')) - - clearMocks() - commandReceiver0.mockClear() - quantelServer.noClipsFound = false - await mockTime.advanceTimeTicks(10000) - - expect(onRequest).toHaveBeenCalledTimes(8) - - // Search for and get clip info: - expect(onRequest).toHaveBeenNthCalledWith(1, 'get', expect.stringContaining('/default/clip?Title=%22myClip0%22')) - expect(onRequest).toHaveBeenNthCalledWith(2, 'get', expect.stringContaining('/default/clip/1337')) - // Fetch fragments: - expect(onRequest).toHaveBeenNthCalledWith(3, 'get', expect.stringContaining('clip/1337/fragments')) - // get port info - expect(onRequest).toHaveBeenNthCalledWith(4, 'get', expect.stringContaining('default/server/1100/port/my_port')) - // Load fragments - expect(onRequest).toHaveBeenNthCalledWith(5, 'post', expect.stringContaining('port/my_port/fragments')) - - // Jump: - expect(onRequest).toHaveBeenNthCalledWith(6, 'put', expect.stringContaining('port/my_port/jump?offset=0')) - expect(onRequest).toHaveBeenNthCalledWith(7, 'post', expect.stringContaining('port/my_port/trigger/STOP')) - expect(onRequest).toHaveBeenNthCalledWith(8, 'post', expect.stringContaining('port/my_port/trigger/JUMP')) - - expect(errorHandler).toHaveBeenCalledTimes(0) - expect(deviceErrorHandler).toHaveBeenCalledTimes(0) + [ + { + command: { + type: QuantelCommandType.LOADCLIPFRAGMENTS, + time: 2990, + portId: 'port0', + timelineObjId: 'obj1', + fromLookahead: false, + clip: { + title: 'test1', + playing: true, + playTime: 3000, + }, + timeOfPlay: 3000, + allowedToPrepareJump: true, + }, + context: 'Load from current state', + tlObjId: 'obj1', + }, + { + command: { + type: QuantelCommandType.PLAYCLIP, + time: 3000, + portId: 'port0', + timelineObjId: 'obj1', + fromLookahead: false, + clip: { + title: 'test1', + playing: true, + playTime: 3000, + }, + mode: QuantelControlMode.QUALITY, + transition: undefined, + }, + context: 'New clip is playing', + tlObjId: 'obj1', + }, + ] + ) + }) - clearMocks() - commandReceiver0.mockClear() + test('Clear', async () => { + await compareStates( + { + time: 2000, + port: { + port0: { + timelineObjId: 'obj0', + mode: QuantelControlMode.QUALITY, + notOnAir: false, + lookahead: false, + clip: { + title: 'test0', + playing: true, + playTime: 2000, + }, + channels: [2], + }, + }, + }, + { time: 3000, port: {} }, + [ + { + command: { + type: QuantelCommandType.RELEASEPORT, + time: 2990, + portId: 'port0', + timelineObjId: 'obj0', + fromLookahead: false, + }, + tlObjId: 'obj0', + context: 'Port does not exist in new state', + }, + ] + ) + }) }) - test('Rename a port', async () => { - const { commandReceiver0, myConductor, errorHandler, deviceErrorHandler } = await setupDefaultQuantelDeviceForTest() - - const mappings = myConductor.mapping - const mapping = mappings['myLayer0'] as Mapping - expect(mapping).toBeTruthy() - - // Rename the port to something else - mapping.options.portId = 'myNewPort' - - myConductor.setTimelineAndMappings([], mappings) - await mockTime.advanceTimeTicks(50) - - // console.log('commandReceiver0', commandReceiver0.mock.calls) - expect(commandReceiver0).toHaveBeenCalledTimes(2) - - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 10105, - expect.objectContaining({ - type: QuantelCommandType.RELEASEPORT, - portId: 'my_port', - }), - expect.any(String), - expect.any(String) - ) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 2, - 10105, - expect.objectContaining({ - type: QuantelCommandType.SETUPPORT, - portId: 'myNewPort', - }), - expect.any(String), - expect.any(String) - ) - - expect(onRequest).toHaveBeenCalledTimes(1) - expect(onRequest).toHaveBeenNthCalledWith(1, 'delete', expect.stringContaining('/default/server/1100/port/my_port')) - - commandReceiver0.mockClear() - onRequest.mockClear() - await mockTime.advanceTimeTicks(500) - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(commandReceiver0).toHaveBeenNthCalledWith( - 1, - 10205, // should be ca 100ms after the previous call - expect.objectContaining({ - type: QuantelCommandType.CLEARCLIP, - portId: 'myNewPort', - }), - expect.any(String), - expect.any(String) - ) + describe('sendCommand', () => { + const mockTime = new MockTime() + beforeAll(() => { + mockTime.init() + }) - expect(onRequest).toHaveBeenCalledTimes(3) - expect(onRequest).toHaveBeenNthCalledWith(1, 'get', expect.stringContaining('/default/server/1100/port/myNewPort')) - expect(onRequest).toHaveBeenNthCalledWith( - 2, - 'put', - expect.stringContaining('/default/server/1100/port/myNewPort/channel/2') - ) - expect(onRequest).toHaveBeenNthCalledWith( - 3, - 'post', - expect.stringContaining('/default/server/1100/port/myNewPort/reset') - ) + test('sequence of commands', async () => { + // note - the internals of the QuantelManager class are state-based so it's easier to do all of this in one long test + const dev = await getInitialisedQuantelDevice() + + dev + .sendCommand({ + command: { + type: QuantelCommandType.SETUPPORT, + time: 990, + portId: 'my_port', + timelineObjId: 'obj0', + channel: 2, + }, + context: 'Old state did not have port', + tlObjId: 'obj0', + }) + .catch((e) => { + throw e + }) + + // give it some time to settle + await mockTime.tick() + + expect(onRequest).toHaveBeenCalledTimes(5) + + // Connect to ISA + expect(onRequest).toHaveBeenNthCalledWith(1, 'post', 'http://localhost:3000/connect/myISA%3A8000') + // get initial server info + expect(onRequest).toHaveBeenNthCalledWith(2, 'get', 'http://localhost:3000/default/server') + + // Set up port: + // get server info + expect(onRequest).toHaveBeenNthCalledWith(3, 'get', 'http://localhost:3000/default/server') + // get port info + expect(onRequest).toHaveBeenNthCalledWith(4, 'get', 'http://localhost:3000/default/server/1100/port/my_port') + // create new port and assign to channel + expect(onRequest).toHaveBeenNthCalledWith( + 5, + 'put', + 'http://localhost:3000/default/server/1100/port/my_port/channel/2' + ) + + onRequest.mockClear() + mockTime.advanceTime(2000) + + dev + .sendCommand({ + command: { + type: QuantelCommandType.LOADCLIPFRAGMENTS, + time: 2990, + portId: 'my_port', + timelineObjId: 'obj1', + fromLookahead: false, + clip: { + guid: 'abcdef872832832a2b932c97d9b2eb9', + playing: false, + playTime: null, + }, + timeOfPlay: 3000, + allowedToPrepareJump: true, + }, + context: 'Load from current state', + tlObjId: 'obj1', + }) + .catch((e) => { + throw e + }) + + // give it some time to settle + await mockTime.tick() + + expect(onRequest).toHaveBeenCalledTimes(5) + // Search for and get clip info: + expect(onRequest).toHaveBeenNthCalledWith( + 1, + 'get', + expect.stringContaining('/default/clip?ClipGUID=%22abcdef872832832a2b932c97d9b2eb9%22') + ) + expect(onRequest).toHaveBeenNthCalledWith(2, 'get', expect.stringContaining('/default/clip/1337')) + // Fetch fragments: + expect(onRequest).toHaveBeenNthCalledWith(3, 'get', expect.stringContaining('clip/1337/fragments')) + // get port info + expect(onRequest).toHaveBeenNthCalledWith(4, 'get', expect.stringContaining('default/server/1100/port/my_port')) + // Load fragments + expect(onRequest).toHaveBeenNthCalledWith(5, 'post', expect.stringContaining('port/my_port/fragments')) + + onRequest.mockClear() + + dev + .sendCommand({ + command: { + type: QuantelCommandType.PLAYCLIP, + time: 3000, + portId: 'my_port', + timelineObjId: 'obj1', + fromLookahead: false, + clip: { + guid: 'abcdef872832832a2b932c97d9b2eb9', + playing: true, + playTime: 3000, + }, + mode: QuantelControlMode.QUALITY, + transition: undefined, + }, + context: 'New clip is playing', + tlObjId: 'obj1', + }) + .catch((e) => { + throw e + }) + + // give it some time to settle + await mockTime.advanceTimeTicks(500) + + expect(onRequest).toHaveBeenCalledTimes(5) + + // prepare jump + expect(onRequest).toHaveBeenNthCalledWith(1, 'put', expect.stringContaining('port/my_port/jump?offset=225')) + // Trigger Jump + expect(onRequest).toHaveBeenNthCalledWith( + 2, + 'post', + expect.stringContaining('/default/server/1100/port/my_port/trigger/JUMP') + ) + // Trigger play + expect(onRequest).toHaveBeenNthCalledWith( + 3, + 'post', + expect.stringContaining('/default/server/1100/port/my_port/trigger/START') + ) + // Check that play worked + expect(onRequest).toHaveBeenNthCalledWith(4, 'get', expect.stringContaining('default/server/1100/port/my_port')) + // Plan to stop at end of clip + expect(onRequest).toHaveBeenNthCalledWith( + 5, + 'post', + expect.stringContaining('/default/server/1100/port/my_port/trigger/STOP?offset=1999') + ) + + onRequest.mockClear() + + dev + .sendCommand({ + command: { + type: QuantelCommandType.CLEARCLIP, + time: 3000, + portId: 'my_port', + timelineObjId: 'obj1', + fromLookahead: false, + }, + context: 'Clear', + tlObjId: 'obj1', + }) + .catch((e) => { + throw e + }) - expect(errorHandler).toHaveBeenCalledTimes(0) - expect(deviceErrorHandler).toHaveBeenCalledTimes(0) + await mockTime.tick() - clearMocks() - commandReceiver0.mockClear() + expect(onRequest).toHaveBeenCalledTimes(1) + expect(onRequest).toHaveBeenNthCalledWith(1, 'post', expect.stringContaining('port/my_port/reset')) + }) }) }) + +function createTimelineState( + objs: Record< + string, + { + id: string + content: TimelineContentQuantelAny + instance?: { originalStart: number } + isLookahead?: boolean + lookaheadForLayer?: string + } + > +): Timeline.TimelineState { + return { + time: 10, + layers: objs as any, + nextEvents: [], + } +} diff --git a/packages/timeline-state-resolver/src/integrations/quantel/diff.ts b/packages/timeline-state-resolver/src/integrations/quantel/diff.ts new file mode 100644 index 000000000..4e0b1cbd5 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/quantel/diff.ts @@ -0,0 +1,255 @@ +import { QuantelOutTransition } from 'timeline-state-resolver-types' +import { QuantelCommandWithContext } from '.' +import { QuantelCommand, QuantelCommandType, QuantelState, QuantelStatePort, QuantelStatePortClip } from './types' +import _ = require('underscore') + +const IDEAL_PREPARE_TIME = 1000 +const PREPARE_TIME_WAIT = 50 + +export function diffStates( + oldState: QuantelState | undefined, + newState: QuantelState +): Array { + const time = newState.time + const highPrioCommands: QuantelCommandWithContext[] = [] + const lowPrioCommands: QuantelCommandWithContext[] = [] + + const addCommand = (command: QuantelCommand, lowPriority: boolean, context?: string) => { + ;(lowPriority ? lowPrioCommands : highPrioCommands).push({ + command, + tlObjId: command.timelineObjId, + context: context ?? 'Context not specified..', + }) + } + const seenClips: { [identifier: string]: true } = {} + const loadFragments = ( + portId: string, + port: QuantelStatePort, + clip: QuantelStatePortClip, + timelineObjId: string, + isPreloading: boolean, + context?: string + ) => { + // Only load identical fragments once: + const clipIdentifier = `${portId}:${clip.clipId}_${clip.guid}_${clip.title}` + if (!seenClips[clipIdentifier]) { + seenClips[clipIdentifier] = true + addCommand( + { + type: QuantelCommandType.LOADCLIPFRAGMENTS, + time: prepareTime, + portId: portId, + timelineObjId: timelineObjId, + fromLookahead: isPreloading || port.lookahead, + clip: clip, + timeOfPlay: time, + allowedToPrepareJump: !isPreloading, + }, + isPreloading || port.lookahead, + context + ) + } + } + + /** The time of when to run "preparation" commands */ + const prepareTime = getPrepareTime(newState.time, oldState?.time) + + const lookaheadPreloadClips: { + portId: string + port: QuantelStatePort + clip: QuantelStatePortClip + timelineObjId: string + }[] = [] + + for (const [portId, newPort] of Object.entries(newState.port)) { + // diff existing ports + const oldPort = oldState?.port[portId] + diffPort(portId, newPort, oldPort, newState.time, prepareTime, addCommand, loadFragments, lookaheadPreloadClips) + } + + for (const [portId, oldPort] of Object.entries(oldState?.port ?? {})) { + // diff old ports that may be removed + const newPort = newState.port[portId] + if (!newPort) { + // removed port + addCommand( + { + type: QuantelCommandType.RELEASEPORT, + time: prepareTime, + portId: portId, + timelineObjId: oldPort.timelineObjId, + fromLookahead: oldPort.lookahead, + }, + oldPort.lookahead, + 'Port does not exist in new state' + ) + } + } + // Lookaheads to preload: + _.each(lookaheadPreloadClips, (lookaheadPreloadClip) => { + // Preloads of lookaheads are handled last, to ensure that any load-fragments of high-prio clips are done first. + loadFragments( + lookaheadPreloadClip.portId, + lookaheadPreloadClip.port, + lookaheadPreloadClip.clip, + lookaheadPreloadClip.timelineObjId, + true, + 'Load from lookahead' + ) + }) + + const allCommands = highPrioCommands.concat(lowPrioCommands) + + allCommands.sort((a, b) => { + // Release ports should always be done first: + if (a.command.type === QuantelCommandType.RELEASEPORT && b.command.type !== QuantelCommandType.RELEASEPORT) + return -1 + if (a.command.type !== QuantelCommandType.RELEASEPORT && b.command.type === QuantelCommandType.RELEASEPORT) return 1 + return 0 + }) + return allCommands +} + +/** The time of when to run "preparation" commands */ +function getPrepareTime(time: number, oldTime?: number): number { + let prepareTime = Math.min( + time, + Math.max( + time - IDEAL_PREPARE_TIME, + (oldTime ?? Date.now()) + PREPARE_TIME_WAIT // earliset possible prepareTime + ) + ) + if (prepareTime < Date.now()) { + // todo - is this a good usage of date.now vs getTime() + // Only to not emit an unnessesary slowCommand event + prepareTime = Date.now() + } + if (time < prepareTime) { + prepareTime = time - 10 + } + + return prepareTime +} + +/** diff an existing port */ +function diffPort( + portId: string, + newPort: QuantelStatePort, + oldPort: QuantelStatePort | undefined, + time: number, + prepareTime: number, + addCommand: (command: QuantelCommand, lowPriority: boolean, context?: string) => void, + loadFragments: ( + portId: string, + port: QuantelStatePort, + clip: QuantelStatePortClip, + timelineObjId: string, + isPreloading: boolean, + context?: string + ) => void, + lookaheadPreloadClips: { + portId: string + port: QuantelStatePort + clip: QuantelStatePortClip + timelineObjId: string + }[] +) { + if (!oldPort || !_.isEqual(newPort.channels, oldPort.channels)) { + const channel = newPort.channels[0] as number | undefined + if (channel !== undefined) { + // todo: support for multiple channels + addCommand( + { + type: QuantelCommandType.SETUPPORT, + time: prepareTime, + portId: portId, + timelineObjId: newPort.timelineObjId, + channel: channel, + }, + newPort.lookahead, + 'Old state did not have port' + ) + } + } + + if (!oldPort || !_.isEqual(newPort.clip, oldPort.clip)) { + if (newPort.clip) { + // Load (and play) the clip: + + let transition: QuantelOutTransition | undefined + + if (oldPort && !oldPort.notOnAir && newPort.notOnAir) { + // When the previous content was on-air, we use the out-transition (so that mix-effects look good). + // But when the previous content wasn't on-air, we don't wan't to use the out-transition (for example; when cuing previews) + transition = oldPort.outTransition + } + + loadFragments(portId, newPort, newPort.clip, newPort.timelineObjId, false, 'Load from current state') + if (newPort.clip.playing) { + addCommand( + { + type: QuantelCommandType.PLAYCLIP, + time: time, + portId: portId, + timelineObjId: newPort.timelineObjId, + fromLookahead: newPort.lookahead, + clip: newPort.clip, + mode: newPort.mode, + transition: transition, + }, + newPort.lookahead, + 'New clip is playing' + ) + } else { + addCommand( + { + type: QuantelCommandType.PAUSECLIP, + time: time, + portId: portId, + timelineObjId: newPort.timelineObjId, + fromLookahead: newPort.lookahead, + clip: newPort.clip, + mode: newPort.mode, + transition: transition, + }, + newPort.lookahead, + 'New clip is paused' + ) + } + } else { + addCommand( + { + type: QuantelCommandType.CLEARCLIP, + time: time, + portId: portId, + timelineObjId: newPort.timelineObjId, + fromLookahead: newPort.lookahead, + transition: oldPort && oldPort.outTransition, + }, + newPort.lookahead, + 'New clip is empty' + ) + } + } + if (!oldPort || !_.isEqual(newPort.lookaheadClip, oldPort.lookaheadClip)) { + if ( + newPort.lookaheadClip && + (!newPort.clip || + newPort.lookaheadClip.clipId !== newPort.clip.clipId || + newPort.lookaheadClip.title !== newPort.clip.title || + newPort.lookaheadClip.guid !== newPort.clip.guid) + ) { + // Also preload lookaheads later: + lookaheadPreloadClips.push({ + portId: portId, + port: newPort, + clip: { + ...newPort.lookaheadClip, + playTime: 0, + playing: false, + }, + timelineObjId: newPort.lookaheadClip.timelineObjId, + }) + } + } +} diff --git a/packages/timeline-state-resolver/src/integrations/quantel/index.ts b/packages/timeline-state-resolver/src/integrations/quantel/index.ts index 9e8a4e61d..d1f3dc57a 100644 --- a/packages/timeline-state-resolver/src/integrations/quantel/index.ts +++ b/packages/timeline-state-resolver/src/integrations/quantel/index.ts @@ -1,81 +1,56 @@ -import * as _ from 'underscore' -import { DeviceWithState, CommandWithContext, DeviceStatus, StatusCode } from './../../devices/device' - import { - DeviceType, - Mapping, - SomeMappingQuantel, - QuantelOptions, - TimelineContentQuantelClip, - QuantelControlMode, - ResolvedTimelineObjectInstanceExtended, - QuantelOutTransition, - DeviceOptionsQuantel, + ActionExecutionResult, + ActionExecutionResultCode, + DeviceStatus, Mappings, + OSCMessageCommandContent, + QuantelActions, + QuantelOptions, + SomeMappingQuantel, + StatusCode, Timeline, TSRTimelineContent, - QuantelActions, - ActionExecutionResultCode, - QuantelActionExecutionPayload, - QuantelActionExecutionResult, } from 'timeline-state-resolver-types' +import { CommandWithContext, Device, DeviceEvents } from '../../service/device' -import { DoOnTime, SendMode } from '../../devices/doOnTime' +import Debug from 'debug' +import EventEmitter = require('eventemitter3') +import { QuantelCommand, QuantelCommandType, QuantelState } from './types' import { QuantelGateway } from 'tv-automation-quantel-gateway-client' -import { startTrace, endTrace, actionNotFoundMessage } from '../../lib' import { QuantelManager } from './connection' -import { - QuantelCommand, - QuantelState, - MappedPorts, - QuantelStatePortClip, - QuantelCommandType, - QuantelStatePort, -} from './types' -export { QuantelCommandType } - -const IDEAL_PREPARE_TIME = 1000 -const PREPARE_TIME_WAIT = 50 +import { convertTimelineStateToQuantelState } from './state' +import { diffStates } from './diff' +const debug = Debug('timeline-state-resolver:quantel') -export interface DeviceOptionsQuantelInternal extends DeviceOptionsQuantel { - commandReceiver?: CommandReceiver +export interface OscDeviceState { + [address: string]: OSCDeviceStateContent +} +interface OSCDeviceStateContent extends OSCMessageCommandContent { + fromTlObject: string } -export type CommandReceiver = ( - time: number, - cmd: QuantelCommand, - context: string, - timelineObjId: string -) => Promise -/** - * This class is used to interface with a Quantel-gateway, - * https://github.com/nrkno/tv-automation-quantel-gateway - * - * This device behaves a little bit different than the others, because a play-command is - * a two-step rocket. - * This is why the commands generated by the state-diff is not one-to-one related to the - * actual commands sent to the Quantel-gateway. - */ -export class QuantelDevice extends DeviceWithState { - private _quantel: QuantelGateway - private _quantelManager: QuantelManager - private _commandReceiver: CommandReceiver +export interface QuantelCommandWithContext { + command: QuantelCommand + context: string + tlObjId: string +} - private _doOnTime: DoOnTime - private _doOnTimeBurst: DoOnTime - private _initOptions?: QuantelOptions +export class QuantelDevice + extends EventEmitter + implements Device +{ + // TODO - monitor ports: this._quantel.setMonitoredPorts(this._getMappedPorts(newMappings)) - constructor(deviceId: string, deviceOptions: DeviceOptionsQuantelInternal, getCurrentTime: () => Promise) { - super(deviceId, deviceOptions, getCurrentTime) + private _quantel: QuantelGateway + private _quantelManager: QuantelManager - if (deviceOptions.options) { - if (deviceOptions.commandReceiver) this._commandReceiver = deviceOptions.commandReceiver - else this._commandReceiver = this._defaultCommandReceiver.bind(this) - } + async init(options: QuantelOptions): Promise { this._quantel = new QuantelGateway() this._quantel.on('error', (e) => this.emit('error', 'Quantel.QuantelGateway', e)) - this._quantelManager = new QuantelManager(this._quantel, () => this.getCurrentTime(), { - allowCloneClips: deviceOptions.options?.allowCloneClips, + // this._quantelManager = new QuantelManager(this._quantel, () => this.getCurrentTime(), { + // todo - obv + this._quantelManager = new QuantelManager(this._quantel, () => Date.now(), { + allowCloneClips: options.allowCloneClips, }) this._quantelManager.on('info', (x) => this.emit('info', `Quantel: ${typeof x === 'string' ? x : JSON.stringify(x)}`) @@ -84,292 +59,78 @@ export class QuantelDevice extends DeviceWithState this.emit('error', 'Quantel: ', e)) - this._quantelManager.on('debug', (...args) => this.emitDebug(...args)) + this._quantelManager.on('debug', (...args) => this.emit('debug', ...args)) - this._doOnTime = new DoOnTime( - () => { - return this.getCurrentTime() - }, - SendMode.IN_ORDER, - this._deviceOptions - ) - this.handleDoOnTime(this._doOnTime, 'Quantel') - - this._doOnTimeBurst = new DoOnTime( - () => { - return this.getCurrentTime() - }, - SendMode.BURST, - this._deviceOptions - ) - this.handleDoOnTime(this._doOnTimeBurst, 'Quantel.burst') - } - - async init(initOptions: QuantelOptions): Promise { - this._initOptions = initOptions - const ISAUrlMaster: string = this._initOptions.ISAUrlMaster || this._initOptions['ISAUrl'] // tmp: ISAUrl for backwards compatibility, to be removed later - if (!this._initOptions.gatewayUrl) throw new Error('Quantel bad connection option: gatewayUrl') + const ISAUrlMaster: string = options.ISAUrlMaster || options['ISAUrl'] // tmp: ISAUrl for backwards compatibility, to be removed later + if (!options.gatewayUrl) throw new Error('Quantel bad connection option: gatewayUrl') if (!ISAUrlMaster) throw new Error('Quantel bad connection option: ISAUrlMaster') - if (!this._initOptions.serverId) throw new Error('Quantel bad connection option: serverId') + if (!options.serverId) throw new Error('Quantel bad connection option: serverId') const isaURLs: string[] = [] if (ISAUrlMaster) isaURLs.push(ISAUrlMaster) - if (this._initOptions.ISAUrlBackup) isaURLs.push(this._initOptions.ISAUrlBackup) + if (options.ISAUrlBackup) isaURLs.push(options.ISAUrlBackup) - await this._quantel.init( - this._initOptions.gatewayUrl, - isaURLs, - this._initOptions.zoneId, - this._initOptions.serverId - ) + await this._quantel.init(options.gatewayUrl, isaURLs, options.zoneId, options.serverId) // todo - maybe not to be awaited... this._quantel.monitorServerStatus((_connected: boolean) => { - this._connectionChanged() + this.emit('connectionChanged', this.getStatus()) }) - return true + return Promise.resolve(true) } - - /** - * Terminates the device safely such that things can be garbage collected. - */ async terminate(): Promise { this._quantel.dispose() - this._doOnTime.dispose() } - /** 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) - } - /** - * Generates an array of Quantel commands by comparing the newState against the oldState, or the current device state. - */ - handleState(newState: Timeline.TimelineState, newMappings: Mappings) { - super.onHandleState(newState, newMappings) - // check if initialized: - if (!this._quantel.initialized) { - this.emit('warning', 'Quantel not initialized yet') - return - } - - this._quantel.setMonitoredPorts(this._getMappedPorts(newMappings)) - - const previousStateTime = Math.max(this.getCurrentTime(), newState.time) - - const oldQuantelState: QuantelState = (this.getStateBefore(previousStateTime) || { state: { time: 0, port: {} } }) - .state - - const convertTrace = startTrace(`device:convertState`, { deviceId: this.deviceId }) - const newQuantelState = this.convertStateToQuantel(newState, newMappings) - this.emit('timeTrace', endTrace(convertTrace)) - // let oldQuantelState = this.convertStateToQuantel(oldState) - const diffTrace = startTrace(`device:diffState`, { deviceId: this.deviceId }) - const commandsToAchieveState = this._diffStates(oldQuantelState, newQuantelState, newState.time) - this.emit('timeTrace', endTrace(diffTrace)) - - // clear any queued commands later than this time: - this._doOnTime.clearQueueNowAndAfter(previousStateTime) - - // add the new commands to the queue - this._addToQueue(commandsToAchieveState) - - // store the new state, for later use: - this.setState(newQuantelState, newState.time) + convertTimelineStateToDeviceState( + timelineState: Timeline.TimelineState, + mappings: Mappings + ): QuantelState { + return convertTimelineStateToQuantelState(timelineState, mappings) } - - /** - * Attempts to restart the gateway - */ - private async restartGateway() { - if (this._quantel.connected) { - return this._quantel.kill() - } else { - throw new Error('Quantel Gateway not connected') - } + diffStates(oldState: QuantelState | undefined, newState: QuantelState): Array { + return diffStates(oldState, newState) } - async executeAction( - actionId: A, - _payload: QuantelActionExecutionPayload - ): Promise> { - switch (actionId) { - case QuantelActions.RestartGateway: - try { - await this.restartGateway() - return { result: ActionExecutionResultCode.Ok } - } catch { - return { result: ActionExecutionResultCode.Error } - } - case QuantelActions.ClearStates: - this.clearStates() - return { result: ActionExecutionResultCode.Ok } - default: - return actionNotFoundMessage(actionId) + async sendCommand({ command, context, tlObjId }: QuantelCommandWithContext): Promise { + const cwc: CommandWithContext = { + context: context, + command: command, + tlObjId: tlObjId, } - } - - /** - * Clear any scheduled commands after this time - * @param clearAfterTime - */ - clearFuture(clearAfterTime: number) { - this._doOnTime.clearQueueAfter(clearAfterTime) - } - get canConnect(): boolean { - return true - } - get connected(): boolean { - return this._quantel.connected - } + this.emit('debug', cwc) + debug(command) - get deviceType() { - return DeviceType.QUANTEL - } - get deviceName(): string { try { - return `Quantel ${this._quantel.ISAUrl}/${this._quantel.zoneId}/${this._quantel.serverId}` + const cmdType = command.type + if (command.type === QuantelCommandType.SETUPPORT) { + await this._quantelManager.setupPort(command) + } else if (command.type === QuantelCommandType.RELEASEPORT) { + await this._quantelManager.releasePort(command) + } else if (command.type === QuantelCommandType.LOADCLIPFRAGMENTS) { + await this._quantelManager.tryLoadClipFragments(command) + } else if (command.type === QuantelCommandType.PLAYCLIP) { + await this._quantelManager.playClip(command) + } else if (command.type === QuantelCommandType.PAUSECLIP) { + await this._quantelManager.pauseClip(command) + } else if (command.type === QuantelCommandType.CLEARCLIP) { + await this._quantelManager.clearClip(command) + } else { + throw new Error(`Unsupported command type "${cmdType}"`) + } } catch (e) { - return `Quantel device (uninitialized)` - } - } - - get queue() { - return this._doOnTime.getQueue() - } - private _getMappedPorts(mappings: Mappings): MappedPorts { - const ports: MappedPorts = {} - - _.each(mappings, (mapping) => { - if ( - mapping && - mapping.device === DeviceType.QUANTEL && - mapping.deviceId === this.deviceId && - _.has(mapping.options, 'portId') && - _.has(mapping.options, 'channelId') - ) { - const qMapping = mapping as Mapping - - if (!ports[qMapping.options.portId]) { - ports[qMapping.options.portId] = { - mode: qMapping.options.mode || QuantelControlMode.QUALITY, - channels: [], - } - } - - ports[qMapping.options.portId].channels = _.sortBy( - _.uniq(ports[qMapping.options.portId].channels.concat([qMapping.options.channelId])) - ) + const error = e as Error + let errorString = error && error.message ? error.message : error.toString() + if (error?.stack) { + errorString += error.stack } - }) - return ports - } - - /** - * Takes a timeline state and returns a Quantel State that will work with the state lib. - * @param timelineState The timeline state to generate from. - */ - convertStateToQuantel(timelineState: Timeline.TimelineState, mappings: Mappings): QuantelState { - const state: QuantelState = { - time: timelineState.time, - port: {}, + this.emit('commandError', new Error(errorString), cwc) } - // create ports from mappings: - - _.each(this._getMappedPorts(mappings), (port, portId: string) => { - state.port[portId] = { - channels: port.channels, - timelineObjId: '', - mode: port.mode, - lookahead: false, - } - }) - - _.each(timelineState.layers, (layer, layerName: string) => { - const layerExt: ResolvedTimelineObjectInstanceExtended = layer - let foundMapping = mappings[layerName] - - let isLookahead = false - if (!foundMapping && layerExt.isLookahead && layerExt.lookaheadForLayer) { - foundMapping = mappings[layerExt.lookaheadForLayer] - isLookahead = true - } - - if ( - foundMapping && - foundMapping.device === DeviceType.QUANTEL && - foundMapping.deviceId === this.deviceId && - _.has(foundMapping.options, 'portId') && - _.has(foundMapping.options, 'channelId') - ) { - const mapping = foundMapping as Mapping | undefined - if (!mapping) throw new Error(`Mapping "${layerName}" not found`) - - const port: QuantelStatePort = state.port[mapping.options.portId] - if (!port) throw new Error(`Port "${mapping.options.portId}" not found`) - - const content = layer.content as TimelineContentQuantelClip - if (content && (content.title || content.guid)) { - // Note on lookaheads: - // If there is ONLY a lookahead on a port, it'll be treated as a "paused (real) clip" - // If there is a lookahead alongside the a real clip, its fragments will be preloaded - - if (isLookahead) { - port.lookaheadClip = { - title: content.title, - guid: content.guid, - timelineObjId: layer.id, - } - } - - if (isLookahead && port.clip) { - // There is already a non-lookahead on the port - // Do nothing more with this then - } else { - const startTime = layer.instance.originalStart || layer.instance.start - - port.timelineObjId = layer.id - port.notOnAir = content.notOnAir || isLookahead - port.outTransition = content.outTransition - port.lookahead = isLookahead - - port.clip = { - title: content.title, - guid: content.guid, - // clipId // set later - - pauseTime: content.pauseTime, - playing: isLookahead ? false : content.playing ?? true, - - inPoint: content.inPoint, - length: content.length, - - playTime: (content.noStarttime || isLookahead ? null : startTime) || null, - } - } - } - } - }) - - return state } - /** - * Prepares the physical device for playout. - * @param okToDestroyStuff Whether it is OK to do things that affects playout visibly - */ - async makeReady(okToDestroyStuff?: boolean): Promise { - if (okToDestroyStuff) { - // release and re-claim all ports: - // TODO - } - // reset our own state(s): - if (okToDestroyStuff) { - this.clearStates() - } + get connected(): boolean { + return this._quantel.connected } - getStatus(): DeviceStatus { + getStatus(): Omit { let statusCode = StatusCode.GOOD const messages: Array = [] @@ -390,288 +151,26 @@ export class QuantelDevice extends DeviceWithState { - const highPrioCommands: QuantelCommand[] = [] - const lowPrioCommands: QuantelCommand[] = [] - - const addCommand = (command: QuantelCommand, lowPriority: boolean) => { - ;(lowPriority ? lowPrioCommands : highPrioCommands).push(command) - } - const seenClips: { [identifier: string]: true } = {} - const loadFragments = ( - portId: string, - port: QuantelStatePort, - clip: QuantelStatePortClip, - timelineObjId: string, - isPreloading: boolean - ) => { - // Only load identical fragments once: - const clipIdentifier = `${portId}:${clip.clipId}_${clip.guid}_${clip.title}` - if (!seenClips[clipIdentifier]) { - seenClips[clipIdentifier] = true - addCommand( - { - type: QuantelCommandType.LOADCLIPFRAGMENTS, - time: prepareTime, - portId: portId, - timelineObjId: timelineObjId, - fromLookahead: isPreloading || port.lookahead, - clip: clip, - timeOfPlay: time, - allowedToPrepareJump: !isPreloading, - }, - isPreloading || port.lookahead - ) - } - } - - /** The time of when to run "preparation" commands */ - let prepareTime = Math.min( - time, - Math.max( - time - IDEAL_PREPARE_TIME, - oldState.time + PREPARE_TIME_WAIT // earliset possible prepareTime - ) - ) - if (prepareTime < this.getCurrentTime()) { - // Only to not emit an unnessesary slowCommand event - prepareTime = this.getCurrentTime() - } - if (time < prepareTime) { - prepareTime = time - 10 - } - - const lookaheadPreloadClips: { - portId: string - port: QuantelStatePort - clip: QuantelStatePortClip - timelineObjId: string - }[] = [] - - _.each(newState.port, (newPort: QuantelStatePort, portId: string) => { - const oldPort = oldState.port[portId] - if (!oldPort || !_.isEqual(newPort.channels, oldPort.channels)) { - const channel = newPort.channels[0] as number | undefined - if (channel !== undefined) { - // todo: support for multiple channels - addCommand( - { - type: QuantelCommandType.SETUPPORT, - time: prepareTime, - portId: portId, - timelineObjId: newPort.timelineObjId, - channel: channel, - }, - newPort.lookahead - ) - } + actions: Record Promise> = { + [QuantelActions.ClearStates]: async () => { + this.emit('resetResolver') + return { + result: ActionExecutionResultCode.Ok, } - - if (!oldPort || !_.isEqual(newPort.clip, oldPort.clip)) { - if (newPort.clip) { - // Load (and play) the clip: - - let transition: QuantelOutTransition | undefined - - if (oldPort && !oldPort.notOnAir && newPort.notOnAir) { - // When the previous content was on-air, we use the out-transition (so that mix-effects look good). - // But when the previous content wasn't on-air, we don't wan't to use the out-transition (for example; when cuing previews) - transition = oldPort.outTransition - } - - loadFragments(portId, newPort, newPort.clip, newPort.timelineObjId, false) - if (newPort.clip.playing) { - addCommand( - { - type: QuantelCommandType.PLAYCLIP, - time: time, - portId: portId, - timelineObjId: newPort.timelineObjId, - fromLookahead: newPort.lookahead, - clip: newPort.clip, - mode: newPort.mode, - transition: transition, - }, - newPort.lookahead - ) - } else { - addCommand( - { - type: QuantelCommandType.PAUSECLIP, - time: time, - portId: portId, - timelineObjId: newPort.timelineObjId, - fromLookahead: newPort.lookahead, - clip: newPort.clip, - mode: newPort.mode, - transition: transition, - }, - newPort.lookahead - ) - } - } else { - addCommand( - { - type: QuantelCommandType.CLEARCLIP, - time: time, - portId: portId, - timelineObjId: newPort.timelineObjId, - fromLookahead: newPort.lookahead, - transition: oldPort && oldPort.outTransition, - }, - newPort.lookahead - ) - } - } - if (!oldPort || !_.isEqual(newPort.lookaheadClip, oldPort.lookaheadClip)) { - if ( - newPort.lookaheadClip && - (!newPort.clip || - newPort.lookaheadClip.clipId !== newPort.clip.clipId || - newPort.lookaheadClip.title !== newPort.clip.title || - newPort.lookaheadClip.guid !== newPort.clip.guid) - ) { - // Also preload lookaheads later: - lookaheadPreloadClips.push({ - portId: portId, - port: newPort, - clip: { - ...newPort.lookaheadClip, - playTime: 0, - playing: false, - }, - timelineObjId: newPort.lookaheadClip.timelineObjId, - }) + }, + [QuantelActions.RestartGateway]: async () => { + if (this._quantel) { + try { + await this._quantel.kill() + return { result: ActionExecutionResultCode.Ok } + } catch (e) { + this.emit('error', 'Error killing quantel gateway', new Error(e as any)) // todo - what to do here... } } - }) - - _.each(oldState.port, (oldPort: QuantelStatePort, portId: string) => { - const newPort = newState.port[portId] - if (!newPort) { - // removed port - addCommand( - { - type: QuantelCommandType.RELEASEPORT, - time: prepareTime, - portId: portId, - timelineObjId: oldPort.timelineObjId, - fromLookahead: oldPort.lookahead, - }, - oldPort.lookahead - ) - } - }) - // console.log('lookaheadPreloadClips', lookaheadPreloadClips) - // Lookaheads to preload: - _.each(lookaheadPreloadClips, (lookaheadPreloadClip) => { - // Preloads of lookaheads are handled last, to ensure that any load-fragments of high-prio clips are done first. - loadFragments( - lookaheadPreloadClip.portId, - lookaheadPreloadClip.port, - lookaheadPreloadClip.clip, - lookaheadPreloadClip.timelineObjId, - true - ) - }) - - const allCommands = highPrioCommands.concat(lowPrioCommands) - - allCommands.sort((a, b) => { - // Release ports should always be done first: - if (a.type === QuantelCommandType.RELEASEPORT && b.type !== QuantelCommandType.RELEASEPORT) return -1 - if (a.type !== QuantelCommandType.RELEASEPORT && b.type === QuantelCommandType.RELEASEPORT) return 1 - return 0 - }) - return allCommands - } - private async _doCommand(command: QuantelCommand, context: string, timlineObjId: string): Promise { - const time = this.getCurrentTime() - return this._commandReceiver(time, command, context, timlineObjId) - } - /** - * Add commands to queue, to be executed at the right time - */ - private _addToQueue(commandsToAchieveState: Array) { - _.each(commandsToAchieveState, (cmd: QuantelCommand) => { - this._doOnTime.queue( - cmd.time, - cmd.portId, - async (c: { cmd: QuantelCommand }) => { - return this._doCommand(c.cmd, c.cmd.type + '_' + c.cmd.timelineObjId, c.cmd.timelineObjId) - }, - { cmd: cmd } - ) - - this._doOnTimeBurst.queue( - cmd.time, - undefined, - async (c: { cmd: QuantelCommand }) => { - if ( - (c.cmd.type === QuantelCommandType.PLAYCLIP || c.cmd.type === QuantelCommandType.PAUSECLIP) && - !c.cmd.fromLookahead - ) { - this._quantelManager.clearAllWaitWithPort(c.cmd.portId) - } - return Promise.resolve() - }, - { cmd: cmd } - ) - }) - } - /** - * Sends commands to the Quantel ISA server - * @param time deprecated - * @param cmd Command to execute - */ - private async _defaultCommandReceiver( - _time: number, - cmd: QuantelCommand, - context: string, - timelineObjId: string - ): Promise { - const cwc: CommandWithContext = { - context: context, - timelineObjId: timelineObjId, - command: cmd, - } - this.emitDebug(cwc) - - try { - const cmdType = cmd.type - if (cmd.type === QuantelCommandType.SETUPPORT) { - await this._quantelManager.setupPort(cmd) - } else if (cmd.type === QuantelCommandType.RELEASEPORT) { - await this._quantelManager.releasePort(cmd) - } else if (cmd.type === QuantelCommandType.LOADCLIPFRAGMENTS) { - await this._quantelManager.tryLoadClipFragments(cmd) - } else if (cmd.type === QuantelCommandType.PLAYCLIP) { - await this._quantelManager.playClip(cmd) - } else if (cmd.type === QuantelCommandType.PAUSECLIP) { - await this._quantelManager.pauseClip(cmd) - } else if (cmd.type === QuantelCommandType.CLEARCLIP) { - await this._quantelManager.clearClip(cmd) - this.getCurrentTime() - } else { - throw new Error(`Unsupported command type "${cmdType}"`) - } - } catch (e) { - const error = e as Error - let errorString = error && error.message ? error.message : error.toString() - if (error?.stack) { - errorString += error.stack - } - this.emit('commandError', new Error(errorString), cwc) - } - } - private _connectionChanged() { - this.emit('connectionChanged', this.getStatus()) + return { result: ActionExecutionResultCode.Error } + }, } } diff --git a/packages/timeline-state-resolver/src/integrations/quantel/state.ts b/packages/timeline-state-resolver/src/integrations/quantel/state.ts new file mode 100644 index 000000000..7813a3150 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/quantel/state.ts @@ -0,0 +1,150 @@ +import { + Mapping, + MappingQuantelPort, + Mappings, + QuantelControlMode, + ResolvedTimelineObjectInstanceExtended, + SomeMappingQuantel, + TSRTimelineContent, + Timeline, + TimelineContentQuantelClip, +} from 'timeline-state-resolver-types' +import { MappedPorts, QuantelState, QuantelStatePort } from './types' + +export function getMappedPorts(mappings: Mappings): MappedPorts { + const ports: MappedPorts = {} + + for (const mapping of Object.values>(mappings)) { + if (mapping && 'portId' in mapping.options && 'channelId' in mapping.options) { + if (!ports[mapping.options.portId]) { + ports[mapping.options.portId] = { + mode: mapping.options.mode || QuantelControlMode.QUALITY, + channels: [], + } + } + + // push now, sort later + ports[mapping.options.portId].channels.push(mapping.options.channelId) + } + } + + // now sort in place + for (const port of Object.values(ports)) { + port.channels.sort() + } + + return ports +} + +export function convertTimelineStateToQuantelState( + timelineState: Timeline.TimelineState, + mappings: Mappings +): QuantelState { + const state: QuantelState = { + time: timelineState.time, + port: {}, + } + + // create ports from mappings: + createPortsFromMappings(state, getMappedPorts(mappings)) + + // merge timeline layer states into port states + for (const [layerName, layer] of Object.entries>( + timelineState.layers + )) { + const { foundMapping, isLookahead } = getMappingForLayer(layer, mappings, layerName) + + if (foundMapping && 'portId' in foundMapping.options && 'channelId' in foundMapping.options) { + // mapping exists + const port: QuantelStatePort = state.port[foundMapping.options.portId] + if (!port) throw new Error(`Port "${foundMapping.options.portId}" not found`) + // port exists + + const content = layer.content as TimelineContentQuantelClip + if (content && (content.title || content.guid)) { + // content exists and has title or guid + setPortStateFromLayer(port, isLookahead, content, layer) + } + } + } + + return state +} + +/** Creates port on state object from mappedPorts */ +function createPortsFromMappings(state: QuantelState, mappedPorts: MappedPorts) { + for (const [portId, port] of Object.entries(mappedPorts)) { + state.port[portId] = { + channels: port.channels, + timelineObjId: '', + mode: port.mode, + lookahead: false, + } + } +} + +/** finds the correct mapping for a layer state and if the state is for a lookahead */ +function getMappingForLayer( + layerExt: ResolvedTimelineObjectInstanceExtended, + mappings: Mappings, + layerName: string +): { + foundMapping: Mapping | undefined + isLookahead: boolean +} { + let foundMapping = mappings[layerName] + + let isLookahead = false + if (!foundMapping && layerExt.isLookahead && layerExt.lookaheadForLayer) { + foundMapping = mappings[layerExt.lookaheadForLayer] + isLookahead = true + } + + return { foundMapping, isLookahead } +} + +/** merges a layer state into a port state */ +function setPortStateFromLayer( + port: QuantelStatePort, + isLookahead: boolean, + content: TimelineContentQuantelClip, + layer: ResolvedTimelineObjectInstanceExtended +) { + // Note on lookaheads: + // If there is ONLY a lookahead on a port, it'll be treated as a "paused (real) clip" + // If there is a lookahead alongside the a real clip, its fragments will be preloaded + + if (isLookahead) { + port.lookaheadClip = { + title: content.title, + guid: content.guid, + timelineObjId: layer.id, + } + } + + if (isLookahead && port.clip) { + // There is already a non-lookahead on the port + // Do nothing more with this then + } else { + const startTime = layer.instance.originalStart || layer.instance.start + + port.timelineObjId = layer.id + port.notOnAir = content.notOnAir || isLookahead + port.outTransition = content.outTransition + port.lookahead = isLookahead + + port.clip = { + title: content.title, + guid: content.guid, + // clipId // set later + + pauseTime: content.pauseTime, + playing: isLookahead ? false : content.playing ?? true, + + inPoint: content.inPoint, + length: content.length, + + playTime: (content.noStarttime || isLookahead ? null : startTime) || null, + } + } +} diff --git a/packages/timeline-state-resolver/src/service/devices.ts b/packages/timeline-state-resolver/src/service/devices.ts index 3278f6081..7314678d3 100644 --- a/packages/timeline-state-resolver/src/service/devices.ts +++ b/packages/timeline-state-resolver/src/service/devices.ts @@ -7,6 +7,7 @@ import { HTTPWatcherDevice } from '../integrations/httpWatcher' import { AbstractDevice } from '../integrations/abstract' import { AtemDevice } from '../integrations/atem' import { TcpSendDevice } from '../integrations/tcpSend' +import { QuantelDevice } from '../integrations/quantel' export interface DeviceEntry { deviceClass: new (context: DeviceContextAPI) => Device @@ -23,6 +24,7 @@ export type ImplementedServiceDeviceTypes = | DeviceType.OSC | DeviceType.SHOTOKU | DeviceType.TCPSEND + | DeviceType.QUANTEL // TODO - move all device implementations here and remove the old Device classes export const DevicesDict: Record = { @@ -68,4 +70,10 @@ export const DevicesDict: Record = { deviceName: (deviceId: string) => 'TCP' + deviceId, executionMode: () => 'sequential', // todo: should this be configurable? }, + [DeviceType.QUANTEL]: { + deviceClass: QuantelDevice, + canConnect: true, + deviceName: (deviceId: string) => 'Quantel' + deviceId, + executionMode: () => 'salvo', + }, } From 0a3ae5f6f764762c0761a53b4364314b945f1c9b Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Tue, 12 Sep 2023 12:01:57 +0200 Subject: [PATCH 02/14] feat(state handler): send commands before planned time of the state --- .../service/__tests__/stateHandler.spec.ts | 93 +++++++++++++++---- .../src/service/commandQueue.ts | 56 +++++++++++ .../src/service/device.ts | 2 + .../src/service/stateHandler.ts | 57 ++++-------- 4 files changed, 154 insertions(+), 54 deletions(-) create mode 100644 packages/timeline-state-resolver/src/service/commandQueue.ts diff --git a/packages/timeline-state-resolver/src/service/__tests__/stateHandler.spec.ts b/packages/timeline-state-resolver/src/service/__tests__/stateHandler.spec.ts index dec5af8d5..9ddca77fc 100644 --- a/packages/timeline-state-resolver/src/service/__tests__/stateHandler.spec.ts +++ b/packages/timeline-state-resolver/src/service/__tests__/stateHandler.spec.ts @@ -3,7 +3,10 @@ import { StateHandler } from '../stateHandler' import { MockTime } from '../../__tests__/mockTime' interface DeviceState { - [prop: string]: true + [prop: string]: { + value: true + preliminary?: number + } } interface CommandWithContext { command: { @@ -16,6 +19,19 @@ interface CommandWithContext { const MOCK_COMMAND_RECEIVER = jest.fn() +const CONTEXT = { + deviceId: 'unitTests0', + logger: { + debug: console.log, + info: console.log, + warn: console.log, + error: console.log, + }, + emitTimeTrace: () => null, + reportStateChangeMeasurement: () => null, + getCurrentTime: async () => Date.now(), +} + describe('stateHandler', () => { const mockTime = new MockTime() beforeEach(() => { @@ -26,18 +42,7 @@ describe('stateHandler', () => { function getNewStateHandler(): StateHandler { return new StateHandler( - { - deviceId: 'unitTests0', - logger: { - debug: console.log, - info: console.log, - warn: console.log, - error: console.log, - }, - emitTimeTrace: () => null, - reportStateChangeMeasurement: () => null, - getCurrentTime: async () => Date.now(), - }, + CONTEXT, { executionType: 'salvo', }, @@ -52,6 +57,7 @@ describe('stateHandler', () => { type: 'added', property: e, }, + preliminary: n[e].preliminary, })), ...Object.keys(o || {}) .filter((e) => !n[e]) @@ -72,7 +78,7 @@ describe('stateHandler', () => { stateHandler .setCurrentState({ - entry1: true, + entry1: { value: true }, }) .catch((e) => { console.error('Error while setting current state', e) @@ -98,7 +104,7 @@ describe('stateHandler', () => { stateHandler .setCurrentState({ - entry1: true, + entry1: { value: true }, }) .catch((e) => { console.error('Error while setting current state', e) @@ -121,7 +127,7 @@ describe('stateHandler', () => { stateHandler .handleState( createTimelineState(10100, { - entry1: true, + entry1: { value: true }, }), {} ) @@ -147,9 +153,62 @@ describe('stateHandler', () => { }, }) }) + + test('transition to a new state with preliminary commands', async () => { + const stateHandler = getNewStateHandler() + + stateHandler.setCurrentState({}).catch((e) => { + console.error('Error while setting current state', e) + }) + + stateHandler + .handleState( + createTimelineState(12000, { + entry1: { + value: true, + preliminary: 300, + }, + entry2: { + value: true, + }, + }), + {} + ) + .catch((e) => { + console.error('Error while handling state', e) + }) + + await mockTime.tick() + + expect(MOCK_COMMAND_RECEIVER).toHaveBeenCalledTimes(0) + + await mockTime.advanceTimeTicks(1700) + + expect(MOCK_COMMAND_RECEIVER).toHaveBeenCalledTimes(1) + expect(MOCK_COMMAND_RECEIVER).toHaveBeenNthCalledWith(1, { + command: { + type: 'added', + property: 'entry1', + }, + preliminary: 300, + }) + + await mockTime.advanceTimeTicks(300) + + expect(MOCK_COMMAND_RECEIVER).toHaveBeenCalledTimes(2) + expect(MOCK_COMMAND_RECEIVER).toHaveBeenNthCalledWith(2, { + command: { + type: 'added', + property: 'entry2', + }, + }) + }) }) -function createTimelineState(time: number, objs: Record): Timeline.TimelineState { +function createTimelineState( + time: number, + objs: Record +): Timeline.TimelineState { return { time, layers: objs as any, diff --git a/packages/timeline-state-resolver/src/service/commandQueue.ts b/packages/timeline-state-resolver/src/service/commandQueue.ts new file mode 100644 index 000000000..a2553ba28 --- /dev/null +++ b/packages/timeline-state-resolver/src/service/commandQueue.ts @@ -0,0 +1,56 @@ +import { BaseDeviceAPI, CommandWithContext } from './device' +import { Measurement } from './measure' + +const wait = async (t: number) => new Promise((r) => setTimeout(() => r(), t)) + +export class CommandQueue { + constructor( + private mode: 'salvo' | 'sequential', + private sendCommand: BaseDeviceAPI['sendCommand'] + ) {} + + async queueCommands(commands: Command[], measurement?: Measurement): Promise { + commands.sort((a, b) => (b.preliminary ?? 0) - (a.preliminary ?? 0)) + const totalTime = commands[0].preliminary ?? 0 + + if (this.mode === 'salvo') { + return this._queueCommandsSalvo(totalTime, commands, measurement) + } else { + return this._queueCommandsSequential(totalTime, commands, measurement) + } + } + + private async _queueCommandsSalvo(totalTime: number, commands: Command[], measurement?: Measurement): Promise { + await Promise.allSettled( + commands.map(async (command) => { + const timeToWait = totalTime - (command.preliminary ?? 0) + if (timeToWait > 0) await wait(totalTime) + + measurement?.executeCommand(command) + return this.sendCommand(command).then(() => { + measurement?.finishedCommandExecution(command) + return command + }) + }) + ) + } + + private async _queueCommandsSequential( + totalTime: number, + commands: Command[], + measurement?: Measurement + ): Promise { + const start = Date.now() // note - would be better to use monotonic time here but BigInt's are annoying + + for (const command of commands || []) { + const timeToWait = totalTime - (Date.now() - start) + if (timeToWait > 0) await wait(timeToWait) + + measurement?.executeCommand(command) + await this.sendCommand(command).catch((e) => { + console.error('Error while executing command', e) // todo + }) + measurement?.finishedCommandExecution(command) + } + } +} diff --git a/packages/timeline-state-resolver/src/service/device.ts b/packages/timeline-state-resolver/src/service/device.ts index dab3a017a..f2f08b30b 100644 --- a/packages/timeline-state-resolver/src/service/device.ts +++ b/packages/timeline-state-resolver/src/service/device.ts @@ -16,6 +16,8 @@ export type CommandWithContext = { context: CommandContext /** ID of the timeline-object that the command originated from */ timelineObjId: string + /** this command is to be executed x ms _before_ the scheduled time */ + preliminary?: number } /** diff --git a/packages/timeline-state-resolver/src/service/stateHandler.ts b/packages/timeline-state-resolver/src/service/stateHandler.ts index 7718e9394..9a67d2ab9 100644 --- a/packages/timeline-state-resolver/src/service/stateHandler.ts +++ b/packages/timeline-state-resolver/src/service/stateHandler.ts @@ -2,9 +2,12 @@ import { FinishedTrace, startTrace, endTrace } from '../lib' import { Mappings, Timeline, TSRTimelineContent } from 'timeline-state-resolver-types' import { BaseDeviceAPI, CommandWithContext } from './device' import { Measurement, StateChangeReport } from './measure' +import { CommandQueue } from './commandQueue' interface StateChange { commands?: Command[] + preliminary?: number + state: Timeline.TimelineState deviceState: DeviceState mappings: Mappings @@ -23,6 +26,7 @@ export class StateHandler { private currentState: ExecutedStateChange | undefined /** Semaphore, to ensure that .executeNextStateChange() is only executed one at a time */ private _executingStateChange = false + private _commandQueue: CommandQueue private clock: NodeJS.Timeout @@ -39,13 +43,15 @@ export class StateHandler { this.logger.error('Error while creating new StateHandler', e) }) + this._commandQueue = new CommandQueue(this.config.executionType, async (c) => device.sendCommand(c)) + this.clock = setInterval(() => { context .getCurrentTime() .then((t) => { // main clock to check if next state needs to be sent out for (const state of this.stateQueue) { - const nextTime = Math.max(0, state?.state.time - t) + const nextTime = Math.max(0, state?.state.time - (state?.preliminary ?? 0) - t) if (nextTime > CLOCK_INTERVAL) break // schedule any states between now and the next tick @@ -130,6 +136,7 @@ export class StateHandler { nextState.deviceState, nextState.mappings ) + nextState.preliminary = Math.max(...nextState.commands.map((c) => c.preliminary ?? 0)) this.context.emitTimeTrace(endTrace(trace)) } catch (e) { // todo - log an error @@ -138,7 +145,10 @@ export class StateHandler { nextState.commands = [] } - if (nextState.state.time <= (await this.context.getCurrentTime()) && this.currentState) { + if ( + nextState.state.time - (nextState.preliminary ?? 0) <= (await this.context.getCurrentTime()) && + this.currentState + ) { await this.executeNextStateChange() } } @@ -165,41 +175,14 @@ export class StateHandler { this.currentState = undefined - if (this.config.executionType === 'salvo') { - Promise.allSettled( - newState.commands.map(async (command) => { - newState.measurement?.executeCommand(command) - return this.device.sendCommand(command).then(() => { - newState.measurement?.finishedCommandExecution(command) - return command - }) - }) - ) - .then(() => { - if (newState.measurement) this.context.reportStateChangeMeasurement(newState.measurement.report()) - }) - .catch((e) => { - this.logger.error('Error while executing next state change', e) - }) - } else { - const execAll = async () => { - for (const command of newState.commands || []) { - newState.measurement?.executeCommand(command) - await this.device.sendCommand(command).catch((e) => { - this.logger.error('Error while executing command', e) - }) - newState.measurement?.finishedCommandExecution(command) - } - } - - execAll() - .then(() => { - if (newState.measurement) this.context.reportStateChangeMeasurement(newState.measurement.report()) - }) - .catch((e) => { - this.logger.error('Error while executing next state change', e) - }) - } + this._commandQueue + .queueCommands(newState.commands, newState.measurement) + .then(() => { + if (newState.measurement) this.context.reportStateChangeMeasurement(newState.measurement.report()) + }) + .catch((e) => { + this.logger.error('Error while executing next state change', e) + }) this.currentState = newState as ExecutedStateChange this._executingStateChange = false From 201ffa79215effe7e6dc63155e9b51d63f260415 Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Wed, 11 Oct 2023 08:42:15 +0200 Subject: [PATCH 03/14] chore: rebase branch --- .../quantel/__tests__/quantel.spec.ts | 29 ++++++++-------- .../src/integrations/quantel/diff.ts | 2 +- .../src/integrations/quantel/index.ts | 34 ++++++++----------- 3 files changed, 31 insertions(+), 34 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantel.spec.ts b/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantel.spec.ts index 9de92cac6..00473472d 100644 --- a/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantel.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantel.spec.ts @@ -13,9 +13,10 @@ import { QuantelCommandWithContext, QuantelDevice } from '..' import { QuantelCommandType, QuantelState } from '../types' import { setupQuantelGatewayMock } from './quantelGatewayMock' import { MockTime } from '../../../__tests__/mockTime' +import { getDeviceContext } from '../../../integrations/__tests__/testlib' async function getInitialisedQuantelDevice(clearMock?: jest.Mock) { - const dev = new QuantelDevice() + const dev = new QuantelDevice(getDeviceContext()) await dev.init({ gatewayUrl: 'localhost:3000', ISAUrlMaster: 'myISA:8000', @@ -326,7 +327,7 @@ describe('Quantel Device', () => { channel: 2, }, context: 'Old state did not have port', - tlObjId: 'obj0', + timelineObjId: 'obj0', }, { command: { @@ -338,7 +339,7 @@ describe('Quantel Device', () => { transition: undefined, }, context: 'New clip is empty', - tlObjId: 'obj0', + timelineObjId: 'obj0', }, ] ) @@ -392,7 +393,7 @@ describe('Quantel Device', () => { allowedToPrepareJump: true, }, context: 'Load from current state', - tlObjId: 'obj1', + timelineObjId: 'obj1', }, { command: { @@ -410,7 +411,7 @@ describe('Quantel Device', () => { transition: undefined, }, context: 'New clip is paused', - tlObjId: 'obj1', + timelineObjId: 'obj1', }, ] ) @@ -469,7 +470,7 @@ describe('Quantel Device', () => { allowedToPrepareJump: true, }, context: 'Load from current state', - tlObjId: 'obj1', + timelineObjId: 'obj1', }, { command: { @@ -487,7 +488,7 @@ describe('Quantel Device', () => { transition: undefined, }, context: 'New clip is playing', - tlObjId: 'obj1', + timelineObjId: 'obj1', }, ] ) @@ -546,7 +547,7 @@ describe('Quantel Device', () => { allowedToPrepareJump: true, }, context: 'Load from current state', - tlObjId: 'obj1', + timelineObjId: 'obj1', }, { command: { @@ -564,7 +565,7 @@ describe('Quantel Device', () => { transition: undefined, }, context: 'New clip is playing', - tlObjId: 'obj1', + timelineObjId: 'obj1', }, ] ) @@ -599,7 +600,7 @@ describe('Quantel Device', () => { timelineObjId: 'obj0', fromLookahead: false, }, - tlObjId: 'obj0', + timelineObjId: 'obj0', context: 'Port does not exist in new state', }, ] @@ -627,7 +628,7 @@ describe('Quantel Device', () => { channel: 2, }, context: 'Old state did not have port', - tlObjId: 'obj0', + timelineObjId: 'obj0', }) .catch((e) => { throw e @@ -675,7 +676,7 @@ describe('Quantel Device', () => { allowedToPrepareJump: true, }, context: 'Load from current state', - tlObjId: 'obj1', + timelineObjId: 'obj1', }) .catch((e) => { throw e @@ -718,7 +719,7 @@ describe('Quantel Device', () => { transition: undefined, }, context: 'New clip is playing', - tlObjId: 'obj1', + timelineObjId: 'obj1', }) .catch((e) => { throw e @@ -764,7 +765,7 @@ describe('Quantel Device', () => { fromLookahead: false, }, context: 'Clear', - tlObjId: 'obj1', + timelineObjId: 'obj1', }) .catch((e) => { throw e diff --git a/packages/timeline-state-resolver/src/integrations/quantel/diff.ts b/packages/timeline-state-resolver/src/integrations/quantel/diff.ts index 4e0b1cbd5..05d4d67d8 100644 --- a/packages/timeline-state-resolver/src/integrations/quantel/diff.ts +++ b/packages/timeline-state-resolver/src/integrations/quantel/diff.ts @@ -17,7 +17,7 @@ export function diffStates( const addCommand = (command: QuantelCommand, lowPriority: boolean, context?: string) => { ;(lowPriority ? lowPrioCommands : highPrioCommands).push({ command, - tlObjId: command.timelineObjId, + timelineObjId: command.timelineObjId, context: context ?? 'Context not specified..', }) } diff --git a/packages/timeline-state-resolver/src/integrations/quantel/index.ts b/packages/timeline-state-resolver/src/integrations/quantel/index.ts index d1f3dc57a..49ea4d609 100644 --- a/packages/timeline-state-resolver/src/integrations/quantel/index.ts +++ b/packages/timeline-state-resolver/src/integrations/quantel/index.ts @@ -11,10 +11,9 @@ import { Timeline, TSRTimelineContent, } from 'timeline-state-resolver-types' -import { CommandWithContext, Device, DeviceEvents } from '../../service/device' +import { CommandWithContext, Device } from '../../service/device' import Debug from 'debug' -import EventEmitter = require('eventemitter3') import { QuantelCommand, QuantelCommandType, QuantelState } from './types' import { QuantelGateway } from 'tv-automation-quantel-gateway-client' import { QuantelManager } from './connection' @@ -32,13 +31,10 @@ interface OSCDeviceStateContent extends OSCMessageCommandContent { export interface QuantelCommandWithContext { command: QuantelCommand context: string - tlObjId: string + timelineObjId: string } -export class QuantelDevice - extends EventEmitter - implements Device -{ +export class QuantelDevice extends Device { // TODO - monitor ports: this._quantel.setMonitoredPorts(this._getMappedPorts(newMappings)) private _quantel: QuantelGateway @@ -46,20 +42,20 @@ export class QuantelDevice async init(options: QuantelOptions): Promise { this._quantel = new QuantelGateway() - this._quantel.on('error', (e) => this.emit('error', 'Quantel.QuantelGateway', e)) + this._quantel.on('error', (e) => this.context.logger.error('Quantel.QuantelGateway', e)) // this._quantelManager = new QuantelManager(this._quantel, () => this.getCurrentTime(), { // todo - obv this._quantelManager = new QuantelManager(this._quantel, () => Date.now(), { allowCloneClips: options.allowCloneClips, }) this._quantelManager.on('info', (x) => - this.emit('info', `Quantel: ${typeof x === 'string' ? x : JSON.stringify(x)}`) + this.context.logger.info(`Quantel: ${typeof x === 'string' ? x : JSON.stringify(x)}`) ) this._quantelManager.on('warning', (x) => - this.emit('warning', `Quantel: ${typeof x === 'string' ? x : JSON.stringify(x)}`) + this.context.logger.warning(`Quantel: ${typeof x === 'string' ? x : JSON.stringify(x)}`) ) - this._quantelManager.on('error', (e) => this.emit('error', 'Quantel: ', e)) - this._quantelManager.on('debug', (...args) => this.emit('debug', ...args)) + this._quantelManager.on('error', (e) => this.context.logger.error('Quantel: ', e)) + this._quantelManager.on('debug', (...args) => this.context.logger.debug(...args)) const ISAUrlMaster: string = options.ISAUrlMaster || options['ISAUrl'] // tmp: ISAUrl for backwards compatibility, to be removed later if (!options.gatewayUrl) throw new Error('Quantel bad connection option: gatewayUrl') @@ -73,7 +69,7 @@ export class QuantelDevice await this._quantel.init(options.gatewayUrl, isaURLs, options.zoneId, options.serverId) // todo - maybe not to be awaited... this._quantel.monitorServerStatus((_connected: boolean) => { - this.emit('connectionChanged', this.getStatus()) + this.context.connectionChanged(this.getStatus()) }) return Promise.resolve(true) @@ -91,13 +87,13 @@ export class QuantelDevice diffStates(oldState: QuantelState | undefined, newState: QuantelState): Array { return diffStates(oldState, newState) } - async sendCommand({ command, context, tlObjId }: QuantelCommandWithContext): Promise { + async sendCommand({ command, context, timelineObjId }: QuantelCommandWithContext): Promise { const cwc: CommandWithContext = { context: context, command: command, - tlObjId: tlObjId, + timelineObjId: timelineObjId, } - this.emit('debug', cwc) + this.context.logger.debug(cwc) debug(command) try { @@ -123,7 +119,7 @@ export class QuantelDevice if (error?.stack) { errorString += error.stack } - this.emit('commandError', new Error(errorString), cwc) + this.context.commandError(new Error(errorString), cwc) } } @@ -156,7 +152,7 @@ export class QuantelDevice actions: Record Promise> = { [QuantelActions.ClearStates]: async () => { - this.emit('resetResolver') + this.context.resetResolver() return { result: ActionExecutionResultCode.Ok, } @@ -167,7 +163,7 @@ export class QuantelDevice await this._quantel.kill() return { result: ActionExecutionResultCode.Ok } } catch (e) { - this.emit('error', 'Error killing quantel gateway', new Error(e as any)) // todo - what to do here... + this.context.logger.error('Error killing quantel gateway', new Error(e as any)) // todo - what to do here... } } return { result: ActionExecutionResultCode.Error } From 9f1b481d5792518a68265f8a32b414786302b7fa Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Tue, 14 Nov 2023 09:06:28 +0100 Subject: [PATCH 04/14] fix: preliminary time is 0 for no commands --- packages/timeline-state-resolver/src/service/stateHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/timeline-state-resolver/src/service/stateHandler.ts b/packages/timeline-state-resolver/src/service/stateHandler.ts index 9a67d2ab9..1d51fb416 100644 --- a/packages/timeline-state-resolver/src/service/stateHandler.ts +++ b/packages/timeline-state-resolver/src/service/stateHandler.ts @@ -136,7 +136,7 @@ export class StateHandler { nextState.deviceState, nextState.mappings ) - nextState.preliminary = Math.max(...nextState.commands.map((c) => c.preliminary ?? 0)) + nextState.preliminary = Math.max(0, ...nextState.commands.map((c) => c.preliminary ?? 0)) this.context.emitTimeTrace(endTrace(trace)) } catch (e) { // todo - log an error From 06f095a81ce917ec9c350c191f662f8b660123fd Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Thu, 30 Nov 2023 16:53:15 +0100 Subject: [PATCH 05/14] feat: include timestamp in statediff api --- .../timeline-state-resolver/src/service/device.ts | 14 ++++++++++++-- .../src/service/stateHandler.ts | 3 ++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/timeline-state-resolver/src/service/device.ts b/packages/timeline-state-resolver/src/service/device.ts index f2f08b30b..3ce91d525 100644 --- a/packages/timeline-state-resolver/src/service/device.ts +++ b/packages/timeline-state-resolver/src/service/device.ts @@ -60,7 +60,12 @@ export abstract class Device, newMappings: Mappings ): DeviceState - abstract diffStates(oldState: DeviceState | undefined, newState: DeviceState, mappings: Mappings): Array + abstract diffStates( + oldState: DeviceState | undefined, + newState: DeviceState, + mappings: Mappings, + time: number + ): Array abstract sendCommand(command: Command): Promise // ------------------------------------------------------------------- } @@ -85,7 +90,12 @@ export interface BaseDeviceAPI * This method takes 2 states and returns a set of commands that will * transition the device from oldState to newState */ - diffStates(oldState: DeviceState | undefined, newState: DeviceState, mappings: Mappings): Array + diffStates( + oldState: DeviceState | undefined, + newState: DeviceState, + mappings: Mappings, + currentTime: number + ): Array /** This method will take a command and send it to the device */ sendCommand(command: Command): Promise } diff --git a/packages/timeline-state-resolver/src/service/stateHandler.ts b/packages/timeline-state-resolver/src/service/stateHandler.ts index 1d51fb416..08f68ca02 100644 --- a/packages/timeline-state-resolver/src/service/stateHandler.ts +++ b/packages/timeline-state-resolver/src/service/stateHandler.ts @@ -134,7 +134,8 @@ export class StateHandler { nextState.commands = this.device.diffStates( this.currentState?.deviceState, nextState.deviceState, - nextState.mappings + nextState.mappings, + await this.context.getCurrentTime() ) nextState.preliminary = Math.max(0, ...nextState.commands.map((c) => c.preliminary ?? 0)) this.context.emitTimeTrace(endTrace(trace)) From 1d018f183313eae3e8535f098ef3b168b5a2ed02 Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Thu, 30 Nov 2023 16:54:13 +0100 Subject: [PATCH 06/14] chore(quantel): use timestamp for prelim time --- .../src/integrations/quantel/diff.ts | 56 ++++++++----------- .../src/integrations/quantel/index.ts | 16 ++++-- .../src/integrations/quantel/types.ts | 1 - 3 files changed, 33 insertions(+), 40 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/quantel/diff.ts b/packages/timeline-state-resolver/src/integrations/quantel/diff.ts index 05d4d67d8..8bbbe2398 100644 --- a/packages/timeline-state-resolver/src/integrations/quantel/diff.ts +++ b/packages/timeline-state-resolver/src/integrations/quantel/diff.ts @@ -8,17 +8,19 @@ const PREPARE_TIME_WAIT = 50 export function diffStates( oldState: QuantelState | undefined, - newState: QuantelState + newState: QuantelState, + currentTime: number ): Array { const time = newState.time const highPrioCommands: QuantelCommandWithContext[] = [] const lowPrioCommands: QuantelCommandWithContext[] = [] - const addCommand = (command: QuantelCommand, lowPriority: boolean, context?: string) => { + const addCommand = (command: QuantelCommand, lowPriority: boolean, context?: string, prelimTime?: number) => { ;(lowPriority ? lowPrioCommands : highPrioCommands).push({ command, timelineObjId: command.timelineObjId, context: context ?? 'Context not specified..', + preliminary: prelimTime, }) } const seenClips: { [identifier: string]: true } = {} @@ -37,7 +39,6 @@ export function diffStates( addCommand( { type: QuantelCommandType.LOADCLIPFRAGMENTS, - time: prepareTime, portId: portId, timelineObjId: timelineObjId, fromLookahead: isPreloading || port.lookahead, @@ -46,13 +47,14 @@ export function diffStates( allowedToPrepareJump: !isPreloading, }, isPreloading || port.lookahead, - context + context, + prelimTime ) } } /** The time of when to run "preparation" commands */ - const prepareTime = getPrepareTime(newState.time, oldState?.time) + const prelimTime = getPreliminaryTime(newState.time, oldState?.time, currentTime) const lookaheadPreloadClips: { portId: string @@ -64,7 +66,7 @@ export function diffStates( for (const [portId, newPort] of Object.entries(newState.port)) { // diff existing ports const oldPort = oldState?.port[portId] - diffPort(portId, newPort, oldPort, newState.time, prepareTime, addCommand, loadFragments, lookaheadPreloadClips) + diffPort(portId, newPort, oldPort, prelimTime, addCommand, loadFragments, lookaheadPreloadClips) } for (const [portId, oldPort] of Object.entries(oldState?.port ?? {})) { @@ -75,13 +77,13 @@ export function diffStates( addCommand( { type: QuantelCommandType.RELEASEPORT, - time: prepareTime, portId: portId, timelineObjId: oldPort.timelineObjId, fromLookahead: oldPort.lookahead, }, oldPort.lookahead, - 'Port does not exist in new state' + 'Port does not exist in new state', + prelimTime ) } } @@ -110,25 +112,15 @@ export function diffStates( return allCommands } -/** The time of when to run "preparation" commands */ -function getPrepareTime(time: number, oldTime?: number): number { - let prepareTime = Math.min( - time, - Math.max( - time - IDEAL_PREPARE_TIME, - (oldTime ?? Date.now()) + PREPARE_TIME_WAIT // earliset possible prepareTime - ) - ) - if (prepareTime < Date.now()) { - // todo - is this a good usage of date.now vs getTime() - // Only to not emit an unnessesary slowCommand event - prepareTime = Date.now() - } - if (time < prepareTime) { - prepareTime = time - 10 - } +function getPreliminaryTime(time: number, oldTime: number | undefined, currentTime: number) { + // we want to be at least PREPARE_TIME_WAIT ms after the old state + const earliest = Math.max((oldTime ?? 0) + PREPARE_TIME_WAIT, currentTime) + + // time - earliest = the most delay we can use + const maxPrelim = Math.max(0, time - earliest) - return prepareTime + // the best time is IDEAL_PREPARE_TIME, but we cannot use it if the oldState was too short ago + return Math.min(maxPrelim, IDEAL_PREPARE_TIME) } /** diff an existing port */ @@ -136,9 +128,8 @@ function diffPort( portId: string, newPort: QuantelStatePort, oldPort: QuantelStatePort | undefined, - time: number, - prepareTime: number, - addCommand: (command: QuantelCommand, lowPriority: boolean, context?: string) => void, + prelimTime: number, + addCommand: (command: QuantelCommand, lowPriority: boolean, context?: string, prelimTime?: number) => void, loadFragments: ( portId: string, port: QuantelStatePort, @@ -161,13 +152,13 @@ function diffPort( addCommand( { type: QuantelCommandType.SETUPPORT, - time: prepareTime, portId: portId, timelineObjId: newPort.timelineObjId, channel: channel, }, newPort.lookahead, - 'Old state did not have port' + 'Old state did not have port', + prelimTime ) } } @@ -189,7 +180,6 @@ function diffPort( addCommand( { type: QuantelCommandType.PLAYCLIP, - time: time, portId: portId, timelineObjId: newPort.timelineObjId, fromLookahead: newPort.lookahead, @@ -204,7 +194,6 @@ function diffPort( addCommand( { type: QuantelCommandType.PAUSECLIP, - time: time, portId: portId, timelineObjId: newPort.timelineObjId, fromLookahead: newPort.lookahead, @@ -220,7 +209,6 @@ function diffPort( addCommand( { type: QuantelCommandType.CLEARCLIP, - time: time, portId: portId, timelineObjId: newPort.timelineObjId, fromLookahead: newPort.lookahead, diff --git a/packages/timeline-state-resolver/src/integrations/quantel/index.ts b/packages/timeline-state-resolver/src/integrations/quantel/index.ts index 49ea4d609..1aa7390be 100644 --- a/packages/timeline-state-resolver/src/integrations/quantel/index.ts +++ b/packages/timeline-state-resolver/src/integrations/quantel/index.ts @@ -17,7 +17,7 @@ import Debug from 'debug' import { QuantelCommand, QuantelCommandType, QuantelState } from './types' import { QuantelGateway } from 'tv-automation-quantel-gateway-client' import { QuantelManager } from './connection' -import { convertTimelineStateToQuantelState } from './state' +import { convertTimelineStateToQuantelState, getMappedPorts } from './state' import { diffStates } from './diff' const debug = Debug('timeline-state-resolver:quantel') @@ -32,11 +32,10 @@ export interface QuantelCommandWithContext { command: QuantelCommand context: string timelineObjId: string + preliminary?: number } export class QuantelDevice extends Device { - // TODO - monitor ports: this._quantel.setMonitoredPorts(this._getMappedPorts(newMappings)) - private _quantel: QuantelGateway private _quantelManager: QuantelManager @@ -84,8 +83,15 @@ export class QuantelDevice extends Device { - return diffStates(oldState, newState) + diffStates( + oldState: QuantelState | undefined, + newState: QuantelState, + mappings: Mappings, + currentTime: number + ): Array { + this._quantel.setMonitoredPorts(getMappedPorts(mappings)) + + return diffStates(oldState, newState, currentTime) } async sendCommand({ command, context, timelineObjId }: QuantelCommandWithContext): Promise { const cwc: CommandWithContext = { diff --git a/packages/timeline-state-resolver/src/integrations/quantel/types.ts b/packages/timeline-state-resolver/src/integrations/quantel/types.ts index 210c4c32b..a0ef29482 100644 --- a/packages/timeline-state-resolver/src/integrations/quantel/types.ts +++ b/packages/timeline-state-resolver/src/integrations/quantel/types.ts @@ -41,7 +41,6 @@ export interface QuantelStatePortClipLookahead extends QuantelStatePortClipConte } interface QuantelCommandBase { - time: number type: QuantelCommandType portId: string timelineObjId: string From 26e6a253386e18a1e216081e43d661c86f3e0f1d Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Thu, 30 Nov 2023 16:54:32 +0100 Subject: [PATCH 07/14] chore: update tests --- .../quantel/__tests__/quantel.spec.ts | 566 +++++++++++++++++- .../quantel/__tests__/quantelManager.spec.ts | 248 ++++++++ 2 files changed, 793 insertions(+), 21 deletions(-) create mode 100644 packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantelManager.spec.ts diff --git a/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantel.spec.ts b/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantel.spec.ts index 00473472d..0130e9b37 100644 --- a/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantel.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantel.spec.ts @@ -4,6 +4,7 @@ import { MappingQuantelType, Mappings, QuantelControlMode, + QuantelTransitionType, SomeMappingQuantel, Timeline, TimelineContentQuantelAny, @@ -37,7 +38,7 @@ describe('Quantel Device', () => { }) describe('convertTimelineStateToDeviceState', () => { - async function compareState( + async function convertState( tlState: Timeline.TimelineState, mappings: Mappings, expDevState: QuantelState @@ -50,11 +51,11 @@ describe('Quantel Device', () => { } test('convert empty state', async () => { - await compareState(createTimelineState({}), {}, { time: 10, port: {} }) + await convertState(createTimelineState({}), {}, { time: 10, port: {} }) }) test('convert 1 layer', async () => { - await compareState( + await convertState( createTimelineState({ layer0: { id: 'obj0', @@ -100,8 +101,55 @@ describe('Quantel Device', () => { ) }) + test('convert 1 layer (with guid)', async () => { + await convertState( + createTimelineState({ + layer0: { + id: 'obj0', + content: { + deviceType: DeviceType.QUANTEL, + + guid: 'guid-id', + }, + instance: { + originalStart: 10, + }, + }, + }), + { + layer0: { + device: DeviceType.QUANTEL, + deviceId: 'myQuantel', + + options: { + mappingType: MappingQuantelType.Port, + portId: 'my_port', + channelId: 2, + }, + }, + }, + { + time: 10, + port: { + my_port: { + timelineObjId: 'obj0', + mode: QuantelControlMode.QUALITY, + notOnAir: false, + lookahead: false, + clip: { + guid: 'guid-id', + playTime: 10, + playing: true, + }, + channels: [2], + }, + }, + } + ) + }) + test('convert 2 layers for 1 port', async () => { - await compareState( + await convertState( createTimelineState({ layer0: { id: 'obj0', @@ -168,7 +216,7 @@ describe('Quantel Device', () => { ) }) test('convert empty layer + 1 lookahaed', async () => { - await compareState( + await convertState( createTimelineState({ layer0_lookahead: { id: 'obj1', @@ -220,7 +268,7 @@ describe('Quantel Device', () => { ) }) test('convert 1 layer + 1 lookahaed', async () => { - await compareState( + await convertState( createTimelineState({ layer0: { id: 'obj0', @@ -282,17 +330,67 @@ describe('Quantel Device', () => { } ) }) + + test('convert inPoint', async () => { + await convertState( + createTimelineState({ + layer0: { + id: 'obj0', + content: { + deviceType: DeviceType.QUANTEL, + + title: 'myClip0', + inPoint: 500, + }, + instance: { + originalStart: 10, + }, + }, + }), + { + layer0: { + device: DeviceType.QUANTEL, + deviceId: 'myQuantel', + + options: { + mappingType: MappingQuantelType.Port, + portId: 'my_port', + channelId: 2, + }, + }, + }, + { + time: 10, + port: { + my_port: { + timelineObjId: 'obj0', + mode: QuantelControlMode.QUALITY, + notOnAir: false, + lookahead: false, + clip: { + title: 'myClip0', + inPoint: 500, + playTime: 10, + playing: true, + }, + channels: [2], + }, + }, + } + ) + }) }) describe('diffState', () => { async function compareStates( oldDevState: QuantelState, newDevState: QuantelState, - expCommands: QuantelCommandWithContext[] + expCommands: QuantelCommandWithContext[], + t?: number ) { const device = await getInitialisedQuantelDevice() - const commands = device.diffStates(oldDevState, newDevState) + const commands = device.diffStates(oldDevState, newDevState, {}, t ?? newDevState.time) expect(commands).toEqual(expCommands) } @@ -321,18 +419,17 @@ describe('Quantel Device', () => { { command: { type: QuantelCommandType.SETUPPORT, - time: 2990, portId: 'port0', timelineObjId: 'obj0', channel: 2, }, context: 'Old state did not have port', timelineObjId: 'obj0', + preliminary: 0, }, { command: { type: QuantelCommandType.CLEARCLIP, - time: 3000, portId: 'port0', timelineObjId: 'obj0', fromLookahead: false, @@ -380,7 +477,6 @@ describe('Quantel Device', () => { { command: { type: QuantelCommandType.LOADCLIPFRAGMENTS, - time: 2990, portId: 'port0', timelineObjId: 'obj1', fromLookahead: false, @@ -394,11 +490,11 @@ describe('Quantel Device', () => { }, context: 'Load from current state', timelineObjId: 'obj1', + preliminary: 0, }, { command: { type: QuantelCommandType.PAUSECLIP, - time: 3000, portId: 'port0', timelineObjId: 'obj1', fromLookahead: false, @@ -417,6 +513,77 @@ describe('Quantel Device', () => { ) }) + test('Load from GUID', async () => { + await compareStates( + { + time: 1000, + port: { + port0: { + timelineObjId: 'obj0', + mode: QuantelControlMode.QUALITY, + notOnAir: false, + lookahead: false, + channels: [2], + }, + }, + }, + { + time: 3000, + port: { + port0: { + timelineObjId: 'obj1', + mode: QuantelControlMode.QUALITY, + notOnAir: false, + lookahead: false, + clip: { + guid: 'guid-id', + playing: false, + playTime: null, + }, + channels: [2], + }, + }, + }, + [ + { + command: { + type: QuantelCommandType.LOADCLIPFRAGMENTS, + portId: 'port0', + timelineObjId: 'obj1', + fromLookahead: false, + clip: { + guid: 'guid-id', + playing: false, + playTime: null, + }, + timeOfPlay: 3000, + allowedToPrepareJump: true, + }, + context: 'Load from current state', + timelineObjId: 'obj1', + preliminary: 0, + }, + { + command: { + type: QuantelCommandType.PAUSECLIP, + portId: 'port0', + timelineObjId: 'obj1', + fromLookahead: false, + clip: { + guid: 'guid-id', + playing: false, + playTime: null, + }, + mode: QuantelControlMode.QUALITY, + transition: undefined, + }, + context: 'New clip is paused', + timelineObjId: 'obj1', + }, + ] + ) + }) + test('Load & play', async () => { await compareStates( { @@ -457,7 +624,6 @@ describe('Quantel Device', () => { { command: { type: QuantelCommandType.LOADCLIPFRAGMENTS, - time: 2990, portId: 'port0', timelineObjId: 'obj1', fromLookahead: false, @@ -471,11 +637,11 @@ describe('Quantel Device', () => { }, context: 'Load from current state', timelineObjId: 'obj1', + preliminary: 0, }, { command: { type: QuantelCommandType.PLAYCLIP, - time: 3000, portId: 'port0', timelineObjId: 'obj1', fromLookahead: false, @@ -534,7 +700,6 @@ describe('Quantel Device', () => { { command: { type: QuantelCommandType.LOADCLIPFRAGMENTS, - time: 2990, portId: 'port0', timelineObjId: 'obj1', fromLookahead: false, @@ -548,11 +713,11 @@ describe('Quantel Device', () => { }, context: 'Load from current state', timelineObjId: 'obj1', + preliminary: 0, }, { command: { type: QuantelCommandType.PLAYCLIP, - time: 3000, portId: 'port0', timelineObjId: 'obj1', fromLookahead: false, @@ -595,17 +760,380 @@ describe('Quantel Device', () => { { command: { type: QuantelCommandType.RELEASEPORT, - time: 2990, portId: 'port0', timelineObjId: 'obj0', fromLookahead: false, }, timelineObjId: 'obj0', context: 'Port does not exist in new state', + preliminary: 0, }, ] ) }) + + test('Preliminary clip load', async () => { + await compareStates( + { + time: 15000, + port: { + port0: { + timelineObjId: 'obj1', + mode: QuantelControlMode.QUALITY, + notOnAir: false, + lookahead: false, + clip: { + title: 'test0', + playing: true, + playTime: 1000, + }, + channels: [2], + }, + }, + }, + { + time: 15500, + port: { + port0: { + timelineObjId: 'obj2', + mode: QuantelControlMode.QUALITY, + notOnAir: false, + lookahead: false, + clip: { + title: 'test1', + playing: true, + playTime: 1500, + }, + channels: [2], + }, + }, + }, + [ + { + command: { + type: QuantelCommandType.LOADCLIPFRAGMENTS, + portId: 'port0', + timelineObjId: 'obj2', + fromLookahead: false, + clip: { + title: 'test1', + playing: true, + playTime: 1500, + }, + timeOfPlay: 15500, + allowedToPrepareJump: true, + }, + context: 'Load from current state', + timelineObjId: 'obj2', + preliminary: 450, + }, + { + command: { + type: QuantelCommandType.PLAYCLIP, + portId: 'port0', + timelineObjId: 'obj2', + fromLookahead: false, + clip: { + title: 'test1', + playing: true, + playTime: 1500, + }, + mode: QuantelControlMode.QUALITY, + transition: undefined, + }, + context: 'New clip is playing', + timelineObjId: 'obj2', + }, + ], + 15020 + ) + }) + + describe('Out transitions', () => { + test('to clear', async () => { + await compareStates( + { + time: 15000, + port: { + port0: { + timelineObjId: 'obj1', + mode: QuantelControlMode.QUALITY, + notOnAir: false, + lookahead: false, + clip: { + title: 'test0', + playing: true, + playTime: 1000, + }, + channels: [2], + outTransition: { + type: QuantelTransitionType.DELAY, + + delay: 1000, + }, + }, + }, + }, + { + time: 15500, + port: { + port0: { + timelineObjId: 'obj2', + mode: QuantelControlMode.QUALITY, + notOnAir: false, + lookahead: false, + channels: [2], + }, + }, + }, + [ + { + command: { + type: QuantelCommandType.CLEARCLIP, + portId: 'port0', + timelineObjId: 'obj2', + fromLookahead: false, + transition: { + type: QuantelTransitionType.DELAY, + delay: 1000, + }, + }, + context: 'New clip is empty', + timelineObjId: 'obj2', + }, + ] + ) + }) + + test('to notOnAir', async () => { + await compareStates( + { + time: 11000, + port: { + port0: { + timelineObjId: 'obj1', + mode: QuantelControlMode.QUALITY, + notOnAir: false, + lookahead: false, + clip: { + title: 'test0', + playing: true, + playTime: 1000, + }, + channels: [2], + outTransition: { + type: QuantelTransitionType.DELAY, + + delay: 1000, + }, + }, + }, + }, + { + time: 13000, + port: { + port0: { + timelineObjId: 'obj2', + mode: QuantelControlMode.QUALITY, + notOnAir: true, + clip: { + title: 'test1', + playing: false, + playTime: null, + }, + lookahead: false, + channels: [2], + }, + }, + }, + [ + { + command: { + type: QuantelCommandType.LOADCLIPFRAGMENTS, + portId: 'port0', + timelineObjId: 'obj2', + fromLookahead: false, + clip: { + title: 'test1', + playing: false, + playTime: null, + }, + timeOfPlay: 13000, + allowedToPrepareJump: true, + }, + context: 'Load from current state', + timelineObjId: 'obj2', + preliminary: 1000, + }, + { + command: { + type: QuantelCommandType.PAUSECLIP, + portId: 'port0', + timelineObjId: 'obj2', + fromLookahead: false, + mode: QuantelControlMode.QUALITY, + clip: { + title: 'test1', + playing: false, + playTime: null, + }, + transition: { + type: QuantelTransitionType.DELAY, + delay: 1000, + }, + }, + context: 'New clip is paused', + timelineObjId: 'obj2', + }, + ], + 11500 + ) + }) + + test('from lookahead', async () => { + await compareStates( + { + time: 11000, + port: { + port0: { + timelineObjId: 'obj1', + mode: QuantelControlMode.QUALITY, + notOnAir: true, + lookahead: false, + clip: { + title: 'test0', + playing: true, + playTime: 1000, + }, + channels: [2], + outTransition: { + type: QuantelTransitionType.DELAY, + + delay: 1000, + }, + }, + }, + }, + { + time: 13000, + port: { + port0: { + timelineObjId: 'obj2', + mode: QuantelControlMode.QUALITY, + notOnAir: true, + clip: { + title: 'test1', + playing: false, + playTime: null, + }, + lookahead: false, + channels: [2], + }, + }, + }, + [ + { + command: { + type: QuantelCommandType.LOADCLIPFRAGMENTS, + portId: 'port0', + timelineObjId: 'obj2', + fromLookahead: false, + clip: { + title: 'test1', + playing: false, + playTime: null, + }, + timeOfPlay: 13000, + allowedToPrepareJump: true, + }, + context: 'Load from current state', + timelineObjId: 'obj2', + preliminary: 1000, + }, + { + command: { + type: QuantelCommandType.PAUSECLIP, + portId: 'port0', + timelineObjId: 'obj2', + fromLookahead: false, + mode: QuantelControlMode.QUALITY, + clip: { + title: 'test1', + playing: false, + playTime: null, + }, + transition: undefined, + }, + context: 'New clip is paused', + timelineObjId: 'obj2', + }, + ], + 11500 + ) + }) + }) + + test('Rename a port', async () => { + await compareStates( + { + time: 11000, + port: { + port0: { + timelineObjId: 'obj1', + mode: QuantelControlMode.QUALITY, + lookahead: false, + channels: [2], + }, + }, + }, + { + time: 13000, + port: { + port0_renamed: { + timelineObjId: 'obj2', + mode: QuantelControlMode.QUALITY, + lookahead: false, + channels: [2], + }, + }, + }, + [ + { + command: { + type: QuantelCommandType.RELEASEPORT, + portId: 'port0', + timelineObjId: 'obj1', + fromLookahead: false, + }, + context: 'Port does not exist in new state', + timelineObjId: 'obj1', + preliminary: 100, + }, + { + command: { + type: QuantelCommandType.SETUPPORT, + portId: 'port0_renamed', + timelineObjId: 'obj2', + channel: 2, + }, + context: 'Old state did not have port', + timelineObjId: 'obj2', + preliminary: 100, + }, + { + command: { + type: QuantelCommandType.CLEARCLIP, + portId: 'port0_renamed', + timelineObjId: 'obj2', + fromLookahead: false, + }, + context: 'New clip is empty', + timelineObjId: 'obj2', + }, + ], + 12900 + ) + }) }) describe('sendCommand', () => { @@ -622,7 +1150,6 @@ describe('Quantel Device', () => { .sendCommand({ command: { type: QuantelCommandType.SETUPPORT, - time: 990, portId: 'my_port', timelineObjId: 'obj0', channel: 2, @@ -663,7 +1190,6 @@ describe('Quantel Device', () => { .sendCommand({ command: { type: QuantelCommandType.LOADCLIPFRAGMENTS, - time: 2990, portId: 'my_port', timelineObjId: 'obj1', fromLookahead: false, @@ -706,7 +1232,6 @@ describe('Quantel Device', () => { .sendCommand({ command: { type: QuantelCommandType.PLAYCLIP, - time: 3000, portId: 'my_port', timelineObjId: 'obj1', fromLookahead: false, @@ -759,7 +1284,6 @@ describe('Quantel Device', () => { .sendCommand({ command: { type: QuantelCommandType.CLEARCLIP, - time: 3000, portId: 'my_port', timelineObjId: 'obj1', fromLookahead: false, diff --git a/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantelManager.spec.ts b/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantelManager.spec.ts new file mode 100644 index 000000000..247226f8a --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantelManager.spec.ts @@ -0,0 +1,248 @@ +import { QuantelGateway } from 'tv-automation-quantel-gateway-client' +import { QuantelManager } from '../connection' +import { setupQuantelGatewayMock } from './quantelGatewayMock' +import { QuantelCommandType } from '../types' +import { QuantelControlMode } from 'timeline-state-resolver-types' +import { MockTime } from '../../../__tests__/mockTime' + +describe('Quantel connection', () => { + const { quantelServer, onRequest } = setupQuantelGatewayMock() + const mockTime = new MockTime() + + beforeEach(() => { + mockTime.init() + + onRequest.mockClear() + quantelServer.ignoreConnectivityCheck = false + quantelServer.ports = {} + }) + + test('Play, seek and re-use clip', async () => { + const gw = new QuantelGateway() + const manager = new QuantelManager(gw, () => Date.now(), {}) + + await gw.init('localhost:3000', 'myISA:8000', undefined, 1100) + + expect(mockTime.getCurrentTime()).toEqual(10000) + await mockTime.advanceTimeToTicks(10100) + + // setup system + await manager.setupPort({ + type: QuantelCommandType.SETUPPORT, + portId: 'my_port', + channel: 2, + timelineObjId: 'tlObj0', + }) + await manager.clearClip({ + type: QuantelCommandType.CLEARCLIP, + portId: 'my_port', + timelineObjId: 'tlObj0', + }) + + // Connect to ISA + expect(onRequest).toHaveBeenNthCalledWith(1, 'post', 'http://localhost:3000/connect/myISA%3A8000') + // get initial server info + expect(onRequest).toHaveBeenNthCalledWith(2, 'get', 'http://localhost:3000/default/server') + + // Set up port: + + // get port info + expect(onRequest).toHaveBeenNthCalledWith(3, 'get', 'http://localhost:3000/default/server/1100/port/my_port') + // create new port and assign to channel + expect(onRequest).toHaveBeenNthCalledWith( + 4, + 'put', + 'http://localhost:3000/default/server/1100/port/my_port/channel/2' + ) + // Reset the port + expect(onRequest).toHaveBeenNthCalledWith(5, 'post', 'http://localhost:3000/default/server/1100/port/my_port/reset') + + onRequest.mockClear() + + await mockTime.advanceTimeToTicks(15000) + + // play a clip + ;(async () => { + await manager.loadClipFragments({ + type: QuantelCommandType.LOADCLIPFRAGMENTS, + portId: 'my_port', + timelineObjId: 'obj1', + fromLookahead: false, + clip: { + title: 'Test0', + playing: true, + playTime: 15000, + }, + timeOfPlay: 15000, + allowedToPrepareJump: true, + }) + await manager.playClip({ + type: QuantelCommandType.PLAYCLIP, + portId: 'my_port', + timelineObjId: 'obj1', + fromLookahead: false, + clip: { + title: 'Test0', + playing: false, + playTime: null, + }, + mode: QuantelControlMode.QUALITY, + transition: undefined, + }) + // load next clip + await manager.loadClipFragments({ + type: QuantelCommandType.LOADCLIPFRAGMENTS, + portId: 'my_port', + timelineObjId: 'obj1', + fromLookahead: false, + clip: { + title: 'myClip0', + playing: false, + playTime: 15200, + }, + timeOfPlay: 15200, + allowedToPrepareJump: true, + }) + })().catch((e) => { + throw e + }) + await mockTime.advanceTimeToTicks(15100) + + expect(onRequest).toHaveBeenCalledTimes(14) + // Search for and get clip info: + expect(onRequest).toHaveBeenNthCalledWith(1, 'get', expect.stringContaining('/default/clip?Title=%22Test0%22')) + expect(onRequest).toHaveBeenNthCalledWith(2, 'get', expect.stringContaining('/default/clip/2')) + // Fetch fragments: + expect(onRequest).toHaveBeenNthCalledWith(3, 'get', expect.stringContaining('clip/2/fragments')) + // get port info + expect(onRequest).toHaveBeenNthCalledWith(4, 'get', expect.stringContaining('default/server/1100/port/my_port')) + // Load fragments + expect(onRequest).toHaveBeenNthCalledWith(5, 'post', expect.stringContaining('port/my_port/fragments')) + + // Note: These are skipped since the playhead is already there: + /* + // Prepare jump + expect(onRequest).toHaveBeenNthCalledWith(6, 'put', expect.stringContaining('port/my_port/jump?offset=0')) + // Trigger Jump + expect(onRequest).toHaveBeenNthCalledWith(6, 'post', expect.stringContaining('/default/server/1100/port/my_port/trigger/JUMP')) + */ + + // Trigger play + expect(onRequest).toHaveBeenNthCalledWith( + 6, + 'post', + expect.stringContaining('/default/server/1100/port/my_port/trigger/START') + ) + // Check that play worked + expect(onRequest).toHaveBeenNthCalledWith(7, 'get', expect.stringContaining('default/server/1100/port/my_port')) + // Plan to stop at end of clip + expect(onRequest).toHaveBeenNthCalledWith( + 8, + 'post', + expect.stringContaining('/default/server/1100/port/my_port/trigger/STOP?offset=999') + ) + + // Search for and get clip info: + expect(onRequest).toHaveBeenNthCalledWith(9, 'get', expect.stringContaining('/default/clip?Title=%22myClip0%22')) + expect(onRequest).toHaveBeenNthCalledWith(10, 'get', expect.stringContaining('/default/clip/1337')) + // Fetch fragments: + expect(onRequest).toHaveBeenNthCalledWith(11, 'get', expect.stringContaining('clip/1337/fragments')) + // get port info + expect(onRequest).toHaveBeenNthCalledWith(12, 'get', expect.stringContaining('default/server/1100/port/my_port')) + // Load fragments + expect(onRequest).toHaveBeenNthCalledWith(13, 'post', expect.stringContaining('port/my_port/fragments?offset=1000')) + // Prepare jump + expect(onRequest).toHaveBeenNthCalledWith(14, 'put', expect.stringContaining('port/my_port/jump?offset=1000')) + + onRequest.mockClear() + await mockTime.advanceTimeToTicks(15200) + ;(async () => { + // play 2nd clip + await manager.playClip({ + type: QuantelCommandType.PLAYCLIP, + portId: 'my_port', + timelineObjId: 'obj1', + fromLookahead: false, + clip: { + title: 'myClip0', + playing: true, + playTime: 15200, + }, + mode: QuantelControlMode.QUALITY, + transition: undefined, + }) + // load clip from before + await manager.loadClipFragments({ + type: QuantelCommandType.LOADCLIPFRAGMENTS, + portId: 'my_port', + timelineObjId: 'obj1', + fromLookahead: false, + clip: { + title: 'Test0', + playing: false, + playTime: 15700, + inPoint: 500, + }, + timeOfPlay: 15700, + allowedToPrepareJump: true, + }) + })().catch((e) => { + throw e + }) + await mockTime.advanceTimeToTicks(15300) + + expect(onRequest).toHaveBeenCalledTimes(9) + // Trigger Jump + expect(onRequest).toHaveBeenNthCalledWith( + 1, + 'post', + expect.stringContaining('/default/server/1100/port/my_port/trigger/JUMP') + ) + // Trigger play + expect(onRequest).toHaveBeenNthCalledWith( + 2, + 'post', + expect.stringContaining('/default/server/1100/port/my_port/trigger/START') + ) + // Check that play worked + expect(onRequest).toHaveBeenNthCalledWith(3, 'get', expect.stringContaining('default/server/1100/port/my_port')) + // Plan to stop at end of clip + expect(onRequest).toHaveBeenNthCalledWith( + 4, + 'post', + expect.stringContaining('/default/server/1100/port/my_port/trigger/STOP?offset=2999') + ) + + // Load next clip: + + // Search for and get clip info: + // expect(onRequest).toHaveBeenNthCalledWith(4, 'get', expect.stringContaining('/default/clip?Title=%22myClip0%22')) // already have this info + expect(onRequest).toHaveBeenNthCalledWith(5, 'get', expect.stringContaining('/default/clip/2')) + // Fetch fragments: + expect(onRequest).toHaveBeenNthCalledWith(6, 'get', expect.stringContaining('clip/2/fragments/13-1000')) + // get port info + expect(onRequest).toHaveBeenNthCalledWith(7, 'get', expect.stringContaining('default/server/1100/port/my_port')) + // Load fragments + expect(onRequest).toHaveBeenNthCalledWith(8, 'post', expect.stringContaining('port/my_port/fragments?offset=3000')) + // Prepare jump + expect(onRequest).toHaveBeenNthCalledWith(9, 'put', expect.stringContaining('port/my_port/jump?offset=3000')) + + onRequest.mockClear() + + await mockTime.advanceTimeToTicks(16050) + manager + .clearClip({ + type: QuantelCommandType.CLEARCLIP, + portId: 'my_port', + timelineObjId: 'obj1', + }) + .catch((e) => { + throw e + }) + await mockTime.advanceTimeToTicks(16100) + + expect(onRequest).toHaveBeenCalledTimes(1) + // Clear port from clip (reset port) + expect(onRequest).toHaveBeenNthCalledWith(1, 'post', expect.stringContaining('port/my_port/reset')) + }) +}) From db4c3e6138396b8bd5dec85f54338e6add6af079 Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Thu, 7 Dec 2023 12:38:47 +0100 Subject: [PATCH 08/14] fix: synced synchronous getCurrentTime --- .../__tests__/mockDeviceInstanceWrapper.ts | 8 +++- .../src/service/DeviceInstance.ts | 36 +++++++++++++- .../service/__tests__/deviceInstance.spec.ts | 26 +++++++++- .../service/__tests__/stateHandler.spec.ts | 2 +- .../src/service/stateHandler.ts | 47 ++++++++----------- 5 files changed, 84 insertions(+), 35 deletions(-) diff --git a/packages/timeline-state-resolver/src/__tests__/mockDeviceInstanceWrapper.ts b/packages/timeline-state-resolver/src/__tests__/mockDeviceInstanceWrapper.ts index abf1c4747..e809911ec 100644 --- a/packages/timeline-state-resolver/src/__tests__/mockDeviceInstanceWrapper.ts +++ b/packages/timeline-state-resolver/src/__tests__/mockDeviceInstanceWrapper.ts @@ -40,8 +40,7 @@ export class MockDeviceInstanceWrapper constructor( public readonly deviceId: string, _startTime: string, - public readonly config: DeviceOptionsAnyInternal, - public readonly getCurrentTime: () => Promise + public readonly config: DeviceOptionsAnyInternal // private readonly _getRemoteCurrentTime: () => number ) { super() @@ -54,6 +53,11 @@ export class MockDeviceInstanceWrapper ConstructedMockDevices[deviceId] = this } + getCurrentTime = jest.fn((): number => { + // throw new Error('Method not implemented.') + return Date.now() + }) + initDevice = jest.fn(async (_activeRundownPlaylistId?: string | undefined): Promise => { // throw new Error('Method not implemented.') return true diff --git a/packages/timeline-state-resolver/src/service/DeviceInstance.ts b/packages/timeline-state-resolver/src/service/DeviceInstance.ts index 233e76247..324ee8728 100644 --- a/packages/timeline-state-resolver/src/service/DeviceInstance.ts +++ b/packages/timeline-state-resolver/src/service/DeviceInstance.ts @@ -50,7 +50,10 @@ export class DeviceInstanceWrapper extends EventEmitter { private _logDebug = false private _logDebugStates = false - constructor(id: string, time: number, private config: Config, public getCurrentTime: () => Promise) { + private _lastUpdateCurrentTime: number | undefined + private _tDiff: number | undefined + + constructor(id: string, time: number, private config: Config, private getRemoteCurrentTime: () => Promise) { super() const deviceSpecs: DeviceEntry = DevicesDict[config.type] @@ -65,6 +68,8 @@ export class DeviceInstanceWrapper extends EventEmitter { this._deviceName = deviceSpecs.deviceName(id, config) this._startTime = time + this._updateTimeSync() + this._stateHandler = new StateHandler( { deviceId: id, @@ -113,7 +118,7 @@ export class DeviceInstanceWrapper extends EventEmitter { }) }) }, - getCurrentTime: this.getCurrentTime, + getCurrentTime: () => this.getCurrentTime(), }, { executionType: deviceSpecs.executionMode(config.options), @@ -194,6 +199,18 @@ export class DeviceInstanceWrapper extends EventEmitter { this._logDebugStates = value } + getCurrentTime(): number { + if ( + !this._lastUpdateCurrentTime || + this._tDiff === undefined || + Date.now() - this._lastUpdateCurrentTime > 5 * 60 * 1000 + ) { + this._updateTimeSync() + } + + return Date.now() + (this._tDiff ?? 0) + } + private _getDeviceContextAPI(): DeviceContextAPI { return { logger: { @@ -254,4 +271,19 @@ export class DeviceInstanceWrapper extends EventEmitter { }, } } + + private _updateTimeSync(): void { + this._lastUpdateCurrentTime = Date.now() // set this first so we don't update twice at the same time + + const start = Date.now() + this.getRemoteCurrentTime() + .then((t) => { + const end = Date.now() + + this._tDiff = t - Math.round((start + end) / 2) + }) + .catch((e) => { + this.emit('error', 'Error when syncing time', e) + }) + } } diff --git a/packages/timeline-state-resolver/src/service/__tests__/deviceInstance.spec.ts b/packages/timeline-state-resolver/src/service/__tests__/deviceInstance.spec.ts index 27cf65e03..f9f06f116 100644 --- a/packages/timeline-state-resolver/src/service/__tests__/deviceInstance.spec.ts +++ b/packages/timeline-state-resolver/src/service/__tests__/deviceInstance.spec.ts @@ -53,8 +53,8 @@ jest.mock('../../integrations/abstract/index', () => ({ }, })) -function getDeviceInstance(): DeviceInstanceWrapper { - return new DeviceInstanceWrapper('wrapper0', Date.now(), { type: DeviceType.ABSTRACT }, async () => Date.now()) +function getDeviceInstance(getTime = async () => Date.now()): DeviceInstanceWrapper { + return new DeviceInstanceWrapper('wrapper0', Date.now(), { type: DeviceType.ABSTRACT }, getTime) } describe('DeviceInstance', () => { @@ -161,5 +161,27 @@ describe('DeviceInstance', () => { }) }) + test('getCurrentTime', async () => { + const getRemoteTime = jest.fn(async () => Date.now() - 10) + const dev = getDeviceInstance(getRemoteTime) // simulate 10ms ipc delay + // wait for the first sync to happen + await new Promise((r) => setTimeout(() => r(), 10)) + expect(getRemoteTime).toHaveBeenCalledTimes(1) + + const t = dev.getCurrentTime() + // it may be a bit delayed + expect(t).toBeGreaterThanOrEqual(Date.now() - 12) + // it should never be faster + expect(t).toBeLessThanOrEqual(Date.now() - 10) + + // check that this still works after a bit of delay + await new Promise((r) => setTimeout(() => r(), 250)) + expect(getRemoteTime).toHaveBeenCalledTimes(1) + + const t2 = dev.getCurrentTime() + expect(t2).toBeGreaterThanOrEqual(Date.now() - 12) + expect(t2).toBeLessThanOrEqual(Date.now() - 10) + }) + // todo - test event handlers }) diff --git a/packages/timeline-state-resolver/src/service/__tests__/stateHandler.spec.ts b/packages/timeline-state-resolver/src/service/__tests__/stateHandler.spec.ts index 9ddca77fc..a33aba3e0 100644 --- a/packages/timeline-state-resolver/src/service/__tests__/stateHandler.spec.ts +++ b/packages/timeline-state-resolver/src/service/__tests__/stateHandler.spec.ts @@ -29,7 +29,7 @@ const CONTEXT = { }, emitTimeTrace: () => null, reportStateChangeMeasurement: () => null, - getCurrentTime: async () => Date.now(), + getCurrentTime: () => Date.now(), } describe('stateHandler', () => { diff --git a/packages/timeline-state-resolver/src/service/stateHandler.ts b/packages/timeline-state-resolver/src/service/stateHandler.ts index 08f68ca02..f246dfd84 100644 --- a/packages/timeline-state-resolver/src/service/stateHandler.ts +++ b/packages/timeline-state-resolver/src/service/stateHandler.ts @@ -46,28 +46,22 @@ export class StateHandler { this._commandQueue = new CommandQueue(this.config.executionType, async (c) => device.sendCommand(c)) this.clock = setInterval(() => { - context - .getCurrentTime() - .then((t) => { - // main clock to check if next state needs to be sent out - for (const state of this.stateQueue) { - const nextTime = Math.max(0, state?.state.time - (state?.preliminary ?? 0) - t) - if (nextTime > CLOCK_INTERVAL) break - // schedule any states between now and the next tick - - setTimeout(() => { - if (!this._executingStateChange && this.stateQueue[0] === state) { - // if this is the next state, execute it - this.executeNextStateChange().catch((e) => { - this.logger.error('Error while executing next state change', e) - }) - } - }, nextTime) + const t = context.getCurrentTime() + // main clock to check if next state needs to be sent out + for (const state of this.stateQueue) { + const nextTime = Math.max(0, state?.state.time - (state?.preliminary ?? 0) - t) + if (nextTime > CLOCK_INTERVAL) break + // schedule any states between now and the next tick + + setTimeout(() => { + if (!this._executingStateChange && this.stateQueue[0] === state) { + // if this is the next state, execute it + this.executeNextStateChange().catch((e) => { + this.logger.error('Error while executing next state change', e) + }) } - }) - .catch((e) => { - this.logger.error('Error in main StateHandler loop', e) - }) + }, nextTime) + } }, CLOCK_INTERVAL) } @@ -113,7 +107,7 @@ export class StateHandler { this.currentState = { commands: [], deviceState: state, - state: this.currentState?.state || { time: await this.context.getCurrentTime(), layers: {}, nextEvents: [] }, + state: this.currentState?.state || { time: this.context.getCurrentTime(), layers: {}, nextEvents: [] }, mappings: this.currentState?.mappings || {}, } await this.calculateNextStateChange() @@ -135,7 +129,7 @@ export class StateHandler { this.currentState?.deviceState, nextState.deviceState, nextState.mappings, - await this.context.getCurrentTime() + this.context.getCurrentTime() ) nextState.preliminary = Math.max(0, ...nextState.commands.map((c) => c.preliminary ?? 0)) this.context.emitTimeTrace(endTrace(trace)) @@ -146,10 +140,7 @@ export class StateHandler { nextState.commands = [] } - if ( - nextState.state.time - (nextState.preliminary ?? 0) <= (await this.context.getCurrentTime()) && - this.currentState - ) { + if (nextState.state.time - (nextState.preliminary ?? 0) <= this.context.getCurrentTime() && this.currentState) { await this.executeNextStateChange() } } @@ -211,5 +202,5 @@ export interface StateHandlerContext { emitTimeTrace: (trace: FinishedTrace) => void reportStateChangeMeasurement: (report: StateChangeReport) => void - getCurrentTime: () => Promise + getCurrentTime: () => number } From f9eccd53dcb162fb2c99accb7aaffc6a6a254c47 Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Fri, 8 Dec 2023 10:37:30 +0100 Subject: [PATCH 09/14] chore: fix some todos --- .../src/integrations/__tests__/testlib.ts | 1 + .../quantel/__tests__/quantel.spec.ts | 6 +++++- .../src/integrations/quantel/index.ts | 19 ++++++++++--------- .../src/service/DeviceInstance.ts | 2 ++ .../src/service/device.ts | 2 ++ 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/__tests__/testlib.ts b/packages/timeline-state-resolver/src/integrations/__tests__/testlib.ts index 95ea7c2ca..18017ca40 100644 --- a/packages/timeline-state-resolver/src/integrations/__tests__/testlib.ts +++ b/packages/timeline-state-resolver/src/integrations/__tests__/testlib.ts @@ -10,6 +10,7 @@ export function getDeviceContext(): DeviceContextAPI { info: jest.fn(), debug: jest.fn(), }, + getCurrentTime: jest.fn(() => Date.now()), emitDebugState: jest.fn(), connectionChanged: jest.fn(), resetResolver: jest.fn(), diff --git a/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantel.spec.ts b/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantel.spec.ts index 0130e9b37..147c7e5f9 100644 --- a/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantel.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantel.spec.ts @@ -1137,6 +1137,7 @@ describe('Quantel Device', () => { }) describe('sendCommand', () => { + const ogTimeout = setTimeout const mockTime = new MockTime() beforeAll(() => { mockTime.init() @@ -1146,6 +1147,9 @@ describe('Quantel Device', () => { // note - the internals of the QuantelManager class are state-based so it's easier to do all of this in one long test const dev = await getInitialisedQuantelDevice() + // give it some time to finish the init + await new Promise((r) => ogTimeout(() => r(), 10)) + dev .sendCommand({ command: { @@ -1161,7 +1165,7 @@ describe('Quantel Device', () => { throw e }) - // give it some time to settle + // give it some time to settle after the command await mockTime.tick() expect(onRequest).toHaveBeenCalledTimes(5) diff --git a/packages/timeline-state-resolver/src/integrations/quantel/index.ts b/packages/timeline-state-resolver/src/integrations/quantel/index.ts index 1aa7390be..1f25948eb 100644 --- a/packages/timeline-state-resolver/src/integrations/quantel/index.ts +++ b/packages/timeline-state-resolver/src/integrations/quantel/index.ts @@ -42,9 +42,7 @@ export class QuantelDevice extends Device { this._quantel = new QuantelGateway() this._quantel.on('error', (e) => this.context.logger.error('Quantel.QuantelGateway', e)) - // this._quantelManager = new QuantelManager(this._quantel, () => this.getCurrentTime(), { - // todo - obv - this._quantelManager = new QuantelManager(this._quantel, () => Date.now(), { + this._quantelManager = new QuantelManager(this._quantel, () => this.context.getCurrentTime(), { allowCloneClips: options.allowCloneClips, }) this._quantelManager.on('info', (x) => @@ -65,11 +63,14 @@ export class QuantelDevice extends Device { - this.context.connectionChanged(this.getStatus()) - }) + this._quantel + .init(options.gatewayUrl, isaURLs, options.zoneId, options.serverId) + .then(() => { + this._quantel.monitorServerStatus((_connected: boolean) => { + this.context.connectionChanged(this.getStatus()) + }) + }) + .catch((e) => this.context.logger.error('Error initialising quantel', e)) return Promise.resolve(true) } @@ -169,7 +170,7 @@ export class QuantelDevice extends Device { }, }, + getCurrentTime: () => this.getCurrentTime(), + emitDebugState: (state: object) => { if (this._logDebugStates) { this.emit('debugState', state) diff --git a/packages/timeline-state-resolver/src/service/device.ts b/packages/timeline-state-resolver/src/service/device.ts index 3ce91d525..7884faf3e 100644 --- a/packages/timeline-state-resolver/src/service/device.ts +++ b/packages/timeline-state-resolver/src/service/device.ts @@ -146,6 +146,8 @@ export interface DeviceContextAPI { /** Emit a "debugState" message */ emitDebugState: (state: object) => void + getCurrentTime: () => number + /** Notify that the connection status has changed. */ connectionChanged: (status: Omit) => void /** From 51df1448bf3e627465395e4a6e9ecc61a993cb6d Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Tue, 12 Dec 2023 13:07:38 +0100 Subject: [PATCH 10/14] chore: rename command executor --- .../{commandQueue.ts => commandExecutor.ts} | 20 ++++++++++++------- .../src/service/stateHandler.ts | 12 ++++++----- 2 files changed, 20 insertions(+), 12 deletions(-) rename packages/timeline-state-resolver/src/service/{commandQueue.ts => commandExecutor.ts} (67%) diff --git a/packages/timeline-state-resolver/src/service/commandQueue.ts b/packages/timeline-state-resolver/src/service/commandExecutor.ts similarity index 67% rename from packages/timeline-state-resolver/src/service/commandQueue.ts rename to packages/timeline-state-resolver/src/service/commandExecutor.ts index a2553ba28..2adb8dc9b 100644 --- a/packages/timeline-state-resolver/src/service/commandQueue.ts +++ b/packages/timeline-state-resolver/src/service/commandExecutor.ts @@ -1,26 +1,32 @@ import { BaseDeviceAPI, CommandWithContext } from './device' import { Measurement } from './measure' +import { StateHandlerContext } from './stateHandler' const wait = async (t: number) => new Promise((r) => setTimeout(() => r(), t)) -export class CommandQueue { +export class CommandExecutor { constructor( + private logger: StateHandlerContext['logger'], private mode: 'salvo' | 'sequential', private sendCommand: BaseDeviceAPI['sendCommand'] ) {} - async queueCommands(commands: Command[], measurement?: Measurement): Promise { + async executeCommands(commands: Command[], measurement?: Measurement): Promise { commands.sort((a, b) => (b.preliminary ?? 0) - (a.preliminary ?? 0)) const totalTime = commands[0].preliminary ?? 0 if (this.mode === 'salvo') { - return this._queueCommandsSalvo(totalTime, commands, measurement) + return this._executeCommandsSalvo(totalTime, commands, measurement) } else { - return this._queueCommandsSequential(totalTime, commands, measurement) + return this._executeCommandsSequential(totalTime, commands, measurement) } } - private async _queueCommandsSalvo(totalTime: number, commands: Command[], measurement?: Measurement): Promise { + private async _executeCommandsSalvo( + totalTime: number, + commands: Command[], + measurement?: Measurement + ): Promise { await Promise.allSettled( commands.map(async (command) => { const timeToWait = totalTime - (command.preliminary ?? 0) @@ -35,7 +41,7 @@ export class CommandQueue { ) } - private async _queueCommandsSequential( + private async _executeCommandsSequential( totalTime: number, commands: Command[], measurement?: Measurement @@ -48,7 +54,7 @@ export class CommandQueue { measurement?.executeCommand(command) await this.sendCommand(command).catch((e) => { - console.error('Error while executing command', e) // todo + this.logger.error('Error while executing command', e) }) measurement?.finishedCommandExecution(command) } diff --git a/packages/timeline-state-resolver/src/service/stateHandler.ts b/packages/timeline-state-resolver/src/service/stateHandler.ts index f246dfd84..25f99e70c 100644 --- a/packages/timeline-state-resolver/src/service/stateHandler.ts +++ b/packages/timeline-state-resolver/src/service/stateHandler.ts @@ -2,7 +2,7 @@ import { FinishedTrace, startTrace, endTrace } from '../lib' import { Mappings, Timeline, TSRTimelineContent } from 'timeline-state-resolver-types' import { BaseDeviceAPI, CommandWithContext } from './device' import { Measurement, StateChangeReport } from './measure' -import { CommandQueue } from './commandQueue' +import { CommandExecutor } from './commandExecutor' interface StateChange { commands?: Command[] @@ -26,7 +26,7 @@ export class StateHandler { private currentState: ExecutedStateChange | undefined /** Semaphore, to ensure that .executeNextStateChange() is only executed one at a time */ private _executingStateChange = false - private _commandQueue: CommandQueue + private _commandExecutor: CommandExecutor private clock: NodeJS.Timeout @@ -43,7 +43,9 @@ export class StateHandler { this.logger.error('Error while creating new StateHandler', e) }) - this._commandQueue = new CommandQueue(this.config.executionType, async (c) => device.sendCommand(c)) + this._commandExecutor = new CommandExecutor(context.logger, this.config.executionType, async (c) => + device.sendCommand(c) + ) this.clock = setInterval(() => { const t = context.getCurrentTime() @@ -167,8 +169,8 @@ export class StateHandler { this.currentState = undefined - this._commandQueue - .queueCommands(newState.commands, newState.measurement) + this._commandExecutor + .executeCommands(newState.commands, newState.measurement) .then(() => { if (newState.measurement) this.context.reportStateChangeMeasurement(newState.measurement.report()) }) From 20c7970bb2ba9faf2f6d3e97155dbe58ec013df7 Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Tue, 12 Dec 2023 13:13:04 +0100 Subject: [PATCH 11/14] chore: remove commented argument --- .../src/__tests__/mockDeviceInstanceWrapper.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/timeline-state-resolver/src/__tests__/mockDeviceInstanceWrapper.ts b/packages/timeline-state-resolver/src/__tests__/mockDeviceInstanceWrapper.ts index e809911ec..dacf50223 100644 --- a/packages/timeline-state-resolver/src/__tests__/mockDeviceInstanceWrapper.ts +++ b/packages/timeline-state-resolver/src/__tests__/mockDeviceInstanceWrapper.ts @@ -37,11 +37,7 @@ export class MockDeviceInstanceWrapper | 'removeAllListeners' > { - constructor( - public readonly deviceId: string, - _startTime: string, - public readonly config: DeviceOptionsAnyInternal // private readonly _getRemoteCurrentTime: () => number - ) { + constructor(public readonly deviceId: string, _startTime: string, public readonly config: DeviceOptionsAnyInternal) { super() // const deviceSpecs = DevicesDict[config.type] From 739d54a2ab1f6cda47a179ddfd7e0eee0da99d18 Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Tue, 12 Dec 2023 13:13:41 +0100 Subject: [PATCH 12/14] chore: remove underscore --- .../src/integrations/quantel/diff.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/quantel/diff.ts b/packages/timeline-state-resolver/src/integrations/quantel/diff.ts index 8bbbe2398..c83431ea7 100644 --- a/packages/timeline-state-resolver/src/integrations/quantel/diff.ts +++ b/packages/timeline-state-resolver/src/integrations/quantel/diff.ts @@ -56,12 +56,7 @@ export function diffStates( /** The time of when to run "preparation" commands */ const prelimTime = getPreliminaryTime(newState.time, oldState?.time, currentTime) - const lookaheadPreloadClips: { - portId: string - port: QuantelStatePort - clip: QuantelStatePortClip - timelineObjId: string - }[] = [] + const lookaheadPreloadClips: LookaheadPreloadClip[] = [] for (const [portId, newPort] of Object.entries(newState.port)) { // diff existing ports @@ -88,7 +83,7 @@ export function diffStates( } } // Lookaheads to preload: - _.each(lookaheadPreloadClips, (lookaheadPreloadClip) => { + Object.values(lookaheadPreloadClips).forEach((lookaheadPreloadClip) => { // Preloads of lookaheads are handled last, to ensure that any load-fragments of high-prio clips are done first. loadFragments( lookaheadPreloadClip.portId, @@ -111,6 +106,12 @@ export function diffStates( }) return allCommands } +interface LookaheadPreloadClip { + portId: string + port: QuantelStatePort + clip: QuantelStatePortClip + timelineObjId: string +} function getPreliminaryTime(time: number, oldTime: number | undefined, currentTime: number) { // we want to be at least PREPARE_TIME_WAIT ms after the old state From 698e890fb5c2bda782ed4398317ef4ae105bfbf0 Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Tue, 12 Dec 2023 13:13:57 +0100 Subject: [PATCH 13/14] chore: comment --- .../timeline-state-resolver/src/integrations/quantel/state.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/timeline-state-resolver/src/integrations/quantel/state.ts b/packages/timeline-state-resolver/src/integrations/quantel/state.ts index 7813a3150..ffff46e9b 100644 --- a/packages/timeline-state-resolver/src/integrations/quantel/state.ts +++ b/packages/timeline-state-resolver/src/integrations/quantel/state.ts @@ -24,7 +24,7 @@ export function getMappedPorts(mappings: Mappings): MappedPo } // push now, sort later - ports[mapping.options.portId].channels.push(mapping.options.channelId) + ports[mapping.options.portId].channels.push(mapping.options.channelId) // todo: support for multiple channels (these should be unique) } } From be884b6ecd647c4bccf6a80c26fce9e1029450c0 Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Tue, 12 Dec 2023 13:39:36 +0100 Subject: [PATCH 14/14] chore: sonarcloud --- .../src/integrations/quantel/__tests__/quantel.spec.ts | 1 - .../timeline-state-resolver/src/integrations/quantel/state.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantel.spec.ts b/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantel.spec.ts index 147c7e5f9..ff0946ae5 100644 --- a/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantel.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantel.spec.ts @@ -400,7 +400,6 @@ describe('Quantel Device', () => { }) test('Set up port', async () => { - jest await compareStates( { time: 1000, port: {} }, { diff --git a/packages/timeline-state-resolver/src/integrations/quantel/state.ts b/packages/timeline-state-resolver/src/integrations/quantel/state.ts index ffff46e9b..2688813c1 100644 --- a/packages/timeline-state-resolver/src/integrations/quantel/state.ts +++ b/packages/timeline-state-resolver/src/integrations/quantel/state.ts @@ -30,7 +30,7 @@ export function getMappedPorts(mappings: Mappings): MappedPo // now sort in place for (const port of Object.values(ports)) { - port.channels.sort() + port.channels = port.channels.sort((a, b) => a - b) } return ports