From 0e88e9e074c80743efb08376f4bd81d7de03313c Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Mon, 30 Sep 2024 14:32:53 +0200 Subject: [PATCH 01/12] fix: Quantel: call clearAllWaitWithPort prior to sending any play commands --- .../src/integrations/quantel/diff.ts | 24 +++++++++++++++++++ .../src/integrations/quantel/index.ts | 2 ++ .../src/integrations/quantel/types.ts | 6 +++++ 3 files changed, 32 insertions(+) diff --git a/packages/timeline-state-resolver/src/integrations/quantel/diff.ts b/packages/timeline-state-resolver/src/integrations/quantel/diff.ts index c83431ea7..8b3f555ba 100644 --- a/packages/timeline-state-resolver/src/integrations/quantel/diff.ts +++ b/packages/timeline-state-resolver/src/integrations/quantel/diff.ts @@ -104,6 +104,30 @@ export function diffStates( if (a.command.type !== QuantelCommandType.RELEASEPORT && b.command.type === QuantelCommandType.RELEASEPORT) return 1 return 0 }) + + const portIdsToClear: Set = new Set() + for (const cmd of allCommands) { + if ( + (cmd.command.type === QuantelCommandType.PLAYCLIP || cmd.command.type === QuantelCommandType.PAUSECLIP) && + !cmd.command.fromLookahead + ) { + // We should clear any delayed out-transitions for this port + portIdsToClear.add(cmd.command.portId) + } + } + + for (const portId of portIdsToClear.values()) { + allCommands.unshift({ + command: { + type: QuantelCommandType.CANCELWAITING, + portId: portId, + timelineObjId: '', + }, + timelineObjId: '', + context: 'Clear all delayed out-transitions', + }) + } + return allCommands } interface LookaheadPreloadClip { diff --git a/packages/timeline-state-resolver/src/integrations/quantel/index.ts b/packages/timeline-state-resolver/src/integrations/quantel/index.ts index 3b3b95dcd..e49c7ac1a 100644 --- a/packages/timeline-state-resolver/src/integrations/quantel/index.ts +++ b/packages/timeline-state-resolver/src/integrations/quantel/index.ts @@ -137,6 +137,8 @@ export class QuantelDevice extends Device Date: Mon, 30 Sep 2024 14:53:38 +0200 Subject: [PATCH 02/12] chore: add unit test for CommandExecutor: calling .executeCommands() multiple times. The commands should be executed in the order that they are given. --- .../service/__tests__/commandExecutor.spec.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 packages/timeline-state-resolver/src/service/__tests__/commandExecutor.spec.ts diff --git a/packages/timeline-state-resolver/src/service/__tests__/commandExecutor.spec.ts b/packages/timeline-state-resolver/src/service/__tests__/commandExecutor.spec.ts new file mode 100644 index 000000000..44e1e5142 --- /dev/null +++ b/packages/timeline-state-resolver/src/service/__tests__/commandExecutor.spec.ts @@ -0,0 +1,57 @@ +import { CommandExecutor } from '../commandExecutor' + +describe('commandExecutor', () => { + test('Sequential order', async () => { + // CommandExecutor + + const logger = { + debug: jest.fn((...args: any[]) => console.log('debug', ...args)), + info: jest.fn((info: string) => console.log('info', info)), + warn: jest.fn((warn: string) => console.log('warning', warn)), + error: jest.fn((context: string, e: Error) => console.log('error', context, e)), + } + const sendCommand = jest.fn() + + let sequence = 0 + + const executor = new CommandExecutor(logger, 'sequential', async (c) => { + await sleep(10) + sendCommand(c, sequence++) + }) + + await executor.executeCommands([makeCommand('a'), makeCommand('b'), makeCommand('c')]) + + expect(sendCommand).toHaveBeenCalledTimes(3) + expect(sendCommand).toHaveBeenNthCalledWith(1, makeCommand('a'), 0) + expect(sendCommand).toHaveBeenNthCalledWith(2, makeCommand('b'), 1) + expect(sendCommand).toHaveBeenNthCalledWith(3, makeCommand('c'), 2) + + sendCommand.mockClear() + sequence = 0 + + // If calling executeCommands several times synchronously, the commands should + // be executed in the order they are given: + + await Promise.all([ + executor.executeCommands([makeCommand('a'), makeCommand('b'), makeCommand('c')]), + executor.executeCommands([makeCommand('x'), makeCommand('y'), makeCommand('z')]), + ]) + + expect(sendCommand).toHaveBeenCalledTimes(6) + expect(sendCommand).toHaveBeenNthCalledWith(1, makeCommand('a'), 0) + expect(sendCommand).toHaveBeenNthCalledWith(2, makeCommand('b'), 1) + expect(sendCommand).toHaveBeenNthCalledWith(3, makeCommand('c'), 2) + expect(sendCommand).toHaveBeenNthCalledWith(4, makeCommand('x'), 3) + expect(sendCommand).toHaveBeenNthCalledWith(5, makeCommand('y'), 4) + expect(sendCommand).toHaveBeenNthCalledWith(6, makeCommand('z'), 5) + }) +}) + +function makeCommand(cmd: string) { + return { command: cmd, context: undefined, timelineObjId: '' } +} +async function sleep(time: number) { + return new Promise((resolve) => { + setTimeout(resolve, time) + }) +} From 5a68cc88d600e60a56ef5b655a8928abce11bfaa Mon Sep 17 00:00:00 2001 From: Jan Starzak Date: Mon, 30 Sep 2024 14:55:07 +0200 Subject: [PATCH 03/12] fix: update quantel tests --- .../quantel/__tests__/quantel.spec.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) 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 ff0946ae5..fc4045380 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 @@ -473,6 +473,15 @@ describe('Quantel Device', () => { }, }, [ + { + command: { + type: QuantelCommandType.CANCELWAITING, + portId: 'port0', + timelineObjId: '', + }, + context: 'Clear all delayed out-transitions', + timelineObjId: '', + }, { command: { type: QuantelCommandType.LOADCLIPFRAGMENTS, @@ -505,6 +514,7 @@ describe('Quantel Device', () => { mode: QuantelControlMode.QUALITY, transition: undefined, }, + preliminary: undefined, context: 'New clip is paused', timelineObjId: 'obj1', }, @@ -544,6 +554,15 @@ describe('Quantel Device', () => { }, }, [ + { + command: { + type: QuantelCommandType.CANCELWAITING, + portId: 'port0', + timelineObjId: '', + }, + context: 'Clear all delayed out-transitions', + timelineObjId: '', + }, { command: { type: QuantelCommandType.LOADCLIPFRAGMENTS, @@ -578,6 +597,7 @@ describe('Quantel Device', () => { }, context: 'New clip is paused', timelineObjId: 'obj1', + preliminary: undefined, }, ] ) @@ -620,6 +640,15 @@ describe('Quantel Device', () => { }, }, [ + { + command: { + type: QuantelCommandType.CANCELWAITING, + portId: 'port0', + timelineObjId: '', + }, + context: 'Clear all delayed out-transitions', + timelineObjId: '', + }, { command: { type: QuantelCommandType.LOADCLIPFRAGMENTS, @@ -654,6 +683,7 @@ describe('Quantel Device', () => { }, context: 'New clip is playing', timelineObjId: 'obj1', + preliminary: undefined, }, ] ) @@ -696,6 +726,15 @@ describe('Quantel Device', () => { }, }, [ + { + command: { + type: QuantelCommandType.CANCELWAITING, + portId: 'port0', + timelineObjId: '', + }, + context: 'Clear all delayed out-transitions', + timelineObjId: '', + }, { command: { type: QuantelCommandType.LOADCLIPFRAGMENTS, @@ -730,6 +769,7 @@ describe('Quantel Device', () => { }, context: 'New clip is playing', timelineObjId: 'obj1', + preliminary: undefined, }, ] ) @@ -808,6 +848,15 @@ describe('Quantel Device', () => { }, }, [ + { + command: { + type: QuantelCommandType.CANCELWAITING, + portId: 'port0', + timelineObjId: '', + }, + context: 'Clear all delayed out-transitions', + timelineObjId: '', + }, { command: { type: QuantelCommandType.LOADCLIPFRAGMENTS, @@ -842,6 +891,7 @@ describe('Quantel Device', () => { }, context: 'New clip is playing', timelineObjId: 'obj2', + preliminary: undefined, }, ], 15020 @@ -946,6 +996,15 @@ describe('Quantel Device', () => { }, }, [ + { + command: { + type: QuantelCommandType.CANCELWAITING, + portId: 'port0', + timelineObjId: '', + }, + context: 'Clear all delayed out-transitions', + timelineObjId: '', + }, { command: { type: QuantelCommandType.LOADCLIPFRAGMENTS, @@ -983,6 +1042,7 @@ describe('Quantel Device', () => { }, context: 'New clip is paused', timelineObjId: 'obj2', + preliminary: undefined, }, ], 11500 @@ -1031,6 +1091,15 @@ describe('Quantel Device', () => { }, }, [ + { + command: { + type: QuantelCommandType.CANCELWAITING, + portId: 'port0', + timelineObjId: '', + }, + context: 'Clear all delayed out-transitions', + timelineObjId: '', + }, { command: { type: QuantelCommandType.LOADCLIPFRAGMENTS, @@ -1065,6 +1134,7 @@ describe('Quantel Device', () => { }, context: 'New clip is paused', timelineObjId: 'obj2', + preliminary: undefined, }, ], 11500 From 6f330f7a8cb04dc1cb549a9d583e53a507ab56bd Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Mon, 30 Sep 2024 14:56:00 +0200 Subject: [PATCH 04/12] fix: add promise-queue to CommandExecutor. This is to ensure that all commands are executed in ssequential order, if calling .executeCommands() multiple times. --- .../src/service/commandExecutor.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/timeline-state-resolver/src/service/commandExecutor.ts b/packages/timeline-state-resolver/src/service/commandExecutor.ts index ba3b03137..ec8429967 100644 --- a/packages/timeline-state-resolver/src/service/commandExecutor.ts +++ b/packages/timeline-state-resolver/src/service/commandExecutor.ts @@ -2,10 +2,12 @@ import * as _ from 'underscore' import { BaseDeviceAPI, CommandWithContext } from './device' import { Measurement } from './measure' import { StateHandlerContext } from './stateHandler' +import PQueue from 'p-queue' const wait = async (t: number) => new Promise((r) => setTimeout(() => r(), t)) export class CommandExecutor { + private commandQueue = new PQueue({ concurrency: 1 }) constructor( private logger: StateHandlerContext['logger'], private mode: 'salvo' | 'sequential', @@ -18,11 +20,13 @@ export class CommandExecutor { commands.sort((a, b) => (b.preliminary ?? 0) - (a.preliminary ?? 0)) const totalTime = commands[0].preliminary ?? 0 - if (this.mode === 'salvo') { - return this._executeCommandsSalvo(totalTime, commands, measurement) - } else { - return this._executeCommandsSequential(totalTime, commands, measurement) - } + await this.commandQueue.add(async () => { + if (this.mode === 'salvo') { + return this._executeCommandsSalvo(totalTime, commands, measurement) + } else { + return this._executeCommandsSequential(totalTime, commands, measurement) + } + }) } private async _executeCommandsSalvo( From a7047ddcd255fe761ab8d1d04d5de28c91e50d98 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Tue, 1 Oct 2024 12:13:41 +0200 Subject: [PATCH 05/12] chore: quantel: add unit test to test cancelled outTransition --- .../quantel/__tests__/quantel.spec.ts | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) 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 ff0946ae5..031a8b913 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 @@ -8,6 +8,7 @@ import { SomeMappingQuantel, Timeline, TimelineContentQuantelAny, + TSRTimeline, TSRTimelineContent, } from 'timeline-state-resolver-types' import { QuantelCommandWithContext, QuantelDevice } from '..' @@ -15,6 +16,10 @@ import { QuantelCommandType, QuantelState } from '../types' import { setupQuantelGatewayMock } from './quantelGatewayMock' import { MockTime } from '../../../__tests__/mockTime' import { getDeviceContext } from '../../../integrations/__tests__/testlib' +import { StateHandler } from '../../../service/stateHandler' +import { CommandWithContext } from '../../..' +import { getResolvedState, resolveTimeline, TimelineObject } from 'superfly-timeline' +import { DevicesDict } from '../../../service/devices' async function getInitialisedQuantelDevice(clearMock?: jest.Mock) { const dev = new QuantelDevice(getDeviceContext()) @@ -1304,6 +1309,242 @@ describe('Quantel Device', () => { expect(onRequest).toHaveBeenNthCalledWith(1, 'post', expect.stringContaining('port/my_port/reset')) }) }) + describe('with StateHandler', () => { + const MOCK_SEND_COMMAND = jest.fn() + const CONTEXT = { + deviceId: 'unitTests0', + logger: { + debug: console.log, + info: console.log, + warn: console.log, + error: console.log, + }, + emitTimeTrace: () => null, + reportStateChangeMeasurement: () => null, + getCurrentTime: () => Date.now(), + } + function getNewStateHandler(dev: QuantelDevice): StateHandler { + const orgSendCommand = dev.sendCommand + dev.sendCommand = (...args) => { + MOCK_SEND_COMMAND(...args) + return orgSendCommand.apply(dev, args) + } + + const deviceSpecs = DevicesDict[DeviceType.QUANTEL] + return new StateHandler( + CONTEXT, + { + executionType: deviceSpecs.executionMode({}), + }, + dev + ) + } + function clearMocks() { + MOCK_SEND_COMMAND.mockClear() + onRequest.mockClear() + } + + test('outTransition to clear, cancel, then play another', async () => { + const dev = await getInitialisedQuantelDevice() + + // give it some time to finish the init + await sleep(10) + + const stateHandler = getNewStateHandler(dev) + + const timeline: TSRTimeline = [ + { + id: 'obj0', + enable: { + start: 1000, + end: 2000, + }, + content: { + deviceType: DeviceType.QUANTEL, + title: 'myClip0', + outTransition: { + type: QuantelTransitionType.DELAY, + delay: 1000, // 3000 + }, + }, + layer: 'layer0', + }, + { + id: 'obj1', + enable: { + start: 2500, + end: 10000, + }, + content: { + deviceType: DeviceType.QUANTEL, + title: 'myClip1', + }, + layer: 'layer0', + }, + ] + const mappings: Mappings = { + layer0: { + device: DeviceType.QUANTEL, + deviceId: 'quantel0', + options: { + mappingType: MappingQuantelType.Port, + portId: 'my_port', + channelId: 1, + }, + }, + } + const resolved = resolveTimeline(timeline, { + time: 0, + }) + + // Handle state at time 0 (nothing is playing) + { + const state = getResolvedState(resolved, 0) + await stateHandler.handleState(state, mappings) + + // Give QuantelManager some time to process the commands + await sleep(10) + + expect(MOCK_SEND_COMMAND).toHaveBeenCalledTimes(2) + expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + command: expect.objectContaining({ type: QuantelCommandType.SETUPPORT, portId: 'my_port', channel: 1 }), + }) + ) + expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + command: expect.objectContaining({ type: QuantelCommandType.CLEARCLIP, portId: 'my_port' }), + }) + ) + + expect(onRequest).toHaveBeenCalledWith('post', 'http://localhost:3000/connect/myISA%3A8000') + expect(onRequest).toHaveBeenCalledWith('get', 'http://localhost:3000/default/server') + expect(onRequest).toHaveBeenCalledWith('get', 'http://localhost:3000/default/server/1100/port/my_port') + expect(onRequest).toHaveBeenCalledWith( + 'put', + 'http://localhost:3000/default/server/1100/port/my_port/channel/1' + ) + expect(onRequest).toHaveBeenCalledWith('post', 'http://localhost:3000/default/server/1100/port/my_port/reset') + clearMocks() + } + + // Handle state at time 1000 (myClip0 starts to play) + { + const state = getResolvedState(resolved, 1000) + await stateHandler.handleState(state, mappings) + // Give QuantelManager some time to process the commands + await sleep(500) // at least SOFT_JUMP_WAIT_TIME + + expect(MOCK_SEND_COMMAND).toHaveBeenCalledTimes(2) + expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + command: expect.objectContaining({ + type: QuantelCommandType.LOADCLIPFRAGMENTS, + portId: 'my_port', + clip: expect.objectContaining({ title: 'myClip0' }), + }), + }) + ) + expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + command: expect.objectContaining({ + type: QuantelCommandType.PLAYCLIP, + portId: 'my_port', + clip: expect.objectContaining({ title: 'myClip0' }), + }), + }) + ) + + expect(onRequest).toHaveBeenCalledWith('post', expect.stringContaining('/1100/port/my_port/fragments?offset=0')) + expect(onRequest).toHaveBeenCalledWith('put', expect.stringContaining('/1100/port/my_port/jump?offset=')) + expect(onRequest).toHaveBeenCalledWith('post', expect.stringContaining('/1100/port/my_port/trigger/JUMP')) + expect(onRequest).toHaveBeenCalledWith('post', expect.stringContaining('/1100/port/my_port/trigger/START')) + expect(onRequest).toHaveBeenCalledWith('get', expect.stringContaining('/1100/port/my_port')) + expect(onRequest).toHaveBeenCalledWith( + 'post', + expect.stringContaining('/1100/port/my_port/trigger/STOP?offset=1999') + ) + + clearMocks() + } + // Handle state at time 2010 (myClip0 should stop (but is delayed due to outTransition)) + { + const state = getResolvedState(resolved, 2000) + await stateHandler.handleState(state, mappings) + // Give QuantelManager some time to process the commands + await sleep(500) + + expect(MOCK_SEND_COMMAND).toHaveBeenCalledTimes(1) + expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + command: expect.objectContaining({ + type: QuantelCommandType.CLEARCLIP, + portId: 'my_port', + transition: expect.objectContaining({ type: QuantelTransitionType.DELAY, delay: 1000 }), + }), + }) + ) + + // Since the output is delayed, we should not have sent any commands: + expect(onRequest).toHaveBeenCalledTimes(0) + + clearMocks() + } + // Handle state at time 2500 (myClip1 starts playing) + { + const state = getResolvedState(resolved, 2500) + await stateHandler.handleState(state, mappings) + + // Wait enough time to ensure that the outTransition from previous clip would have finished (had it not been cancelled) + await sleep(1000) + + expect(MOCK_SEND_COMMAND).toHaveBeenCalledTimes(2) + expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + command: expect.objectContaining({ + type: QuantelCommandType.LOADCLIPFRAGMENTS, + portId: 'my_port', + clip: expect.objectContaining({ title: 'myClip1' }), + }), + }) + ) + expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + command: expect.objectContaining({ + type: QuantelCommandType.PLAYCLIP, + portId: 'my_port', + clip: expect.objectContaining({ title: 'myClip1' }), + }), + }) + ) + + // Start playing of next clip: + expect(onRequest).toHaveBeenCalledWith( + 'post', + expect.stringContaining('/1100/port/my_port/fragments?offset=2000') + ) + expect(onRequest).toHaveBeenCalledWith('put', expect.stringContaining('/1100/port/my_port/jump?offset=')) + expect(onRequest).toHaveBeenCalledWith('post', expect.stringContaining('/1100/port/my_port/trigger/JUMP')) + expect(onRequest).toHaveBeenCalledWith('post', expect.stringContaining('/1100/port/my_port/trigger/START')) + expect(onRequest).toHaveBeenCalledWith('get', expect.stringContaining('/1100/port/my_port')) + expect(onRequest).toHaveBeenCalledWith( + 'post', + expect.stringContaining('/1100/port/my_port/trigger/STOP?offset=3233') + ) + // The first clip should NOT have stopped, as it was delayed and cancelled: + expect(onRequest).not.toHaveBeenCalledWith('post', expect.stringContaining('trigger/STOP')) + + clearMocks() + } + }) + }) }) function createTimelineState( @@ -1324,3 +1565,7 @@ function createTimelineState( nextEvents: [], } } + +async function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} From d8def8fa7518407ed7fb00adf96b5f2744505dda Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Tue, 1 Oct 2024 13:42:10 +0200 Subject: [PATCH 06/12] chore: fix test --- .../src/integrations/quantel/__tests__/quantel.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 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 031a8b913..b8b18b073 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 @@ -1471,7 +1471,7 @@ describe('Quantel Device', () => { clearMocks() } - // Handle state at time 2010 (myClip0 should stop (but is delayed due to outTransition)) + // Handle state at time 2000 (myClip0 should stop (but is delayed due to outTransition)) { const state = getResolvedState(resolved, 2000) await stateHandler.handleState(state, mappings) @@ -1539,7 +1539,7 @@ describe('Quantel Device', () => { expect.stringContaining('/1100/port/my_port/trigger/STOP?offset=3233') ) // The first clip should NOT have stopped, as it was delayed and cancelled: - expect(onRequest).not.toHaveBeenCalledWith('post', expect.stringContaining('trigger/STOP')) + expect(onRequest).not.toHaveBeenCalledWith('post', expect.stringContaining('reset')) clearMocks() } From d9eccdca93a7f230b8de45c7b671dfdf05fe549d Mon Sep 17 00:00:00 2001 From: Jan Starzak Date: Tue, 1 Oct 2024 15:40:13 +0200 Subject: [PATCH 07/12] chore: add more logging to QuantelManager --- .../src/integrations/quantel/connection.ts | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/quantel/connection.ts b/packages/timeline-state-resolver/src/integrations/quantel/connection.ts index cddd9f7f6..21b3de7f7 100644 --- a/packages/timeline-state-resolver/src/integrations/quantel/connection.ts +++ b/packages/timeline-state-resolver/src/integrations/quantel/connection.ts @@ -25,7 +25,15 @@ interface QuantelManagerOptions { /** If set: If a clip turns out to be on the wrong server, an attempt to copy the clip will be done. */ allowCloneClips?: boolean } -export class QuantelManager extends EventEmitter { + +interface QuantelManagerEvents { + info: [arg0: any] + warning: [arg0: any] + error: [err: any] + debug: [...arg: any] +} + +export class QuantelManager extends EventEmitter { private _quantelState: QuantelTrackedState = { port: {}, } @@ -48,7 +56,7 @@ export class QuantelManager extends EventEmitter { private options: QuantelManagerOptions ) { super() - this._quantel.on('error', (...args) => this.emit('error', ...args)) + this._quantel.on('error', (...args) => this.emit('error', args)) this._quantel.on('debug', (...args) => this.emit('debug', ...args)) } @@ -349,12 +357,20 @@ export class QuantelManager extends EventEmitter { private async prepareClipJump(cmd: QuantelCommandClip, alsoDoAction: 'play' | 'pause'): Promise { // Fetch tracked reference to the loaded clip: const trackedPort = this.getTrackedPort(cmd.portId) + const cmdStr = JSON.stringify(cmd) + this.emit('debug', `prepareClipJump: cmd=${cmdStr} ${alsoDoAction}`) + if (cmd.transition) { if (cmd.transition.type === QuantelTransitionType.DELAY) { + this.emit( + 'debug', + `prepareClipJump: cmd=${cmdStr} ${alsoDoAction}, waiting with port ${cmd.portId} for ${cmd.transition.delay}ms` + ) if (await this.waitWithPort(cmd.portId, cmd.transition.delay)) { // at this point, the wait aws aborted by someone else. Do nothing then. return } + this.emit('debug', `prepareClipJump: cmd=${cmdStr} ${alsoDoAction}, waiting done`) } } @@ -374,9 +390,9 @@ export class QuantelManager extends EventEmitter { ) this.emit( 'warning', - `prepareClipJump: cmd=${JSON.stringify( - cmd - )}: ${alsoDoAction}: clipId=${clipId}: jumpToOffset=${jumpToOffset}: trackedPort=${JSON.stringify(trackedPort)}` + `prepareClipJump: cmd=${cmdStr}: ${alsoDoAction}: clipId=${clipId}: jumpToOffset=${jumpToOffset}: trackedPort=${JSON.stringify( + trackedPort + )}` ) if ( (jumpToOffset === trackedPort.offset && trackedPort.playing === false) || // On request to play clip again, prepare jump // We're already there @@ -452,6 +468,7 @@ export class QuantelManager extends EventEmitter { if (alsoDoAction === 'play') { // Start playing: + this.emit('debug', `prepareClipJump: cmd=${cmdStr} ${alsoDoAction}, portPlay`) await this._quantel.portPlay(cmd.portId) await this.wait(60) @@ -497,6 +514,7 @@ export class QuantelManager extends EventEmitter { trackedPort.scheduledStop = loadedFragments.portOutPoint } } else if (alsoDoAction === 'pause' && trackedPort.playing) { + this.emit('debug', `prepareClipJump: cmd=${cmdStr} ${alsoDoAction}, portHardJump ${jumpToOffset}`) await this._quantel.portHardJump(cmd.portId, jumpToOffset) trackedPort.offset = jumpToOffset From eaa7b58dc23ec1b2b9561e6795494627daf15619 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Thu, 3 Oct 2024 08:15:50 +0200 Subject: [PATCH 08/12] fix: cancel waits on port for more commandTypes --- .../src/integrations/quantel/diff.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/quantel/diff.ts b/packages/timeline-state-resolver/src/integrations/quantel/diff.ts index 8b3f555ba..bf0643e12 100644 --- a/packages/timeline-state-resolver/src/integrations/quantel/diff.ts +++ b/packages/timeline-state-resolver/src/integrations/quantel/diff.ts @@ -105,18 +105,22 @@ export function diffStates( return 0 }) - const portIdsToClear: Set = new Set() + // If we run any play-command, we will need to cancel any delayed/waiting out-transitions for that port: + const portIdsToCancelWaiting: Set = new Set() for (const cmd of allCommands) { if ( - (cmd.command.type === QuantelCommandType.PLAYCLIP || cmd.command.type === QuantelCommandType.PAUSECLIP) && + (cmd.command.type === QuantelCommandType.PLAYCLIP || + cmd.command.type === QuantelCommandType.PAUSECLIP || + cmd.command.type === QuantelCommandType.CLEARCLIP || + cmd.command.type === QuantelCommandType.RELEASEPORT) && !cmd.command.fromLookahead ) { // We should clear any delayed out-transitions for this port - portIdsToClear.add(cmd.command.portId) + portIdsToCancelWaiting.add(cmd.command.portId) } } - for (const portId of portIdsToClear.values()) { + for (const portId of portIdsToCancelWaiting.values()) { allCommands.unshift({ command: { type: QuantelCommandType.CANCELWAITING, From 7d350888d1186bde85d1659757c4d1c2eebca92f Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Thu, 3 Oct 2024 08:19:13 +0200 Subject: [PATCH 09/12] chore: update tests --- .../src/__tests__/mockTime.ts | 3 + .../quantel/__tests__/quantel.spec.ts | 379 +++++++++++++++++- .../quantel/__tests__/quantelGatewayMock.ts | 2 +- .../src/integrations/quantel/connection.ts | 9 +- 4 files changed, 373 insertions(+), 20 deletions(-) diff --git a/packages/timeline-state-resolver/src/__tests__/mockTime.ts b/packages/timeline-state-resolver/src/__tests__/mockTime.ts index 3c9ad8c86..338e5f08b 100644 --- a/packages/timeline-state-resolver/src/__tests__/mockTime.ts +++ b/packages/timeline-state-resolver/src/__tests__/mockTime.ts @@ -22,6 +22,9 @@ export class MockTime { this._now = 10000 jest.useFakeTimers({ now: this._now }) } + reset = () => { + jest.useRealTimers() + } advanceTime = (advanceTime: number) => { this._now += advanceTime jest.advanceTimersByTime(advanceTime) 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 cd3df9606..9da73c506 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 @@ -18,8 +18,9 @@ import { MockTime } from '../../../__tests__/mockTime' import { getDeviceContext } from '../../../integrations/__tests__/testlib' import { StateHandler } from '../../../service/stateHandler' import { CommandWithContext } from '../../..' -import { getResolvedState, resolveTimeline, TimelineObject } from 'superfly-timeline' +import { getResolvedState, resolveTimeline } from 'superfly-timeline' import { DevicesDict } from '../../../service/devices' +import { setSoftJumpWaitTime } from '../connection' async function getInitialisedQuantelDevice(clearMock?: jest.Mock) { const dev = new QuantelDevice(getDeviceContext()) @@ -420,6 +421,15 @@ describe('Quantel Device', () => { }, }, [ + { + command: { + type: QuantelCommandType.CANCELWAITING, + portId: 'port0', + timelineObjId: '', + }, + context: 'Clear all delayed out-transitions', + timelineObjId: '', + }, { command: { type: QuantelCommandType.SETUPPORT, @@ -801,6 +811,15 @@ describe('Quantel Device', () => { }, { time: 3000, port: {} }, [ + { + command: { + type: QuantelCommandType.CANCELWAITING, + portId: 'port0', + timelineObjId: '', + }, + context: 'Clear all delayed out-transitions', + timelineObjId: '', + }, { command: { type: QuantelCommandType.RELEASEPORT, @@ -941,6 +960,15 @@ describe('Quantel Device', () => { }, }, [ + { + command: { + type: QuantelCommandType.CANCELWAITING, + portId: 'port0', + timelineObjId: '', + }, + context: 'Clear all delayed out-transitions', + timelineObjId: '', + }, { command: { type: QuantelCommandType.CLEARCLIP, @@ -1172,6 +1200,24 @@ describe('Quantel Device', () => { }, }, [ + { + command: { + type: QuantelCommandType.CANCELWAITING, + portId: 'port0_renamed', + timelineObjId: '', + }, + context: 'Clear all delayed out-transitions', + timelineObjId: '', + }, + { + command: { + type: QuantelCommandType.CANCELWAITING, + portId: 'port0', + timelineObjId: '', + }, + context: 'Clear all delayed out-transitions', + timelineObjId: '', + }, { command: { type: QuantelCommandType.RELEASEPORT, @@ -1216,6 +1262,9 @@ describe('Quantel Device', () => { beforeAll(() => { mockTime.init() }) + afterAll(() => { + mockTime.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 @@ -1394,8 +1443,9 @@ describe('Quantel Device', () => { getCurrentTime: () => Date.now(), } function getNewStateHandler(dev: QuantelDevice): StateHandler { + // eslint-disable-next-line @typescript-eslint/unbound-method const orgSendCommand = dev.sendCommand - dev.sendCommand = (...args) => { + dev.sendCommand = async (...args) => { MOCK_SEND_COMMAND(...args) return orgSendCommand.apply(dev, args) } @@ -1413,6 +1463,9 @@ describe('Quantel Device', () => { MOCK_SEND_COMMAND.mockClear() onRequest.mockClear() } + beforeAll(() => { + setSoftJumpWaitTime(0) + }) test('outTransition to clear, cancel, then play another', async () => { const dev = await getInitialisedQuantelDevice() @@ -1434,7 +1487,7 @@ describe('Quantel Device', () => { title: 'myClip0', outTransition: { type: QuantelTransitionType.DELAY, - delay: 1000, // 3000 + delay: 500, // 2500 }, }, layer: 'layer0', @@ -1442,7 +1495,7 @@ describe('Quantel Device', () => { { id: 'obj1', enable: { - start: 2500, + start: 2100, end: 10000, }, content: { @@ -1474,16 +1527,21 @@ describe('Quantel Device', () => { // Give QuantelManager some time to process the commands await sleep(10) - - expect(MOCK_SEND_COMMAND).toHaveBeenCalledTimes(2) + expect(MOCK_SEND_COMMAND).toHaveBeenCalledTimes(3) expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( 1, expect.objectContaining({ - command: expect.objectContaining({ type: QuantelCommandType.SETUPPORT, portId: 'my_port', channel: 1 }), + command: expect.objectContaining({ type: QuantelCommandType.CANCELWAITING, portId: 'my_port' }), }) ) expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( 2, + expect.objectContaining({ + command: expect.objectContaining({ type: QuantelCommandType.SETUPPORT, portId: 'my_port', channel: 1 }), + }) + ) + expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( + 3, expect.objectContaining({ command: expect.objectContaining({ type: QuantelCommandType.CLEARCLIP, portId: 'my_port' }), }) @@ -1505,11 +1563,20 @@ describe('Quantel Device', () => { const state = getResolvedState(resolved, 1000) await stateHandler.handleState(state, mappings) // Give QuantelManager some time to process the commands - await sleep(500) // at least SOFT_JUMP_WAIT_TIME + await sleep(100) - expect(MOCK_SEND_COMMAND).toHaveBeenCalledTimes(2) + expect(MOCK_SEND_COMMAND).toHaveBeenCalledTimes(3) expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( 1, + expect.objectContaining({ + command: expect.objectContaining({ + type: QuantelCommandType.CANCELWAITING, + portId: 'my_port', + }), + }) + ) + expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ command: expect.objectContaining({ type: QuantelCommandType.LOADCLIPFRAGMENTS, @@ -1519,7 +1586,7 @@ describe('Quantel Device', () => { }) ) expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( - 2, + 3, expect.objectContaining({ command: expect.objectContaining({ type: QuantelCommandType.PLAYCLIP, @@ -1546,36 +1613,311 @@ describe('Quantel Device', () => { const state = getResolvedState(resolved, 2000) await stateHandler.handleState(state, mappings) // Give QuantelManager some time to process the commands - await sleep(500) + await sleep(10) - expect(MOCK_SEND_COMMAND).toHaveBeenCalledTimes(1) + expect(MOCK_SEND_COMMAND).toHaveBeenCalledTimes(2) expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( 1, + expect.objectContaining({ + command: expect.objectContaining({ + type: QuantelCommandType.CANCELWAITING, + portId: 'my_port', + }), + }) + ) + expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ command: expect.objectContaining({ type: QuantelCommandType.CLEARCLIP, portId: 'my_port', - transition: expect.objectContaining({ type: QuantelTransitionType.DELAY, delay: 1000 }), + transition: { type: QuantelTransitionType.DELAY, delay: 500 }, }), }) ) - // Since the output is delayed, we should not have sent any commands: + // Since the clear is delayed, we should not have sent any commands: + expect(onRequest).toHaveBeenCalledTimes(0) clearMocks() } + // Handle state at time 2100 (myClip1 starts playing) + { + const state = getResolvedState(resolved, 2100) + await stateHandler.handleState(state, mappings) + + // Wait enough time to ensure that the outTransition from previous clip would have finished (had it not been cancelled) + await sleep(500) + + expect(MOCK_SEND_COMMAND).toHaveBeenCalledTimes(3) + expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + command: expect.objectContaining({ + type: QuantelCommandType.CANCELWAITING, + portId: 'my_port', + }), + }) + ) + expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + command: expect.objectContaining({ + type: QuantelCommandType.LOADCLIPFRAGMENTS, + portId: 'my_port', + clip: expect.objectContaining({ title: 'myClip1' }), + }), + }) + ) + expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + command: expect.objectContaining({ + type: QuantelCommandType.PLAYCLIP, + portId: 'my_port', + clip: expect.objectContaining({ title: 'myClip1' }), + }), + }) + ) + // Start playing of next clip: + expect(onRequest).toHaveBeenCalledWith( + 'post', + expect.stringContaining('/1100/port/my_port/fragments?offset=2000') + ) + expect(onRequest).toHaveBeenCalledWith('put', expect.stringContaining('/1100/port/my_port/jump?offset=')) + expect(onRequest).toHaveBeenCalledWith('post', expect.stringContaining('/1100/port/my_port/trigger/JUMP')) + expect(onRequest).toHaveBeenCalledWith('post', expect.stringContaining('/1100/port/my_port/trigger/START')) + expect(onRequest).toHaveBeenCalledWith('get', expect.stringContaining('/1100/port/my_port')) + expect(onRequest).toHaveBeenCalledWith( + 'post', + expect.stringContaining('/1100/port/my_port/trigger/STOP?offset=3233') + ) + // The first clip should NOT have stopped, as it was delayed and cancelled: + expect(onRequest).not.toHaveBeenCalledWith('post', expect.stringMatching(/trigger\/STOP$/)) + + clearMocks() + } + await dev.terminate() + }) + test('outTransition to lookahead, cancel, then play another', async () => { + const dev = await getInitialisedQuantelDevice() + + // give it some time to finish the init + await sleep(10) + + const stateHandler = getNewStateHandler(dev) + + const timeline: TSRTimeline = [ + { + id: 'obj0', + enable: { + start: 1000, + end: 2000, + }, + content: { + deviceType: DeviceType.QUANTEL, + title: 'myClip0', + outTransition: { + type: QuantelTransitionType.DELAY, + delay: 500, // 2500 + }, + }, + layer: 'layer0', + }, + { + id: 'obj0_lookahead', + enable: { + start: '#obj0.end', + end: '#obj1.start', + }, + content: { + deviceType: DeviceType.QUANTEL, + title: 'myClip0', + // outTransition: { + // type: QuantelTransitionType.DELAY, + // delay: 1000, // 3000 + // }, + }, + layer: 'layer0_lookahead', + lookaheadForLayer: 'layer0', + isLookahead: true, + }, + { + id: 'obj1', + enable: { + start: 2100, + end: 10000, + }, + content: { + deviceType: DeviceType.QUANTEL, + title: 'myClip1', + }, + layer: 'layer0', + }, + ] + const mappings: Mappings = { + layer0: { + device: DeviceType.QUANTEL, + deviceId: 'quantel0', + options: { + mappingType: MappingQuantelType.Port, + portId: 'my_port', + channelId: 1, + }, + }, + } + const resolved = resolveTimeline(timeline, { + time: 0, + }) + + // Handle state at time 0 (nothing is playing) + { + const state = getResolvedState(resolved, 0) + + await stateHandler.handleState(state, mappings) + + // Give QuantelManager some time to process the commands + + await sleep(10) + + expect(MOCK_SEND_COMMAND).toHaveBeenCalledTimes(3) + expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + command: expect.objectContaining({ type: QuantelCommandType.CANCELWAITING, portId: 'my_port' }), + }) + ) + expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + command: expect.objectContaining({ type: QuantelCommandType.SETUPPORT, portId: 'my_port', channel: 1 }), + }) + ) + expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + command: expect.objectContaining({ type: QuantelCommandType.CLEARCLIP, portId: 'my_port' }), + }) + ) + + expect(onRequest).toHaveBeenCalledWith('post', 'http://localhost:3000/connect/myISA%3A8000') + expect(onRequest).toHaveBeenCalledWith('get', 'http://localhost:3000/default/server') + expect(onRequest).toHaveBeenCalledWith('get', 'http://localhost:3000/default/server/1100/port/my_port') + expect(onRequest).toHaveBeenCalledWith( + 'put', + 'http://localhost:3000/default/server/1100/port/my_port/channel/1' + ) + expect(onRequest).toHaveBeenCalledWith('post', 'http://localhost:3000/default/server/1100/port/my_port/reset') + clearMocks() + } + + // Handle state at time 1000 (myClip0 starts to play) + { + const state = getResolvedState(resolved, 1000) + await stateHandler.handleState(state, mappings) + // Give QuantelManager some time to process the commands + await sleep(100) + + expect(MOCK_SEND_COMMAND).toHaveBeenCalledTimes(3) + expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + command: expect.objectContaining({ + type: QuantelCommandType.CANCELWAITING, + portId: 'my_port', + }), + }) + ) + expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + command: expect.objectContaining({ + type: QuantelCommandType.LOADCLIPFRAGMENTS, + portId: 'my_port', + clip: expect.objectContaining({ title: 'myClip0' }), + }), + }) + ) + expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + command: expect.objectContaining({ + type: QuantelCommandType.PLAYCLIP, + portId: 'my_port', + clip: expect.objectContaining({ title: 'myClip0' }), + }), + }) + ) + expect(onRequest).toHaveBeenCalledWith('post', expect.stringContaining('/1100/port/my_port/fragments?offset=0')) + expect(onRequest).toHaveBeenCalledWith('put', expect.stringContaining('/1100/port/my_port/jump?offset=')) + expect(onRequest).toHaveBeenCalledWith('post', expect.stringContaining('/1100/port/my_port/trigger/JUMP')) + expect(onRequest).toHaveBeenCalledWith('post', expect.stringContaining('/1100/port/my_port/trigger/START')) + expect(onRequest).toHaveBeenCalledWith('get', expect.stringContaining('/1100/port/my_port')) + expect(onRequest).toHaveBeenCalledWith( + 'post', + expect.stringContaining('/1100/port/my_port/trigger/STOP?offset=1999') + ) + + clearMocks() + } + // Handle state at time 2000 (myClip0 should stop (but is delayed due to outTransition)) + { + const state = getResolvedState(resolved, 2000) + await stateHandler.handleState(state, mappings) + // Give QuantelManager some time to process the commands + await sleep(10) + + expect(MOCK_SEND_COMMAND).toHaveBeenCalledTimes(2) + expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + command: expect.objectContaining({ + type: QuantelCommandType.LOADCLIPFRAGMENTS, + portId: 'my_port', + clip: expect.objectContaining({ title: 'myClip0' }), + }), + }) + ) + expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + command: expect.objectContaining({ + type: QuantelCommandType.PAUSECLIP, + portId: 'my_port', + clip: expect.objectContaining({ title: 'myClip0' }), + transition: { type: QuantelTransitionType.DELAY, delay: 500 }, + }), + }) + ) + + // Since the pause is delayed, we should not have sent any reset or stop commands: + + expect(onRequest).not.toHaveBeenCalledWith('post', expect.stringContaining('reset')) + expect(onRequest).not.toHaveBeenCalledWith('post', expect.stringContaining('trigger/STOP')) + + clearMocks() + } // Handle state at time 2500 (myClip1 starts playing) { const state = getResolvedState(resolved, 2500) await stateHandler.handleState(state, mappings) // Wait enough time to ensure that the outTransition from previous clip would have finished (had it not been cancelled) - await sleep(1000) + await sleep(500) - expect(MOCK_SEND_COMMAND).toHaveBeenCalledTimes(2) + expect(MOCK_SEND_COMMAND).toHaveBeenCalledTimes(3) expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( 1, + expect.objectContaining({ + command: expect.objectContaining({ + type: QuantelCommandType.CANCELWAITING, + portId: 'my_port', + }), + }) + ) + expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ command: expect.objectContaining({ type: QuantelCommandType.LOADCLIPFRAGMENTS, @@ -1585,7 +1927,7 @@ describe('Quantel Device', () => { }) ) expect(MOCK_SEND_COMMAND).toHaveBeenNthCalledWith( - 2, + 3, expect.objectContaining({ command: expect.objectContaining({ type: QuantelCommandType.PLAYCLIP, @@ -1609,10 +1951,11 @@ describe('Quantel Device', () => { expect.stringContaining('/1100/port/my_port/trigger/STOP?offset=3233') ) // The first clip should NOT have stopped, as it was delayed and cancelled: - expect(onRequest).not.toHaveBeenCalledWith('post', expect.stringContaining('reset')) + expect(onRequest).not.toHaveBeenCalledWith('post', expect.stringMatching(/trigger\/STOP$/)) clearMocks() } + await dev.terminate() }) }) }) diff --git a/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantelGatewayMock.ts b/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantelGatewayMock.ts index c9ce6754e..6b968b86d 100644 --- a/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantelGatewayMock.ts +++ b/packages/timeline-state-resolver/src/integrations/quantel/__tests__/quantelGatewayMock.ts @@ -313,7 +313,7 @@ async function handleRequest( 'delete /:zoneID/server/:serverID/port/:portID': async (params): Promise => { if (!quantelServer.ISAOptionHasBeenProvided) return noIsaSetupResponse - await sleep(100) + await sleep(1) const port = quantelServer.ports[params.portID] diff --git a/packages/timeline-state-resolver/src/integrations/quantel/connection.ts b/packages/timeline-state-resolver/src/integrations/quantel/connection.ts index cddd9f7f6..b5160d940 100644 --- a/packages/timeline-state-resolver/src/integrations/quantel/connection.ts +++ b/packages/timeline-state-resolver/src/integrations/quantel/connection.ts @@ -16,7 +16,7 @@ import { QuantelTrackedStatePort, } from './types' -const SOFT_JUMP_WAIT_TIME = 250 +let SOFT_JUMP_WAIT_TIME = 250 // Is a constant, but can be changed during unit tests const DEFAULT_FPS = 25 // frames per second const JUMP_ERROR_MARGIN = 10 // frames @@ -662,3 +662,10 @@ class Cache { }, 1) } } + +/** + * USED IN UNIT TESTS ONLY + */ +export function setSoftJumpWaitTime(time: number) { + SOFT_JUMP_WAIT_TIME = time +} From bf420364030e3fb9025c1aae6c84425a9f6d08b0 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Thu, 3 Oct 2024 10:37:25 +0200 Subject: [PATCH 10/12] chore: refactor: add enum ExecuteMode --- .../service/__tests__/commandExecutor.spec.ts | 3 +- .../service/__tests__/stateHandler.spec.ts | 3 +- .../src/service/commandExecutor.ts | 4 +- .../src/service/device.ts | 5 +++ .../src/service/devices.ts | 38 +++++++++---------- .../src/service/stateHandler.ts | 4 +- 6 files changed, 32 insertions(+), 25 deletions(-) diff --git a/packages/timeline-state-resolver/src/service/__tests__/commandExecutor.spec.ts b/packages/timeline-state-resolver/src/service/__tests__/commandExecutor.spec.ts index 44e1e5142..6e3a10c3f 100644 --- a/packages/timeline-state-resolver/src/service/__tests__/commandExecutor.spec.ts +++ b/packages/timeline-state-resolver/src/service/__tests__/commandExecutor.spec.ts @@ -1,4 +1,5 @@ import { CommandExecutor } from '../commandExecutor' +import { ExecuteMode } from '../device' describe('commandExecutor', () => { test('Sequential order', async () => { @@ -14,7 +15,7 @@ describe('commandExecutor', () => { let sequence = 0 - const executor = new CommandExecutor(logger, 'sequential', async (c) => { + const executor = new CommandExecutor(logger, ExecuteMode.SEQUENTIAL, async (c) => { await sleep(10) sendCommand(c, sequence++) }) 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 a33aba3e0..39b44d4b0 100644 --- a/packages/timeline-state-resolver/src/service/__tests__/stateHandler.spec.ts +++ b/packages/timeline-state-resolver/src/service/__tests__/stateHandler.spec.ts @@ -1,6 +1,7 @@ import { Timeline, TSRTimelineContent } from 'timeline-state-resolver-types' import { StateHandler } from '../stateHandler' import { MockTime } from '../../__tests__/mockTime' +import { ExecuteMode } from '../device' interface DeviceState { [prop: string]: { @@ -44,7 +45,7 @@ describe('stateHandler', () => { return new StateHandler( CONTEXT, { - executionType: 'salvo', + executionType: ExecuteMode.SALVO, }, { convertTimelineStateToDeviceState: (s) => s.layers as unknown as DeviceState, diff --git a/packages/timeline-state-resolver/src/service/commandExecutor.ts b/packages/timeline-state-resolver/src/service/commandExecutor.ts index ec8429967..c16bf5533 100644 --- a/packages/timeline-state-resolver/src/service/commandExecutor.ts +++ b/packages/timeline-state-resolver/src/service/commandExecutor.ts @@ -1,5 +1,5 @@ import * as _ from 'underscore' -import { BaseDeviceAPI, CommandWithContext } from './device' +import { BaseDeviceAPI, CommandWithContext, ExecuteMode } from './device' import { Measurement } from './measure' import { StateHandlerContext } from './stateHandler' import PQueue from 'p-queue' @@ -10,7 +10,7 @@ export class CommandExecutor { private commandQueue = new PQueue({ concurrency: 1 }) constructor( private logger: StateHandlerContext['logger'], - private mode: 'salvo' | 'sequential', + private defaultMode: ExecuteMode, private sendCommand: BaseDeviceAPI['sendCommand'] ) {} diff --git a/packages/timeline-state-resolver/src/service/device.ts b/packages/timeline-state-resolver/src/service/device.ts index 50fe277f4..5d2ac791b 100644 --- a/packages/timeline-state-resolver/src/service/device.ts +++ b/packages/timeline-state-resolver/src/service/device.ts @@ -22,6 +22,11 @@ export type CommandWithContext = { queueId?: string } +export enum ExecuteMode { + SALVO = 'salvo', + SEQUENTIAL = 'sequential', +} + /** * API for use by the DeviceInstance to be able to use a device */ diff --git a/packages/timeline-state-resolver/src/service/devices.ts b/packages/timeline-state-resolver/src/service/devices.ts index 98574cbb5..65b4462d9 100644 --- a/packages/timeline-state-resolver/src/service/devices.ts +++ b/packages/timeline-state-resolver/src/service/devices.ts @@ -1,6 +1,6 @@ import { OscDevice } from '../integrations/osc' import { DeviceType } from 'timeline-state-resolver-types' -import { Device, DeviceContextAPI } from './device' +import { Device, DeviceContextAPI, ExecuteMode } from './device' import { AuthenticatedHTTPSendDevice } from '../integrations/httpSend/AuthenticatedHTTPSendDevice' import { ShotokuDevice } from '../integrations/shotoku' import { HTTPWatcherDevice } from '../integrations/httpWatcher' @@ -22,7 +22,7 @@ export interface DeviceEntry { deviceClass: new (context: DeviceContextAPI) => Device canConnect: boolean deviceName: (deviceId: string, options: any) => string - executionMode: (options: any) => 'salvo' | 'sequential' + executionMode: (options: any) => ExecuteMode } export type ImplementedServiceDeviceTypes = @@ -50,102 +50,102 @@ export const DevicesDict: Record = { deviceClass: AbstractDevice, canConnect: false, deviceName: (deviceId: string) => 'Abstract ' + deviceId, - executionMode: () => 'salvo', + executionMode: () => ExecuteMode.SALVO, }, [DeviceType.ATEM]: { deviceClass: AtemDevice, canConnect: true, deviceName: (deviceId: string) => 'Atem ' + deviceId, - executionMode: () => 'salvo', + executionMode: () => ExecuteMode.SALVO, }, [DeviceType.HTTPSEND]: { deviceClass: AuthenticatedHTTPSendDevice, canConnect: false, deviceName: (deviceId: string) => 'HTTPSend ' + deviceId, - executionMode: () => 'sequential', // todo - config? + executionMode: () => ExecuteMode.SEQUENTIAL, // todo - config? }, [DeviceType.HTTPWATCHER]: { deviceClass: HTTPWatcherDevice, canConnect: false, deviceName: (deviceId: string) => 'HTTP-Watch ' + deviceId, - executionMode: () => 'sequential', + executionMode: () => ExecuteMode.SEQUENTIAL, }, [DeviceType.HYPERDECK]: { deviceClass: HyperdeckDevice, canConnect: true, deviceName: (deviceId: string) => 'Hyperdeck ' + deviceId, - executionMode: () => 'salvo', + executionMode: () => ExecuteMode.SALVO, }, [DeviceType.LAWO]: { deviceClass: LawoDevice, canConnect: true, deviceName: (deviceId: string) => 'Lawo ' + deviceId, - executionMode: () => 'salvo', + executionMode: () => ExecuteMode.SALVO, }, [DeviceType.OBS]: { deviceClass: OBSDevice, canConnect: true, deviceName: (deviceId: string) => 'OBS ' + deviceId, - executionMode: () => 'salvo', + executionMode: () => ExecuteMode.SALVO, }, [DeviceType.OSC]: { deviceClass: OscDevice, canConnect: true, deviceName: (deviceId: string) => 'OSC ' + deviceId, - executionMode: () => 'salvo', + executionMode: () => ExecuteMode.SALVO, }, [DeviceType.PANASONIC_PTZ]: { deviceClass: PanasonicPtzDevice, canConnect: true, deviceName: (deviceId: string) => 'Panasonic PTZ ' + deviceId, - executionMode: () => 'salvo', + executionMode: () => ExecuteMode.SALVO, }, [DeviceType.PHAROS]: { deviceClass: PharosDevice, canConnect: true, deviceName: (deviceId: string) => 'Pharos ' + deviceId, - executionMode: () => 'salvo', + executionMode: () => ExecuteMode.SALVO, }, [DeviceType.SOFIE_CHEF]: { deviceClass: SofieChefDevice, canConnect: true, deviceName: (deviceId: string) => 'SofieChef ' + deviceId, - executionMode: () => 'salvo', + executionMode: () => ExecuteMode.SALVO, }, [DeviceType.SHOTOKU]: { deviceClass: ShotokuDevice, canConnect: true, deviceName: (deviceId: string) => 'SHOTOKU' + deviceId, - executionMode: () => 'salvo', + executionMode: () => ExecuteMode.SALVO, }, [DeviceType.SINGULAR_LIVE]: { deviceClass: SingularLiveDevice, canConnect: false, deviceName: (deviceId: string) => 'Singular.Live ' + deviceId, - executionMode: () => 'sequential', + executionMode: () => ExecuteMode.SEQUENTIAL, }, [DeviceType.TCPSEND]: { deviceClass: TcpSendDevice, canConnect: true, deviceName: (deviceId: string) => 'TCP' + deviceId, - executionMode: () => 'sequential', // todo: should this be configurable? + executionMode: () => ExecuteMode.SEQUENTIAL, // todo: should this be configurable? }, [DeviceType.TELEMETRICS]: { deviceClass: TelemetricsDevice, canConnect: true, deviceName: (deviceId: string) => 'Telemetrics ' + deviceId, - executionMode: () => 'salvo', + executionMode: () => ExecuteMode.SALVO, }, [DeviceType.TRICASTER]: { deviceClass: TriCasterDevice, canConnect: true, deviceName: (deviceId: string) => 'TriCaster ' + deviceId, - executionMode: () => 'salvo', + executionMode: () => ExecuteMode.SALVO, }, [DeviceType.QUANTEL]: { deviceClass: QuantelDevice, canConnect: true, deviceName: (deviceId: string) => 'Quantel' + deviceId, - executionMode: () => 'sequential', + executionMode: () => ExecuteMode.SEQUENTIAL, }, } diff --git a/packages/timeline-state-resolver/src/service/stateHandler.ts b/packages/timeline-state-resolver/src/service/stateHandler.ts index 25f99e70c..c2973be48 100644 --- a/packages/timeline-state-resolver/src/service/stateHandler.ts +++ b/packages/timeline-state-resolver/src/service/stateHandler.ts @@ -1,6 +1,6 @@ import { FinishedTrace, startTrace, endTrace } from '../lib' import { Mappings, Timeline, TSRTimelineContent } from 'timeline-state-resolver-types' -import { BaseDeviceAPI, CommandWithContext } from './device' +import { BaseDeviceAPI, CommandWithContext, ExecuteMode } from './device' import { Measurement, StateChangeReport } from './measure' import { CommandExecutor } from './commandExecutor' @@ -188,7 +188,7 @@ export class StateHandler { } export interface StateHandlerConfig { - executionType: 'salvo' | 'sequential' + executionType: ExecuteMode } export interface StateHandlerContext { From a7dc8e4d69872d9f29b529cab74c79a4065fdb85 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Thu, 3 Oct 2024 10:38:44 +0200 Subject: [PATCH 11/12] fix: CommandExecutor: allow for commands to specify their ExecutionMode individually --- .../service/__tests__/commandExecutor.spec.ts | 129 +++++++++++++++++- .../src/service/commandExecutor.ts | 43 +++++- .../src/service/device.ts | 4 +- 3 files changed, 168 insertions(+), 8 deletions(-) diff --git a/packages/timeline-state-resolver/src/service/__tests__/commandExecutor.spec.ts b/packages/timeline-state-resolver/src/service/__tests__/commandExecutor.spec.ts index 6e3a10c3f..35175451a 100644 --- a/packages/timeline-state-resolver/src/service/__tests__/commandExecutor.spec.ts +++ b/packages/timeline-state-resolver/src/service/__tests__/commandExecutor.spec.ts @@ -46,10 +46,135 @@ describe('commandExecutor', () => { expect(sendCommand).toHaveBeenNthCalledWith(5, makeCommand('y'), 4) expect(sendCommand).toHaveBeenNthCalledWith(6, makeCommand('z'), 5) }) + test('Salvo order', async () => { + // CommandExecutor + + const logger = { + debug: jest.fn((...args: any[]) => console.log('debug', ...args)), + info: jest.fn((info: string) => console.log('info', info)), + warn: jest.fn((warn: string) => console.log('warning', warn)), + error: jest.fn((context: string, e: Error) => console.log('error', context, e)), + } + const sendCommandStart = jest.fn() + const sendCommandEnd = jest.fn() + + let sequence = 1 + + const executor = new CommandExecutor(logger, ExecuteMode.SALVO, async (c) => { + sendCommandStart(c, sequence++) + await sleep(10) + sendCommandEnd(c, sequence++) + }) + + await Promise.all([ + executor.executeCommands([makeCommand('a'), makeCommand('b'), makeCommand('c')]), + executor.executeCommands([makeCommand('d'), makeCommand('e'), makeCommand('f')]), + ]) + + expect(sendCommandStart).toHaveBeenCalledTimes(6) + expect(sendCommandEnd).toHaveBeenCalledTimes(6) + + // The Salvos in batch 1 are executed in parallel: + expect(sendCommandStart).toHaveBeenCalledWith(makeCommand('a'), 1) + expect(sendCommandStart).toHaveBeenCalledWith(makeCommand('b'), 2) + expect(sendCommandStart).toHaveBeenCalledWith(makeCommand('c'), 3) + + expect(sendCommandEnd).toHaveBeenCalledWith(makeCommand('a'), 4) + expect(sendCommandEnd).toHaveBeenCalledWith(makeCommand('b'), 5) + expect(sendCommandEnd).toHaveBeenCalledWith(makeCommand('c'), 6) + + // The Salvos in batch 2 are executed in parallel: + expect(sendCommandStart).toHaveBeenCalledWith(makeCommand('d'), 7) + expect(sendCommandStart).toHaveBeenCalledWith(makeCommand('e'), 8) + expect(sendCommandStart).toHaveBeenCalledWith(makeCommand('f'), 9) + + expect(sendCommandEnd).toHaveBeenCalledWith(makeCommand('d'), 10) + expect(sendCommandEnd).toHaveBeenCalledWith(makeCommand('e'), 11) + expect(sendCommandEnd).toHaveBeenCalledWith(makeCommand('f'), 12) + }) + test('Mixed order', async () => { + // CommandExecutor + + const logger = { + debug: jest.fn((...args: any[]) => console.log('debug', ...args)), + info: jest.fn((info: string) => console.log('info', info)), + warn: jest.fn((warn: string) => console.log('warning', warn)), + error: jest.fn((context: string, e: Error) => console.log('error', context, e)), + } + const sendCommandStart = jest.fn() + const sendCommandEnd = jest.fn() + + let sequence = 1 + + const executor = new CommandExecutor(logger, ExecuteMode.SEQUENTIAL, async (c) => { + sendCommandStart(c, sequence++) + await sleep(10) + sendCommandEnd(c, sequence++) + }) + + await Promise.all([ + executor.executeCommands([ + makeCommand('1_a1', ExecuteMode.SALVO), + makeCommand('1_b1', ExecuteMode.SEQUENTIAL), + makeCommand('1_a2', ExecuteMode.SALVO), + makeCommand('1_b2', ExecuteMode.SEQUENTIAL), + makeCommand('1_a3', ExecuteMode.SALVO), + makeCommand('1_b3', ExecuteMode.SEQUENTIAL), + ]), + + executor.executeCommands([ + makeCommand('2_a1', ExecuteMode.SALVO), + makeCommand('2_b1', ExecuteMode.SEQUENTIAL), + makeCommand('2_a2', ExecuteMode.SALVO), + makeCommand('2_b2', ExecuteMode.SEQUENTIAL), + makeCommand('2_a3', ExecuteMode.SALVO), + makeCommand('2_b3', ExecuteMode.SEQUENTIAL), + ]), + ]) + + expect(sendCommandStart).toHaveBeenCalledTimes(12) + expect(sendCommandEnd).toHaveBeenCalledTimes(12) + + // The Salvos in batch 1 are executed in parallel: + expect(sendCommandStart).toHaveBeenCalledWith(makeCommand('1_a1', ExecuteMode.SALVO), 1) + expect(sendCommandStart).toHaveBeenCalledWith(makeCommand('1_a2', ExecuteMode.SALVO), 2) + expect(sendCommandStart).toHaveBeenCalledWith(makeCommand('1_a3', ExecuteMode.SALVO), 3) + expect(sendCommandEnd).toHaveBeenCalledWith(makeCommand('1_a1', ExecuteMode.SALVO), 4) + expect(sendCommandEnd).toHaveBeenCalledWith(makeCommand('1_a2', ExecuteMode.SALVO), 5) + expect(sendCommandEnd).toHaveBeenCalledWith(makeCommand('1_a3', ExecuteMode.SALVO), 6) + + // The Sequentials in batch 1 are executed in order, but interleaved with the Salvos in batch 2: + expect(sendCommandStart).toHaveBeenCalledWith(makeCommand('1_b1', ExecuteMode.SEQUENTIAL), 7) + expect(sendCommandStart).toHaveBeenCalledWith(makeCommand('2_a1', ExecuteMode.SALVO), 8) + expect(sendCommandStart).toHaveBeenCalledWith(makeCommand('2_a2', ExecuteMode.SALVO), 9) + expect(sendCommandStart).toHaveBeenCalledWith(makeCommand('2_a3', ExecuteMode.SALVO), 10) + + expect(sendCommandEnd).toHaveBeenCalledWith(makeCommand('1_b1', ExecuteMode.SEQUENTIAL), 11) + expect(sendCommandStart).toHaveBeenCalledWith(makeCommand('1_b2', ExecuteMode.SEQUENTIAL), 12) + + expect(sendCommandEnd).toHaveBeenCalledWith(makeCommand('2_a1', ExecuteMode.SALVO), 13) + expect(sendCommandEnd).toHaveBeenCalledWith(makeCommand('2_a2', ExecuteMode.SALVO), 14) + expect(sendCommandEnd).toHaveBeenCalledWith(makeCommand('2_a3', ExecuteMode.SALVO), 15) + + expect(sendCommandEnd).toHaveBeenCalledWith(makeCommand('1_b2', ExecuteMode.SEQUENTIAL), 16) + expect(sendCommandStart).toHaveBeenCalledWith(makeCommand('1_b3', ExecuteMode.SEQUENTIAL), 17) + expect(sendCommandEnd).toHaveBeenCalledWith(makeCommand('1_b3', ExecuteMode.SEQUENTIAL), 18) + + // The Sequentials in batch 2 are executed in order: + expect(sendCommandStart).toHaveBeenCalledWith(makeCommand('2_b1', ExecuteMode.SEQUENTIAL), 19) + expect(sendCommandEnd).toHaveBeenCalledWith(makeCommand('2_b1', ExecuteMode.SEQUENTIAL), 20) + expect(sendCommandStart).toHaveBeenCalledWith(makeCommand('2_b2', ExecuteMode.SEQUENTIAL), 21) + expect(sendCommandEnd).toHaveBeenCalledWith(makeCommand('2_b2', ExecuteMode.SEQUENTIAL), 22) + expect(sendCommandStart).toHaveBeenCalledWith(makeCommand('2_b3', ExecuteMode.SEQUENTIAL), 23) + expect(sendCommandEnd).toHaveBeenCalledWith(makeCommand('2_b3', ExecuteMode.SEQUENTIAL), 24) + + sendCommandStart.mockClear() + sequence = 0 + }) }) -function makeCommand(cmd: string) { - return { command: cmd, context: undefined, timelineObjId: '' } +function makeCommand(cmd: string, executeMode?: ExecuteMode) { + return { command: cmd, context: undefined, timelineObjId: '', executeMode } } async function sleep(time: number) { return new Promise((resolve) => { diff --git a/packages/timeline-state-resolver/src/service/commandExecutor.ts b/packages/timeline-state-resolver/src/service/commandExecutor.ts index c16bf5533..5d7198070 100644 --- a/packages/timeline-state-resolver/src/service/commandExecutor.ts +++ b/packages/timeline-state-resolver/src/service/commandExecutor.ts @@ -7,7 +7,9 @@ import PQueue from 'p-queue' const wait = async (t: number) => new Promise((r) => setTimeout(() => r(), t)) export class CommandExecutor { - private commandQueue = new PQueue({ concurrency: 1 }) + private commandQueueSalvo = new PQueue({ concurrency: 1 }) + private commandQueueSequential = new PQueue({ concurrency: 1 }) + constructor( private logger: StateHandlerContext['logger'], private defaultMode: ExecuteMode, @@ -20,13 +22,44 @@ export class CommandExecutor { commands.sort((a, b) => (b.preliminary ?? 0) - (a.preliminary ?? 0)) const totalTime = commands[0].preliminary ?? 0 - await this.commandQueue.add(async () => { - if (this.mode === 'salvo') { - return this._executeCommandsSalvo(totalTime, commands, measurement) + const salvoCommands: Command[] = [] + const sequentialCommands: Command[] = [] + + for (const command of commands) { + const mode = command.executeMode || this.defaultMode + if (mode === ExecuteMode.SEQUENTIAL) { + sequentialCommands.push(command) } else { - return this._executeCommandsSequential(totalTime, commands, measurement) + salvoCommands.push(command) } + } + + // The execution logic is as follows: + // * When the commands sent into executeCommands() is called a BATCH. + // + // * Salvos should wait for Salvos from previous BATCH to have finished before executing + // * Salvos within the same BATCH are executed in parallel. + // + // * Sequentials should wait for all Salvos and Sequentials from previous BATCH to have finished before executing + // * Sequentials should be executed after all Salvos from the same BATCH have been executed + // * Sequentials within the same BATCH are executed in order + + let pSequential: Promise | null = null + + // Wait for Salvos from previous batch to finish: + await this.commandQueueSalvo.add(async () => { + await this._executeCommandsSalvo(totalTime, salvoCommands, measurement) + + // Salvos should not have to wait for Sequentials to finish, so we don't await the pSequential here: + // eslint-disable-next-line @typescript-eslint/await-thenable + pSequential = this.commandQueueSequential.add(async () => { + await this._executeCommandsSequential(totalTime, sequentialCommands, measurement) + }) }) + + if (!pSequential) throw new Error('Internal error in CommandExecutor: pSequential not set') + // eslint-disable-next-line @typescript-eslint/await-thenable + await pSequential } private async _executeCommandsSalvo( diff --git a/packages/timeline-state-resolver/src/service/device.ts b/packages/timeline-state-resolver/src/service/device.ts index 5d2ac791b..02998f3ec 100644 --- a/packages/timeline-state-resolver/src/service/device.ts +++ b/packages/timeline-state-resolver/src/service/device.ts @@ -18,8 +18,10 @@ export type CommandWithContext = { timelineObjId: string /** this command is to be executed x ms _before_ the scheduled time */ preliminary?: number - /** commands with different queueId's can be executed in parallel in sequential mode */ + /** sequential mode only: commands with different queueId's can be executed in parallel */ queueId?: string + /** If the command should be executed in sequential order or as a salvo. If not set, falls back to device default */ + executeMode?: ExecuteMode } export enum ExecuteMode { From ff443bb9260e05288c0c7c96950233ca79d8a679 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Thu, 3 Oct 2024 14:23:50 +0200 Subject: [PATCH 12/12] fix: Quantel: set ExecutionMode.SALVO for CANCELWAITING commands --- .../integrations/quantel/__tests__/quantel.spec.ts | 13 +++++++++++++ .../src/integrations/quantel/diff.ts | 3 +++ .../timeline-state-resolver/src/service/devices.ts | 2 +- 3 files changed, 17 insertions(+), 1 deletion(-) 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 9da73c506..b8960fdd9 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 @@ -21,6 +21,7 @@ import { CommandWithContext } from '../../..' import { getResolvedState, resolveTimeline } from 'superfly-timeline' import { DevicesDict } from '../../../service/devices' import { setSoftJumpWaitTime } from '../connection' +import { ExecuteMode } from '../../../service/device' async function getInitialisedQuantelDevice(clearMock?: jest.Mock) { const dev = new QuantelDevice(getDeviceContext()) @@ -429,6 +430,7 @@ describe('Quantel Device', () => { }, context: 'Clear all delayed out-transitions', timelineObjId: '', + executeMode: ExecuteMode.SALVO, }, { command: { @@ -496,6 +498,7 @@ describe('Quantel Device', () => { }, context: 'Clear all delayed out-transitions', timelineObjId: '', + executeMode: ExecuteMode.SALVO, }, { command: { @@ -577,6 +580,7 @@ describe('Quantel Device', () => { }, context: 'Clear all delayed out-transitions', timelineObjId: '', + executeMode: ExecuteMode.SALVO, }, { command: { @@ -663,6 +667,7 @@ describe('Quantel Device', () => { }, context: 'Clear all delayed out-transitions', timelineObjId: '', + executeMode: ExecuteMode.SALVO, }, { command: { @@ -749,6 +754,7 @@ describe('Quantel Device', () => { }, context: 'Clear all delayed out-transitions', timelineObjId: '', + executeMode: ExecuteMode.SALVO, }, { command: { @@ -819,6 +825,7 @@ describe('Quantel Device', () => { }, context: 'Clear all delayed out-transitions', timelineObjId: '', + executeMode: ExecuteMode.SALVO, }, { command: { @@ -880,6 +887,7 @@ describe('Quantel Device', () => { }, context: 'Clear all delayed out-transitions', timelineObjId: '', + executeMode: ExecuteMode.SALVO, }, { command: { @@ -968,6 +976,7 @@ describe('Quantel Device', () => { }, context: 'Clear all delayed out-transitions', timelineObjId: '', + executeMode: ExecuteMode.SALVO, }, { command: { @@ -1037,6 +1046,7 @@ describe('Quantel Device', () => { }, context: 'Clear all delayed out-transitions', timelineObjId: '', + executeMode: ExecuteMode.SALVO, }, { command: { @@ -1132,6 +1142,7 @@ describe('Quantel Device', () => { }, context: 'Clear all delayed out-transitions', timelineObjId: '', + executeMode: ExecuteMode.SALVO, }, { command: { @@ -1208,6 +1219,7 @@ describe('Quantel Device', () => { }, context: 'Clear all delayed out-transitions', timelineObjId: '', + executeMode: ExecuteMode.SALVO, }, { command: { @@ -1217,6 +1229,7 @@ describe('Quantel Device', () => { }, context: 'Clear all delayed out-transitions', timelineObjId: '', + executeMode: ExecuteMode.SALVO, }, { command: { diff --git a/packages/timeline-state-resolver/src/integrations/quantel/diff.ts b/packages/timeline-state-resolver/src/integrations/quantel/diff.ts index bf0643e12..641ec2c55 100644 --- a/packages/timeline-state-resolver/src/integrations/quantel/diff.ts +++ b/packages/timeline-state-resolver/src/integrations/quantel/diff.ts @@ -2,6 +2,7 @@ import { QuantelOutTransition } from 'timeline-state-resolver-types' import { QuantelCommandWithContext } from '.' import { QuantelCommand, QuantelCommandType, QuantelState, QuantelStatePort, QuantelStatePortClip } from './types' import _ = require('underscore') +import { ExecuteMode } from '../../service/device' const IDEAL_PREPARE_TIME = 1000 const PREPARE_TIME_WAIT = 50 @@ -129,6 +130,8 @@ export function diffStates( }, timelineObjId: '', context: 'Clear all delayed out-transitions', + // These must be SALVO, so that they are executed first thing, and not get stuck behind a delayed sequential command. + executeMode: ExecuteMode.SALVO, }) } diff --git a/packages/timeline-state-resolver/src/service/devices.ts b/packages/timeline-state-resolver/src/service/devices.ts index f52b0585c..e4a8f3259 100644 --- a/packages/timeline-state-resolver/src/service/devices.ts +++ b/packages/timeline-state-resolver/src/service/devices.ts @@ -100,7 +100,7 @@ export const DevicesDict: Record = { deviceClass: MultiOSCMessageDevice, canConnect: false, deviceName: (deviceId: string) => 'MultiOSC ' + deviceId, - executionMode: () => 'salvo', + executionMode: () => ExecuteMode.SALVO, }, [DeviceType.PANASONIC_PTZ]: { deviceClass: PanasonicPtzDevice,