From 572762c362b85296ba21bc651ef69e353934f088 Mon Sep 17 00:00:00 2001 From: Balte de Wit Date: Thu, 14 Sep 2023 17:05:47 +0200 Subject: [PATCH 1/2] chore: refactor casparcg device --- .../src/__mocks__/casparcg-connection.ts | 11 +- .../src/__tests__/conductor.spec.ts | 82 +- .../timeline-state-resolver/src/conductor.ts | 14 +- .../casparCG/__tests__/casparcg.spec.ts | 2203 ++--------------- .../src/integrations/casparCG/actions.ts | 79 + .../src/integrations/casparCG/diff.ts | 160 ++ .../src/integrations/casparCG/index.ts | 928 +------ .../src/integrations/casparCG/state.ts | 297 +++ .../src/service/device.ts | 2 +- .../src/service/devices.ts | 8 + 10 files changed, 912 insertions(+), 2872 deletions(-) create mode 100644 packages/timeline-state-resolver/src/integrations/casparCG/actions.ts create mode 100644 packages/timeline-state-resolver/src/integrations/casparCG/diff.ts create mode 100644 packages/timeline-state-resolver/src/integrations/casparCG/state.ts diff --git a/packages/timeline-state-resolver/src/__mocks__/casparcg-connection.ts b/packages/timeline-state-resolver/src/__mocks__/casparcg-connection.ts index df94a8d75..c56f4f046 100644 --- a/packages/timeline-state-resolver/src/__mocks__/casparcg-connection.ts +++ b/packages/timeline-state-resolver/src/__mocks__/casparcg-connection.ts @@ -3,7 +3,7 @@ import { Commands as orgCommands, AMCPCommand as orgAMCPCommand, SendResult } fr import { ResponseTypes } from 'casparcg-connection/dist/connection' import { EventEmitter } from 'events' -const mockDo = jest.fn() +export const mockDo = jest.fn() const instances: Array = [] @@ -28,8 +28,13 @@ export class BasicCasparCGAPI extends EventEmitter { instances.push(this) } + connect() { + this.connected = true + this.emit('connect') + } + async executeCommand(command: AMCPCommand): Promise { - mockDo.apply(this, command) + mockDo.apply(this, [command]) if (command.command === Commands.Info) { return Promise.resolve({ @@ -89,3 +94,5 @@ export class BasicCasparCGAPI extends EventEmitter { return instances } } + +export class CasparCG extends BasicCasparCGAPI {} diff --git a/packages/timeline-state-resolver/src/__tests__/conductor.spec.ts b/packages/timeline-state-resolver/src/__tests__/conductor.spec.ts index cc23db37c..19a558bba 100644 --- a/packages/timeline-state-resolver/src/__tests__/conductor.spec.ts +++ b/packages/timeline-state-resolver/src/__tests__/conductor.spec.ts @@ -27,6 +27,7 @@ jest.mock('../service/DeviceInstance', () => ({ import { Conductor, TimelineTriggerTimeResult } from '../conductor' import type { DeviceInstanceWrapper } from '../service/DeviceInstance' +import { mockDo } from '../__mocks__/casparcg-connection' describe('Conductor', () => { const mockTime = new MockTime() @@ -452,10 +453,6 @@ describe('Conductor', () => { }, 1500) test('Changing of mappings live', async () => { - const commandReceiver0: any = jest.fn(async () => { - return Promise.resolve() - }) - const myLayerMapping0: Mapping = { device: DeviceType.CASPARCG, deviceId: 'device0', @@ -480,7 +477,6 @@ describe('Conductor', () => { options: { host: '127.0.0.1', }, - commandReceiver: commandReceiver0, }) conductor.setTimelineAndMappings([], myLayerMapping) @@ -516,15 +512,20 @@ describe('Conductor', () => { await mockTime.advanceTimeToTicks(10500) - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(getMockCall(commandReceiver0, 0, 1).command).toEqual(Commands.Play) - expect(getMockCall(commandReceiver0, 0, 1).params).toMatchObject({ - clip: 'AMB', - channel: 1, - layer: 10, + expect(mockDo).toHaveBeenCalledTimes(1) + expect(mockDo).toHaveBeenNthCalledWith(1, { + command: Commands.Play, + params: { + channel: 1, + layer: 10, + clip: 'AMB', + }, + context: { + context: '', + layerId: '', + }, }) - - commandReceiver0.mockClear() + mockDo.mockClear() // modify the mapping: myLayerMapping0.options.layer = 20 @@ -532,21 +533,31 @@ describe('Conductor', () => { await mockTime.advanceTimeTicks(100) // just a little bit - expect(commandReceiver0).toHaveBeenCalledTimes(2) - expect(getMockCall(commandReceiver0, 0, 1).command).toEqual(Commands.Clear) - expect(getMockCall(commandReceiver0, 0, 1).params).toMatchObject({ - // clip: 'AMB', - channel: 1, - layer: 10, + expect(mockDo).toHaveBeenCalledTimes(2) + expect(mockDo).toHaveBeenNthCalledWith(1, { + command: Commands.Clear, + params: { + channel: 1, + layer: 10, + }, + context: { + context: '', + layerId: '', + }, }) - expect(getMockCall(commandReceiver0, 1, 1).command).toEqual(Commands.Play) - expect(getMockCall(commandReceiver0, 1, 1).params).toMatchObject({ - clip: 'AMB', - channel: 1, - layer: 20, + expect(mockDo).toHaveBeenNthCalledWith(2, { + command: Commands.Play, + params: { + channel: 1, + layer: 20, + clip: 'AMB', + }, + context: { + context: '', + layerId: '', + }, }) - - commandReceiver0.mockClear() + mockDo.mockClear() // Replace the mapping altogether: delete myLayerMapping['myLayer0'] @@ -580,23 +591,32 @@ describe('Conductor', () => { // }, // }) // } else { - expect(commandReceiver0).toHaveBeenCalledTimes(2) - expect(getMockCall(commandReceiver0, 0, 1)).toMatchObject({ + + expect(mockDo).toHaveBeenCalledTimes(2) + expect(mockDo).toHaveBeenNthCalledWith(1, { command: Commands.Clear, params: { channel: 1, layer: 20, }, + context: { + context: '', + layerId: '', + }, }) - - expect(getMockCall(commandReceiver0, 1, 1)).toMatchObject({ + expect(mockDo).toHaveBeenNthCalledWith(1, { command: Commands.Play, params: { - clip: 'AMB', channel: 2, layer: 10, + clip: 'AMB', + }, + context: { + context: '', + layerId: '', }, }) + mockDo.mockClear() // } }) diff --git a/packages/timeline-state-resolver/src/conductor.ts b/packages/timeline-state-resolver/src/conductor.ts index b8bc048f7..d457c466c 100644 --- a/packages/timeline-state-resolver/src/conductor.ts +++ b/packages/timeline-state-resolver/src/conductor.ts @@ -27,6 +27,7 @@ import { DeviceOptionsHTTPWatcher, DeviceOptionsAbstract, DeviceOptionsTCPSend, + DeviceOptionsCasparCG, } from 'timeline-state-resolver-types' import { DoOnTime } from './devices/doOnTime' @@ -36,7 +37,6 @@ import { assertNever, endTrace, fillStateFromDatastore, FinishedTrace, startTrac import { CommandWithContext } from './devices/device' import { DeviceContainer } from './devices/deviceContainer' -import { CasparCGDevice, DeviceOptionsCasparCGInternal } from './integrations/casparCG' import { AtemDevice, DeviceOptionsAtemInternal } from './integrations/atem' import { LawoDevice, DeviceOptionsLawoInternal } from './integrations/lawo' import { PanasonicPtzDevice, DeviceOptionsPanasonicPTZInternal } from './integrations/panasonicPTZ' @@ -501,15 +501,6 @@ export class Conductor extends EventEmitter { threadedClassOptions: ThreadedClassConfig ): Promise>> | null { switch (deviceOptions.type) { - case DeviceType.CASPARCG: - return DeviceContainer.create( - '../../dist/integrations/casparCG/index.js', - 'CasparCGDevice', - deviceId, - deviceOptions, - getCurrentTime, - threadedClassOptions - ) case DeviceType.ATEM: return DeviceContainer.create( '../../dist/integrations/atem/index.js', @@ -646,6 +637,7 @@ export class Conductor extends EventEmitter { threadedClassOptions ) case DeviceType.ABSTRACT: + case DeviceType.CASPARCG: case DeviceType.HTTPSEND: case DeviceType.HTTPWATCHER: case DeviceType.OSC: @@ -1553,7 +1545,7 @@ export class Conductor extends EventEmitter { } export type DeviceOptionsAnyInternal = | DeviceOptionsAbstract - | DeviceOptionsCasparCGInternal + | DeviceOptionsCasparCG | DeviceOptionsAtemInternal | DeviceOptionsLawoInternal | DeviceOptionsHTTPSend diff --git a/packages/timeline-state-resolver/src/integrations/casparCG/__tests__/casparcg.spec.ts b/packages/timeline-state-resolver/src/integrations/casparCG/__tests__/casparcg.spec.ts index edb05af02..80175f53b 100644 --- a/packages/timeline-state-resolver/src/integrations/casparCG/__tests__/casparcg.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/casparCG/__tests__/casparcg.spec.ts @@ -1,2046 +1,285 @@ -import { Conductor } from '../../../conductor' +/* eslint-disable jest/expect-expect */ import { - TimelineContentTypeCasparCg, - SomeMappingCasparCG, - Mappings, DeviceType, - ChannelFormat, - Transition, - Ease, - Direction, - TSRTimeline, - Mapping, - MappingCasparCGType, + Mappings, + Timeline, + TimelineContentCasparCGAny, + TimelineContentTypeCasparCg, + TSRTimelineContent, } from 'timeline-state-resolver-types' -import { MockTime } from '../../../__tests__/mockTime' -import { getMockCall } from '../../../__tests__/lib' -import { Commands } from 'casparcg-connection' - -// usage logCalls(commandReceiver0) -// function logCalls (fcn) { -// console.log('calls') -// fcn.mock.calls.forEach((call) => { -// console.log(call[0], call[1]) -// }) -// } - -describe('CasparCG', () => { - const mockTime = new MockTime() - beforeEach(() => { - mockTime.init() - }) - test('CasparCG: Play AMB for 60s', async () => { - const commandReceiver0: any = jest.fn(async () => { - return Promise.resolve() - }) - const myLayerMapping0: Mapping = { - device: DeviceType.CASPARCG, - deviceId: 'myCCG', - options: { - mappingType: MappingCasparCGType.Layer, - channel: 2, - layer: 42, - }, - } - const myLayerMapping: Mappings = { - myLayer0: myLayerMapping0, - } - - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) - await myConductor.init() // we cannot do an await, because setTimeout will never call without jest moving on. - await myConductor.addDevice('myCCG', { - type: DeviceType.CASPARCG, - options: { - host: '127.0.0.1', - }, - commandReceiver: commandReceiver0, - skipVirginCheck: true, - }) - await mockTime.advanceTimeToTicks(10100) - - expect(commandReceiver0).toHaveBeenCalledTimes(0) - - commandReceiver0.mockClear() - - myConductor.setTimelineAndMappings( - [ - { - id: 'obj0', - enable: { - start: mockTime.getCurrentTime() - 1000, // 1 seconds ago - duration: 2000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.MEDIA, - - file: 'AMB', - loop: true, - }, - }, - ], - myLayerMapping - ) - - await mockTime.advanceTimeToTicks(10200) - - // one command has been sent: - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(getMockCall(commandReceiver0, 0, 1).params).toMatchObject({ - channel: 2, - layer: 42, - clip: 'AMB', - loop: true, - seek: 0, // looping and seeking nos supported when length not provided - }) - - // advance time to end of clip: - await mockTime.advanceTimeToTicks(11200) - - // two commands have been sent: - expect(commandReceiver0).toHaveBeenCalledTimes(2) - expect(getMockCall(commandReceiver0, 1, 1).command).toEqual(Commands.Clear) - expect(getMockCall(commandReceiver0, 1, 1).params.channel).toEqual(2) - expect(getMockCall(commandReceiver0, 1, 1).params.layer).toEqual(42) - }) - test('CasparCG: Play AMB for 60s, start at 10s', async () => { - const commandReceiver0: any = jest.fn(async () => { - return Promise.resolve() - }) - const myLayerMapping0: Mapping = { - device: DeviceType.CASPARCG, - deviceId: 'myCCG', - options: { - mappingType: MappingCasparCGType.Layer, - channel: 2, - layer: 42, - }, - } - const myLayerMapping: Mappings = { - myLayer0: myLayerMapping0, - } - - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) - await myConductor.init() // we cannot do an await, because setTimeout will never call without jest moving on. - await myConductor.addDevice('myCCG', { - type: DeviceType.CASPARCG, - options: { - host: '127.0.0.1', - }, - commandReceiver: commandReceiver0, - skipVirginCheck: true, - }) - await mockTime.advanceTimeToTicks(10100) - - expect(commandReceiver0).toHaveBeenCalledTimes(0) - - commandReceiver0.mockClear() - - myConductor.setTimelineAndMappings( - [ - { - id: 'obj0', - enable: { - start: mockTime.getCurrentTime() - 10000, // 10 seconds ago - duration: 60000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.MEDIA, - - file: 'AMB', - }, - }, - ], - myLayerMapping - ) - - await mockTime.advanceTimeToTicks(10200) - - // one command has been sent: - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(getMockCall(commandReceiver0, 0, 1).params).toMatchObject({ - channel: 2, - layer: 42, - clip: 'AMB', - seek: 25 * 10, - }) - }) - test('CasparCG: Play AMB for 60s in 50fps', async () => { - const commandReceiver0: any = jest.fn(async () => { - return Promise.resolve() - }) - const myLayerMapping0: Mapping = { - device: DeviceType.CASPARCG, - deviceId: 'myCCG', - options: { - mappingType: MappingCasparCGType.Layer, - channel: 2, - layer: 42, - }, - } - const myLayerMapping: Mappings = { - myLayer0: myLayerMapping0, - } - - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) - await myConductor.init() // we cannot do an await, because setTimeout will never call without jest moving on. - await myConductor.addDevice('myCCG', { - type: DeviceType.CASPARCG, - options: { - host: '127.0.0.1', - fps: 50, - }, - commandReceiver: commandReceiver0, - skipVirginCheck: true, - }) - await mockTime.advanceTimeToTicks(10100) - - commandReceiver0.mockClear() - - myConductor.setTimelineAndMappings( - [ - { - id: 'obj0', - enable: { - start: mockTime.getCurrentTime() - 1000, // 1 seconds ago - duration: 2000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.MEDIA, - - file: 'AMB', - loop: true, - inPoint: 0, - length: 60 * 1000, - }, - }, - ], - myLayerMapping - ) - - await mockTime.advanceTimeToTicks(10200) - - // one command has been sent: - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(getMockCall(commandReceiver0, 0, 1).params).toMatchObject({ - channel: 2, - layer: 42, +import { CasparCGDevice } from '..' - clip: 'AMB', - loop: true, - seek: 50, - length: 60 * 50, - }) - - // advance time to end of clip: - await mockTime.advanceTimeToTicks(11200) - - // two commands have been sent: - expect(commandReceiver0).toHaveBeenCalledTimes(2) - expect(getMockCall(commandReceiver0, 1, 1).command).toBe(Commands.Clear) - expect(getMockCall(commandReceiver0, 1, 1).params.channel).toEqual(2) - expect(getMockCall(commandReceiver0, 1, 1).params.layer).toEqual(42) - }) - - test('CasparCG: Play IP input for 60s', async () => { - const commandReceiver0: any = jest.fn(async () => { - return Promise.resolve() - }) - const myLayerMapping0: Mapping = { - device: DeviceType.CASPARCG, - deviceId: 'myCCG', - options: { - mappingType: MappingCasparCGType.Layer, - channel: 2, - layer: 42, - }, - } - const myLayerMapping: Mappings = { - myLayer0: myLayerMapping0, - } - - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) - await myConductor.init() - await myConductor.addDevice('myCCG', { - type: DeviceType.CASPARCG, - options: { - host: '127.0.0.1', - }, - commandReceiver: commandReceiver0, - skipVirginCheck: true, - }) - - myConductor.setTimelineAndMappings( - [ - { - id: 'obj0', - enable: { - start: mockTime.getCurrentTime() - 1000, // 1 seconds ago - duration: 2000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.IP, - - uri: 'rtsp://127.0.0.1:5004', - }, - }, - ], - myLayerMapping - ) - await mockTime.advanceTimeTicks(100) - - // one command has been sent: - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(getMockCall(commandReceiver0, 0, 1).command).toBe(Commands.Play) - expect(getMockCall(commandReceiver0, 0, 1).params).toMatchObject({ - channel: 2, - layer: 42, - - clip: 'rtsp://127.0.0.1:5004', - seek: 0, // can't seek in an ip input - }) - - // advance time to end of clip: - await mockTime.advanceTimeTicks(2000) - - // two commands have been sent: - expect(commandReceiver0).toHaveBeenCalledTimes(2) - expect(getMockCall(commandReceiver0, 1, 1).command).toEqual(Commands.Clear) - expect(getMockCall(commandReceiver0, 1, 1).params.channel).toEqual(2) - expect(getMockCall(commandReceiver0, 1, 1).params.layer).toEqual(42) - }) - - test('CasparCG: Play decklink input for 60s', async () => { - const commandReceiver0: any = jest.fn(async () => { - return Promise.resolve() - }) - const myLayerMapping0: Mapping = { - device: DeviceType.CASPARCG, - deviceId: 'myCCG', - options: { - mappingType: MappingCasparCGType.Layer, - channel: 2, - layer: 42, - }, - } - const myLayerMapping: Mappings = { - myLayer0: myLayerMapping0, - } - - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) - await myConductor.init() - await myConductor.addDevice('myCCG', { - type: DeviceType.CASPARCG, - options: { - host: '127.0.0.1', - }, - commandReceiver: commandReceiver0, - skipVirginCheck: true, - }) - - // await mockTime.advanceTimeToTicks(10050) - // expect(commandReceiver0).toHaveBeenCalledTimes(3) - - // await mockTime.advanceTimeToTicks(10010) - - await mockTime.advanceTimeToTicks(10050) - expect(commandReceiver0).toHaveBeenCalledTimes(0) - - myConductor.setTimelineAndMappings( - [ - { - id: 'obj0', - enable: { - start: 9000, - duration: 2000, // 11000 - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.INPUT, - - device: 1, - inputType: 'decklink', - deviceFormat: ChannelFormat.HD_720P5000, +import { mockDo } from '../../../__mocks__/casparcg-connection' +import { CasparCGDeviceState } from '../state' +import { CasparCGCommand } from '../diff' +import { Commands } from 'casparcg-connection' +import { Layer, LayerContentType } from 'casparcg-state' + +async function getInitialisedOscDevice() { + const dev = new CasparCGDevice() + await dev.init({ host: 'localhost', port: 8082 }) + return dev +} + +describe('CasparCG Device', () => { + describe('convertTimelineStateToDeviceState', () => { + async function compareState(tlState: Timeline.TimelineState, expDevState: CasparCGDeviceState) { + const mappings: Mappings = { + layer0: { + device: DeviceType.CASPARCG, + deviceId: 'caspar0', + options: { + channel: 1, + layer: 10, }, }, - ], - myLayerMapping - ) - await mockTime.advanceTimeToTicks(10100) - - // one command has been sent: - expect(commandReceiver0).toHaveBeenCalledTimes(1) + } + const device = await getInitialisedOscDevice() - expect(getMockCall(commandReceiver0, 0, 1).command).toBe(Commands.PlayDecklink) - expect(getMockCall(commandReceiver0, 0, 1).params).toMatchObject({ - channel: 2, - layer: 42, - device: 1, - format: ChannelFormat.HD_720P5000, - }) - - await mockTime.advanceTimeToTicks(12000) - expect(commandReceiver0).toHaveBeenCalledTimes(2) - - expect(getMockCall(commandReceiver0, 1, 1).command).toBe(Commands.Clear) - expect(getMockCall(commandReceiver0, 1, 1).params).toMatchObject({ - channel: 2, - layer: 42, - }) - - // advance time to end of clip: - // await mockTime.advanceTimeToTicks(11200) - - // two commands have been sent: - // expect(commandReceiver0).toHaveBeenCalledTimes(5) - }) + const actualState = device.convertTimelineStateToDeviceState(tlState, mappings) - test('CasparCG: Play template for 60s', async () => { - const commandReceiver0: any = jest.fn(async () => { - return Promise.resolve() - }) - const myLayerMapping0: Mapping = { - device: DeviceType.CASPARCG, - deviceId: 'myCCG', - options: { - mappingType: MappingCasparCGType.Layer, - channel: 2, - layer: 42, - }, - } - const myLayerMapping: Mappings = { - myLayer0: myLayerMapping0, + expect(actualState).toEqual(expDevState) } - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, + test('convert empty state', async () => { + await compareState(createTimelineState({}), {}) }) - await myConductor.init() - await myConductor.addDevice('myCCG', { - type: DeviceType.CASPARCG, - options: { - host: '127.0.0.1', - }, - commandReceiver: commandReceiver0, - skipVirginCheck: true, - }) - - await mockTime.advanceTimeToTicks(10050) - expect(commandReceiver0).toHaveBeenCalledTimes(0) - - myConductor.setTimelineAndMappings( - [ - { - id: 'obj0', - enable: { - start: 9000, - duration: 2000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.TEMPLATE, - name: 'LT', - data: { - f0: 'Hello', - f1: 'World', + test('convert amb', async () => { + await compareState( + createTimelineState({ + layer0: { + id: 'obj0', + layer: 'layer0', + content: { + deviceType: DeviceType.CASPARCG, + type: TimelineContentTypeCasparCg.MEDIA, + file: 'amb', + }, + instance: { + originalStart: 10, }, - useStopCommand: true, - }, - }, - ], - myLayerMapping - ) - - await mockTime.advanceTimeToTicks(10100) - - // one command has been sent: - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(getMockCall(commandReceiver0, 0, 1).command).toBe(Commands.CgAdd) - expect(getMockCall(commandReceiver0, 0, 1).params).toMatchObject({ - channel: 2, - layer: 42, - - template: 'LT', - cgLayer: 1, - playOnLoad: true, - data: { f0: 'Hello', f1: 'World' }, - }) - - await mockTime.advanceTimeToTicks(12100) - - expect(getMockCall(commandReceiver0, 1, 1).command).toBe(Commands.CgStop) - }) - - test('CasparCG: Schedule recording', async () => { - const commandReceiver0: any = jest.fn(async () => { - return Promise.resolve() - }) - const myLayerMapping0: Mapping = { - device: DeviceType.CASPARCG, - deviceId: 'myCCG', - options: { - mappingType: MappingCasparCGType.Layer, - channel: 2, - layer: 42, - }, - } - const myLayerMapping: Mappings = { - myLayer0: myLayerMapping0, - } - - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) - await myConductor.init() - await myConductor.addDevice('myCCG', { - type: DeviceType.CASPARCG, - options: { - host: '127.0.0.1', - }, - commandReceiver: commandReceiver0, - skipVirginCheck: true, - }) - - await mockTime.advanceTimeToTicks(10050) - expect(commandReceiver0).toHaveBeenCalledTimes(0) - - myConductor.setTimelineAndMappings( - [ - { - id: 'obj0', - enable: { - start: 9000, - duration: 2000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.RECORD, - - file: 'RECORDING', - encoderOptions: '-format mkv -c:v libx264 -crf 22', - }, - }, - ], - myLayerMapping - ) - - await mockTime.advanceTimeToTicks(10100) - - // one command has been sent: - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(getMockCall(commandReceiver0, 0, 1).command).toBe(Commands.Add) - expect(getMockCall(commandReceiver0, 0, 1).params).toMatchObject({ - consumer: 'FILE', - parameters: 'RECORDING -format mkv -c:v libx264 -crf 22', - }) - - await mockTime.advanceTimeToTicks(12100) - - expect(getMockCall(commandReceiver0, 1, 1).command).toBe(Commands.Remove) - }) - - test('CasparCG: Play 2 routes for 60s', async () => { - const commandReceiver0: any = jest.fn(async () => { - return Promise.resolve() - }) - const myLayerMapping0: Mapping = { - device: DeviceType.CASPARCG, - deviceId: 'myCCG', - options: { - mappingType: MappingCasparCGType.Layer, - channel: 2, - layer: 42, - }, - } - const myLayerMapping1: Mapping = { - device: DeviceType.CASPARCG, - deviceId: 'myCCG', - options: { - mappingType: MappingCasparCGType.Layer, - channel: 1, - layer: 42, - }, - } - const myLayerMapping: Mappings = { - myLayer0: myLayerMapping0, - myLayer1: myLayerMapping1, - } - - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) - await myConductor.init() - await myConductor.addDevice('myCCG', { - type: DeviceType.CASPARCG, - options: { - host: '127.0.0.1', - }, - commandReceiver: commandReceiver0, - skipVirginCheck: true, - }) - - const deviceContainer = myConductor.getDevice('myCCG') - const device = deviceContainer!.device - await device['_ccgState'] - - await mockTime.advanceTimeToTicks(10050) - expect(commandReceiver0).toHaveBeenCalledTimes(0) - - myConductor.setTimelineAndMappings( - [ - { - id: 'obj0', - enable: { - start: 9000, - duration: 3000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.ROUTE, - - mappedLayer: 'myLayer1', - delay: 80, // * 1000, // @todo: because reasons, TSR uses fps of 0.025, which breaks all calculations in CasparCG-state - mode: 'BACKGROUND', }, - }, + }), { - id: 'obj1', - enable: { - start: 11000, - duration: 1000, - }, - layer: 'myLayer1', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.ROUTE, - - channel: 2, - layer: 23, - delay: 320, // * 1000 - }, - }, - ], - myLayerMapping - ) - - await mockTime.advanceTimeToTicks(10100) - - // one command has been sent: - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(getMockCall(commandReceiver0, 0, 1).params).toMatchObject({ - channel: 2, - layer: 42, - - route: { - channel: 1, - layer: 42, - }, - framesDelay: 2, - mode: 'BACKGROUND', - }) - - await mockTime.advanceTimeToTicks(11000) - - expect(commandReceiver0).toHaveBeenCalledTimes(2) - expect(getMockCall(commandReceiver0, 1, 1).params).toMatchObject({ - channel: 1, - layer: 42, - - route: { - channel: 2, - layer: 23, - }, - framesDelay: 8, - }) - - // advance time to end of clip: - await mockTime.advanceTimeToTicks(12010) - - // two more commands have been sent: - expect(commandReceiver0).toHaveBeenCalledTimes(4) - // expect 2 clear commands: - expect(getMockCall(commandReceiver0, 2, 1).command).toEqual(Commands.Clear) - expect(getMockCall(commandReceiver0, 3, 1).command).toEqual(Commands.Clear) - }) - - test('CasparCG: AMB with transitions', async () => { - const commandReceiver0: any = jest.fn(async () => { - return Promise.resolve() - }) - const myLayerMapping0: Mapping = { - device: DeviceType.CASPARCG, - deviceId: 'myCCG', - options: { - mappingType: MappingCasparCGType.Layer, - channel: 2, - layer: 42, - }, - } - const myLayerMapping: Mappings = { - myLayer0: myLayerMapping0, - } - - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) - await myConductor.init() - await myConductor.addDevice('myCCG', { - type: DeviceType.CASPARCG, - options: { - host: '127.0.0.1', - }, - commandReceiver: commandReceiver0, - skipVirginCheck: true, - }) - - // Check that no commands has been sent: - expect(commandReceiver0).toHaveBeenCalledTimes(0) - - await mockTime.advanceTimeToTicks(10050) - expect(commandReceiver0).toHaveBeenCalledTimes(0) - - myConductor.setTimelineAndMappings( - [ - { - id: 'obj0', - enable: { - start: mockTime.getCurrentTime() - 1000, // 1 seconds ago - duration: 2000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.MEDIA, - - file: 'AMB', - - transitions: { - inTransition: { - type: Transition.MIX, - duration: 1000, - easing: Ease.LINEAR, - direction: Direction.LEFT, - }, - outTransition: { - type: Transition.MIX, - duration: 1000, - easing: Ease.LINEAR, - direction: Direction.RIGHT, - }, + '1-10': { + time: 10, + layer: { + id: 'obj0', + layerNo: 10, + content: LayerContentType.MEDIA, + media: 'amb', + playTime: 10, + + pauseTime: null, + playing: true, + + clearOn404: true, }, + lookahead: undefined, }, - }, - ], - myLayerMapping - ) - - // fast-forward: - await mockTime.advanceTimeTicks(100) - // Check that an ACMP-command has been sent - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(getMockCall(commandReceiver0, 0, 1).command).toEqual(Commands.Play) - expect(getMockCall(commandReceiver0, 0, 1).params).toMatchObject({ - channel: 2, - layer: 42, - - transition: { - transitionType: 'MIX', - duration: 25, - tween: 'LINEAR', - direction: 'LEFT', - }, - clip: 'AMB', - seek: 25, - loop: false, - }) - - await mockTime.advanceTimeTicks(2000) - expect(commandReceiver0).toHaveBeenCalledTimes(2) - expect(getMockCall(commandReceiver0, 1, 1).command).toEqual(Commands.Play) - expect(getMockCall(commandReceiver0, 1, 1).params).toMatchObject({ - channel: 2, - layer: 42, - transition: { - transitionType: 'MIX', - duration: 25, - tween: 'LINEAR', - direction: 'RIGHT', - }, - clip: 'empty', - }) - - // Nothing more should've happened: - await mockTime.advanceTimeToTicks(12400) - - expect(commandReceiver0.mock.calls.length).toBe(2) - }) - - test('CasparCG: Mixer commands', async () => { - const commandReceiver0: any = jest.fn(async () => { - return Promise.resolve() - }) - const myLayerMapping0: Mapping = { - device: DeviceType.CASPARCG, - deviceId: 'myCCG', - options: { - mappingType: MappingCasparCGType.Layer, - channel: 2, - layer: 42, - }, - } - const myLayerMapping: Mappings = { - myLayer0: myLayerMapping0, - } - - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, + } + ) }) - myConductor.on('error', (e) => { - throw new Error(e) - }) - myConductor.on('warning', (msg) => { - console.warn(msg) - }) - await myConductor.init() - await myConductor.addDevice('myCCG', { - type: DeviceType.CASPARCG, - options: { - host: '127.0.0.1', - }, - commandReceiver: commandReceiver0, - skipVirginCheck: true, - }) - - // Check that no commands has been sent: - expect(commandReceiver0).toHaveBeenCalledTimes(0) - - myConductor.setTimelineAndMappings( - [ - { - id: 'obj0', - enable: { - start: mockTime.getCurrentTime() - 1000, // 1 seconds ago - duration: 12000, // 12s - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.MEDIA, // more to be implemented later! - file: 'AMB', - loop: true, - }, - keyframes: [ - { - id: 'kf1', - enable: { - start: 500, // 0 = parent's start - duration: 5500, - }, - content: { - mixer: { - perspective: { - topLeftX: 0, - topLeftY: 0, - topRightX: 0.5, - topRightY: 0, - bottomRightX: 0.5, - bottomRightY: 1, - bottomLeftX: 0, - bottomLeftY: 1, - }, - }, - }, + test('convert lookahead', async () => { + await compareState( + createTimelineState({ + layer0_lookahead: { + id: 'obj0', + layer: 'layer0_lookahead', + content: { + deviceType: DeviceType.CASPARCG, + type: TimelineContentTypeCasparCg.MEDIA, + file: 'amb_lookahead', }, - { - id: 'kf2', - enable: { - start: 6000, // 0 = parent's start - duration: 6000, - }, - content: { - mixer: { - perspective: { - topLeftX: 0, - topLeftY: 0, - topRightX: 1, - topRightY: 0, - bottomRightX: 1, - bottomRightY: 1, - bottomLeftX: 0, - bottomLeftY: 1, - }, - }, - }, + instance: { + originalStart: 10, }, - ], - }, - ], - myLayerMapping - ) - - // fast-forward: - await mockTime.advanceTimeTicks(100) - - // Check that ACMP-commands has been sent - expect(commandReceiver0).toHaveBeenCalledTimes(2) - // we've already tested play commands so let's check the mixer command: - expect(getMockCall(commandReceiver0, 1, 1).command).toMatch(Commands.MixerPerspective) - expect(getMockCall(commandReceiver0, 1, 1).params).toMatchObject({ - channel: 2, - layer: 42, - topLeftX: 0, - topLeftY: 0, - topRightX: 0.5, - topRightY: 0, - bottomRightX: 0.5, - bottomRightY: 1, - bottomLeftX: 0, - bottomLeftY: 1, - }) - - // fast-forward: - await mockTime.advanceTimeTicks(5000) - - expect(commandReceiver0.mock.calls).toHaveLength(3) - // expect(CasparCG.mockDo.mock.calls[2][0]).toBeInstanceOf(AMCP.StopCommand); - expect(getMockCall(commandReceiver0, 2, 1).command).toMatch(Commands.MixerPerspective) - expect(getMockCall(commandReceiver0, 2, 1).params).toMatchObject({ - channel: 2, - layer: 42, - topLeftX: 0, - topLeftY: 0, - topRightX: 1, - topRightY: 0, - bottomRightX: 1, - bottomRightY: 1, - bottomLeftX: 0, - bottomLeftY: 1, - }) - }) - - test('CasparCG: loadbg command', async () => { - const commandReceiver0: any = jest.fn(async () => { - return Promise.resolve() - }) - const myLayerMapping0: Mapping = { - device: DeviceType.CASPARCG, - deviceId: 'myCCG', - options: { - mappingType: MappingCasparCGType.Layer, - channel: 2, - layer: 42, - }, - } - const myLayerMapping: Mappings = { - myLayer0: myLayerMapping0, - } - - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) - await myConductor.init() - await myConductor.addDevice('myCCG', { - type: DeviceType.CASPARCG, - options: { - host: '127.0.0.1', - }, - commandReceiver: commandReceiver0, - skipVirginCheck: true, - }) - - expect(mockTime.getCurrentTime()).toEqual(10000) - - await mockTime.advanceTimeToTicks(10050) - expect(commandReceiver0).toHaveBeenCalledTimes(0) - - myConductor.setTimelineAndMappings( - [ - { - id: 'obj0_bg', - enable: { - start: 10000, - duration: 1200, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.MEDIA, - - file: 'AMB', - loop: true, + isLookahead: true, + lookaheadForLayer: 'layer0', }, - // @ts-ignore - isLookahead: true, - }, + }), { - id: 'obj0', - enable: { - start: 11200, // 1.2 seconds in the future - duration: 2000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.MEDIA, - - file: 'AMB', - loop: true, + '1-10': { + time: 10, + layer: undefined, + lookahead: { + id: 'obj0', + // @ts-expect-error: this is due to some fun typecasting, but has no negative effect + layerNo: 10, + content: LayerContentType.MEDIA, + media: 'amb_lookahead', + playTime: 10, + + pauseTime: 10, + playing: false, + + clearOn404: true, + }, }, - }, - ], - myLayerMapping - ) - - await mockTime.advanceTimeTicks(100) - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(getMockCall(commandReceiver0, 0, 1).command).toEqual(Commands.Loadbg) - expect(getMockCall(commandReceiver0, 0, 1).params).toMatchObject({ - channel: 2, - layer: 42, - - clip: 'AMB', - auto: false, - loop: true, - seek: 0, - clearOn404: true, - }) - - await mockTime.advanceTimeTicks(2000) - expect(getMockCall(commandReceiver0, 1, 1).command).toEqual(Commands.Play) - expect(getMockCall(commandReceiver0, 1, 1).params).toEqual({ - channel: 2, - layer: 42, - }) - - await mockTime.advanceTimeTicks(2000) - expect(commandReceiver0).toHaveBeenCalledTimes(3) - expect(getMockCall(commandReceiver0, 2, 1).command).toEqual(Commands.Clear) - expect(getMockCall(commandReceiver0, 2, 1).params).toEqual({ - channel: 2, - layer: 42, + } + ) }) }) - test('CasparCG: load command', async () => { - const commandReceiver0: any = jest.fn(async () => { - return Promise.resolve() - }) - const myLayerMapping0: Mapping = { - device: DeviceType.CASPARCG, - deviceId: 'myCCG', - options: { - mappingType: MappingCasparCGType.Layer, - channel: 2, - layer: 42, - previewWhenNotOnAir: true, - }, - } - const myLayerMapping: Mappings = { - myLayer0: myLayerMapping0, - } + describe('diffState', () => { + async function compareStates( + oldDevState: CasparCGDeviceState, + newDevState: CasparCGDeviceState, + expCommands: CasparCGCommand[] + ) { + const device = await getInitialisedOscDevice() - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) - await myConductor.init() - await myConductor.addDevice('myCCG', { - type: DeviceType.CASPARCG, - options: { - host: '127.0.0.1', - }, - commandReceiver: commandReceiver0, - skipVirginCheck: true, - }) + const commands = device.diffStates(oldDevState, newDevState) - expect(mockTime.getCurrentTime()).toEqual(10000) - - await mockTime.advanceTimeToTicks(10050) - expect(commandReceiver0).toHaveBeenCalledTimes(0) - - myConductor.setTimelineAndMappings( - [ - { - id: 'obj0_bg', - enable: { - start: 10000, - duration: 1200, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.MEDIA, - - file: 'AMB', - loop: true, - }, - // @ts-ignore - isLookahead: true, - }, - { - id: 'obj0', - enable: { - start: 11200, // 1.2 seconds in the future - duration: 2000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.MEDIA, - - file: 'AMB', - loop: true, - }, - }, - ], - myLayerMapping - ) - - await mockTime.advanceTimeTicks(100) - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(getMockCall(commandReceiver0, 0, 1).command).toEqual(Commands.Load) - expect(getMockCall(commandReceiver0, 0, 1).params).toMatchObject({ - channel: 2, - layer: 42, - - clip: 'AMB', - loop: true, - seek: 0, - clearOn404: true, - }) - - await mockTime.advanceTimeTicks(2000) - - expect(getMockCall(commandReceiver0, 1, 1).command).toEqual(Commands.Resume) - expect(getMockCall(commandReceiver0, 1, 1).params).toEqual({ - channel: 2, - layer: 42, - }) - - await mockTime.advanceTimeTicks(2000) - expect(commandReceiver0).toHaveBeenCalledTimes(3) - expect(getMockCall(commandReceiver0, 2, 1).command).toEqual(Commands.Clear) - expect(getMockCall(commandReceiver0, 2, 1).params).toEqual({ - channel: 2, - layer: 42, - }) - }) - - test('CasparCG: Schedule Play, then change my mind', async () => { - const commandReceiver0: any = jest.fn(async () => { - return Promise.resolve() - }) - const myLayerMapping0: Mapping = { - device: DeviceType.CASPARCG, - deviceId: 'myCCG', - options: { - mappingType: MappingCasparCGType.Layer, - channel: 2, - layer: 42, - }, - } - const myLayerMapping: Mappings = { - myLayer0: myLayerMapping0, + expect(commands).toEqual(expCommands) } - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) - await myConductor.init() - await myConductor.addDevice('myCCG', { - type: DeviceType.CASPARCG, - options: { - host: '127.0.0.1', - }, - commandReceiver: commandReceiver0, - skipVirginCheck: true, - }) - - await mockTime.advanceTimeToTicks(10050) - expect(commandReceiver0).toHaveBeenCalledTimes(0) - - myConductor.setTimelineAndMappings( - [ - { - id: 'obj0_bg', - enable: { - start: 10000, - duration: 1200, // 11200 - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.MEDIA, - - file: 'AMB', - loop: true, - }, - // @ts-ignore - isLookahead: true, - }, - { - id: 'obj0', - enable: { - start: 11200, // 1.2 seconds in the future - duration: 2000, // 13200 - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.MEDIA, - - file: 'AMB', - loop: true, - }, - }, - ], - myLayerMapping - ) - - await mockTime.advanceTimeToTicks(10100) - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(getMockCall(commandReceiver0, 0, 1).command).toEqual(Commands.Loadbg) - expect(getMockCall(commandReceiver0, 0, 1).params).toMatchObject({ - channel: 2, - layer: 42, - - clip: 'AMB', - auto: false, - loop: true, - seek: 0, + test('Empty states', async () => { + await compareStates({}, {}, []) }) - // then change my mind: - myConductor.setTimelineAndMappings([]) - await mockTime.advanceTimeToTicks(10200) + const layer: Layer = { + id: 'obj0', + layerNo: 10, + content: LayerContentType.MEDIA, + media: 'amb', + playTime: 10, - expect(commandReceiver0).toHaveBeenCalledTimes(2) - expect(getMockCall(commandReceiver0, 1, 1).command).toEqual(Commands.Loadbg) - expect(getMockCall(commandReceiver0, 1, 1).params).toMatchObject({ - channel: 2, - layer: 42, - clip: 'EMPTY', - }) + pauseTime: null, + playing: true, - await mockTime.advanceTimeToTicks(13000) // 10100 - expect(commandReceiver0).toHaveBeenCalledTimes(2) - }) - test('CasparCG: Play a looping video, then continue looping', async () => { - const commandReceiver0: any = jest.fn(async () => { - return Promise.resolve() - }) - const myLayerMapping0: Mapping = { - device: DeviceType.CASPARCG, - deviceId: 'myCCG', - options: { - mappingType: MappingCasparCGType.Layer, - channel: 1, - layer: 10, - }, - } - const myLayerMapping: Mappings = { - myLayer0: myLayerMapping0, + clearOn404: true, } - - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) - await myConductor.init() - await myConductor.addDevice('myCCG', { - type: DeviceType.CASPARCG, - options: { - host: '127.0.0.1', - }, - commandReceiver: commandReceiver0, - skipVirginCheck: true, - }) - - await mockTime.advanceTimeToTicks(10050) - expect(commandReceiver0).toHaveBeenCalledTimes(0) - - myConductor.setTimelineAndMappings( - [ + test('Load AMB', async () => { + await compareStates( + {}, { - id: 'obj0', - enable: { - start: 10000, - duration: 5000, // 15000 - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.MEDIA, - - file: 'AMB', - loop: true, + '1-10': { + time: 10, + layer: undefined, + lookahead: layer, }, }, - { - id: 'obj1', - enable: { - start: '#obj0.end', // 15000 - duration: 5000, // 20000 - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.MEDIA, - - file: 'AMB', - loop: true, + [ + { + command: { + command: Commands.Loadbg, + params: { clip: 'amb', channel: 1, layer: 10, clearOn404: true, loop: false, seek: 0 }, + context: { layerId: '1-10_empty_base', context: 'Nextup media (amb)' }, + }, + context: 'Nextup media (amb)', + tlObjId: '1-10_empty_base', // note - this makes no sense but is an issue in casparcg-state }, - }, - ], - myLayerMapping - ) - - await mockTime.advanceTimeToTicks(30000) - expect(commandReceiver0).toHaveBeenCalledTimes(2) - - expect(getMockCall(commandReceiver0, 0, 1).command).toEqual(Commands.Play) - expect(getMockCall(commandReceiver0, 0, 1).params).toMatchObject({ - channel: 1, - layer: 10, - - clip: 'AMB', - loop: true, - seek: 0, - }) - expect(getMockCall(commandReceiver0, 1, 1).command).toEqual(Commands.Clear) - expect(getMockCall(commandReceiver0, 1, 1).params).toEqual({ - channel: 1, - layer: 10, - }) - }) - - test('CasparCG: Play a filtered decklink in PAL for 60s', async () => { - const commandReceiver0: any = jest.fn(async () => { - return Promise.resolve() - }) - const myLayerMapping0: Mapping = { - device: DeviceType.CASPARCG, - deviceId: 'myCCG', - options: { - mappingType: MappingCasparCGType.Layer, - channel: 2, - layer: 42, - }, - } - const myLayerMapping: Mappings = { - myLayer0: myLayerMapping0, - } - - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) - await myConductor.init() // we cannot do an await, because setTimeout will never call without jest moving on. - await myConductor.addDevice('myCCG', { - type: DeviceType.CASPARCG, - options: { - host: '127.0.0.1', - }, - commandReceiver: commandReceiver0, - skipVirginCheck: true, + ] + ) }) - await mockTime.advanceTimeToTicks(10100) - - expect(commandReceiver0).toHaveBeenCalledTimes(0) - - commandReceiver0.mockClear() - - myConductor.setTimelineAndMappings( - [ + test('Play AMB', async () => { + await compareStates( + {}, { - id: 'obj0', - enable: { - start: mockTime.getCurrentTime() - 1000, // 1 seconds ago - duration: 2000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.INPUT, - - device: 1, - inputType: 'decklink', - deviceFormat: ChannelFormat.HD_720P5000, - - // format: ChannelFormat.PAL, - videoFilter: 'yadif=0:-1', + '1-10': { + time: 10, + layer, + lookahead: undefined, }, }, - ] as TSRTimeline, - myLayerMapping - ) - - await mockTime.advanceTimeToTicks(10200) - - // one command has been sent: - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(getMockCall(commandReceiver0, 0, 1).params).toMatchObject({ - channel: 2, - layer: 42, - - device: 1, - vFilter: 'yadif=0:-1', - format: ChannelFormat.HD_720P5000, - }) - - // advance time to end of clip: - await mockTime.advanceTimeToTicks(11200) - - // two commands have been sent: - expect(commandReceiver0).toHaveBeenCalledTimes(2) - expect(getMockCall(commandReceiver0, 1, 1).command).toEqual(Commands.Clear) - expect(getMockCall(commandReceiver0, 1, 1).params.channel).toEqual(2) - expect(getMockCall(commandReceiver0, 1, 1).params.layer).toEqual(42) - }) - - test('CasparCG: play missing file with reloads', async () => { - const commandReceiver0: any = jest.fn(async () => { - return Promise.resolve() - }) - const myLayerMapping0: Mapping = { - device: DeviceType.CASPARCG, - deviceId: 'myCCG', - options: { - mappingType: MappingCasparCGType.Layer, - channel: 2, - layer: 42, - }, - } - const myLayerMapping: Mappings = { - myLayer0: myLayerMapping0, - } - - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) - await myConductor.init() - await myConductor.addDevice('myCCG', { - type: DeviceType.CASPARCG, - options: { - host: '127.0.0.1', - retryInterval: undefined, // disable retries explicitly, we will manually trigger them - }, - commandReceiver: commandReceiver0, - skipVirginCheck: true, - }) - myConductor.setTimelineAndMappings([], myLayerMapping) - await mockTime.advanceTimeToTicks(10100) - - expect(commandReceiver0).toHaveBeenCalledTimes(0) - - commandReceiver0.mockClear() - - const deviceContainer = myConductor.getDevice('myCCG') - const device = deviceContainer!.device - - myConductor.setTimelineAndMappings([ - { - id: 'obj0', - enable: { - start: mockTime.getCurrentTime() - 1000, // 1 seconds ago - duration: 2000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.MEDIA, - - file: 'AMB', - loop: true, - }, - }, - ]) - - await mockTime.advanceTimeToTicks(10200) - - // one command has been sent: - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(getMockCall(commandReceiver0, 0, 1).command).toBe(Commands.Play) - expect(getMockCall(commandReceiver0, 0, 1).params).toMatchObject({ - channel: 2, - layer: 42, - - clip: 'AMB', - loop: true, - seek: 0, // looping and seeking nos supported when length not provided - }) - - // advance before half way - await mockTime.advanceTimeToTicks(10500) - // no retries issued yet - expect(commandReceiver0).toHaveBeenCalledTimes(1) - - // advance to half way - await mockTime.advanceTimeToTicks(10700) - // call the retry mechanism - await (device as any)._assertIntendedState() - await mockTime.advanceTimeToTicks(10800) - - expect(commandReceiver0).toHaveBeenCalledTimes(2) - expect(getMockCall(commandReceiver0, 1, 1).command).toBe(Commands.Play) - expect(getMockCall(commandReceiver0, 1, 1).params).toMatchObject({ - channel: 2, - layer: 42, - - clip: 'AMB', - loop: true, - seek: 0, // looping and seeking nos supported when length not provided - }) - - // apply command to internal ccg-state - const resCommand = getMockCall(commandReceiver0, 1, 1) - // @ts-ignore - await device._changeTrackedStateFromCommand( - resCommand, - { responseCode: 202, command: resCommand.command }, - mockTime.getCurrentTime() - ) - // trigger retry mechanism - await (device as any)._assertIntendedState() - await mockTime.advanceTimeToTicks(10900) - // no retries done - expect(commandReceiver0).toHaveBeenCalledTimes(2) - - // advance time to end of clip: - await mockTime.advanceTimeToTicks(11200) - - // 3 commands have been sent: - expect(commandReceiver0).toHaveBeenCalledTimes(3) - expect(getMockCall(commandReceiver0, 2, 1).command).toEqual(Commands.Clear) - expect(getMockCall(commandReceiver0, 2, 1).params.channel).toEqual(2) - expect(getMockCall(commandReceiver0, 2, 1).params.layer).toEqual(42) - - // advance time to after clip: - await mockTime.advanceTimeToTicks(11700) - // call the retry mechanism - await (device as any)._assertIntendedState() - await mockTime.advanceTimeToTicks(11800) - // no retries issued - expect(commandReceiver0).toHaveBeenCalledTimes(3) - }) - - test('CasparCG: play empty and expect no reloads', async () => { - const commandReceiver0: any = jest.fn(async () => { - return Promise.resolve() - }) - const myLayerMapping0: Mapping = { - device: DeviceType.CASPARCG, - deviceId: 'myCCG', - options: { - mappingType: MappingCasparCGType.Layer, - channel: 2, - layer: 42, - }, - } - const myLayerMapping: Mappings = { - myLayer0: myLayerMapping0, - } - - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) - await myConductor.init() - await myConductor.addDevice('myCCG', { - type: DeviceType.CASPARCG, - options: { - host: '127.0.0.1', - retryInterval: undefined, // disable retries explicitly, we will manually trigger them - }, - commandReceiver: commandReceiver0, - skipVirginCheck: true, - }) - myConductor.setTimelineAndMappings([], myLayerMapping) - await mockTime.advanceTimeToTicks(10100) - - expect(commandReceiver0).toHaveBeenCalledTimes(0) - - commandReceiver0.mockClear() - - const deviceContainer = myConductor.getDevice('myCCG') - const device = deviceContainer!.device - - myConductor.setTimelineAndMappings([ - { - id: 'group0', - enable: { - while: 1, - }, - layer: 'abstract', - isGroup: true, - content: { - deviceType: DeviceType.ABSTRACT, - type: 'empty', - }, - children: [ + [ { - id: 'obj0', - enable: { - start: 0, // always - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.MEDIA, - - file: 'empty', - transitions: { - inTransition: { - type: Transition.CUT, - duration: 56 * 40, - }, - }, + command: { + command: Commands.Play, + params: { clip: 'amb', channel: 1, layer: 10, clearOn404: true, loop: false, seek: 0 }, + context: { layerId: 'obj0', context: 'VFilter diff ("undefined", "undefined") (content: media!=)' }, }, + context: 'VFilter diff ("undefined", "undefined") (content: media!=)', + tlObjId: 'obj0', }, - ], - }, - ]) - - await mockTime.advanceTimeToTicks(10200) - - // one command has been sent: - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(getMockCall(commandReceiver0, 0, 1).params).toMatchObject({ - channel: 2, - layer: 42, - - clip: 'empty', - transition: { - transitionType: 'CUT', - direction: 'RIGHT', - tween: 'LINEAR', - duration: 56, - }, - seek: 252, // 10100 / 40 - }) - - // apply command to internal ccg-state - // @ts-ignore - const resCommand = getMockCall(commandReceiver0, 0, 1) - // @ts-ignore - await device._changeTrackedStateFromCommand( - resCommand, - { responseCode: 202, command: resCommand.command }, - mockTime.getCurrentTime() - ) - - // advance before half way - await mockTime.advanceTimeToTicks(10500) - // no retries issued yet - expect(commandReceiver0).toHaveBeenCalledTimes(1) - - // advance to half way - await mockTime.advanceTimeToTicks(10700) - // call the retry mechanism - await (device as any)._assertIntendedState() - // still no retries as empty always plays - expect(commandReceiver0).toHaveBeenCalledTimes(1) - - // note: no clear command is sent because the layer is already empty - - // advance time to after clip: - await mockTime.advanceTimeToTicks(20700) - // call the retry mechanism - await (device as any)._assertIntendedState() - await mockTime.advanceTimeToTicks(20800) - // no retries issued - expect(commandReceiver0).toHaveBeenCalledTimes(1) - }) - - test('CasparCG: Multiple mappings for 1 layer', async () => { - const commandReceiver0: any = jest.fn(async () => { - return Promise.resolve() + ] + ) }) - const myLayerMapping0: Mapping = { - device: DeviceType.CASPARCG, - deviceId: 'myCCG', - options: { - mappingType: MappingCasparCGType.Layer, - channel: 2, - layer: 42, - }, - } - const myLayerMapping: Mappings = { - myLayer0: myLayerMapping0, - myLayer1: myLayerMapping0, - } - - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) - await myConductor.init() // we cannot do an await, because setTimeout will never call without jest moving on. - await myConductor.addDevice('myCCG', { - type: DeviceType.CASPARCG, - options: { - host: '127.0.0.1', - }, - commandReceiver: commandReceiver0, - skipVirginCheck: true, - }) - await mockTime.advanceTimeToTicks(10100) - - expect(commandReceiver0).toHaveBeenCalledTimes(0) - - commandReceiver0.mockClear() - - myConductor.setTimelineAndMappings( - [ + test('Stop AMB', async () => { + await compareStates( { - id: 'obj0', - enable: { - start: mockTime.getCurrentTime() - 1000, // 1 seconds ago - duration: 2000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.MEDIA, - - file: 'AMB', - inPoint: 480, + '1-10': { + time: 10, + layer, + lookahead: undefined, }, }, - { - id: 'obj1', - enable: { - start: mockTime.getCurrentTime() - 1000, // 1 seconds ago - duration: 2000, + {}, + [ + { + command: { + command: Commands.Stop, + params: { channel: 1, layer: 10 }, + context: { layerId: 'obj0', context: 'No new content ()' }, + }, + context: 'No new content ()', + tlObjId: 'obj0', }, - layer: 'myLayer1', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.MEDIA, - - file: 'AMB', - length: 5000, + { + command: { + command: Commands.Clear, + params: { channel: 1, layer: 10 }, + context: { layerId: 'obj0', context: 'Clear old stuff' }, + }, + context: 'Clear old stuff', + tlObjId: 'obj0', }, - }, - ], - myLayerMapping - ) - - await mockTime.advanceTimeToTicks(10200) - - // one command has been sent: - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(getMockCall(commandReceiver0, 0, 1).params).toMatchObject({ - channel: 2, - layer: 42, - - clip: 'AMB', - inPoint: 12, - length: 125, - seek: 25 + 12, // started 1 second ago + ] + ) }) - commandReceiver0.mockClear() - - // advance time to end of clip: - await mockTime.advanceTimeToTicks(11200) - - // two commands have been sent: - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(getMockCall(commandReceiver0, 0, 1).command).toBe(Commands.Clear) }) - test('CasparCG: Multiple mappings for 1 layer extend template data', async () => { - const commandReceiver0: any = jest.fn(async () => { - return Promise.resolve() - }) - const myLayerMapping0: Mapping = { - device: DeviceType.CASPARCG, - deviceId: 'myCCG', - options: { - mappingType: MappingCasparCGType.Layer, - channel: 2, - layer: 42, - }, - } - const myLayerMapping: Mappings = { - myLayer0: myLayerMapping0, - myLayer1: myLayerMapping0, - } - - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) - await myConductor.init() // we cannot do an await, because setTimeout will never call without jest moving on. - await myConductor.addDevice('myCCG', { - type: DeviceType.CASPARCG, - options: { - host: '127.0.0.1', - }, - commandReceiver: commandReceiver0, - skipVirginCheck: true, - }) - await mockTime.advanceTimeToTicks(10100) - - expect(commandReceiver0).toHaveBeenCalledTimes(0) - - commandReceiver0.mockClear() + describe('sendCommand', () => { + test('send a command', async () => { + const dev = await getInitialisedOscDevice() - myConductor.setTimelineAndMappings( - [ - { - id: 'obj0', - enable: { - start: mockTime.getCurrentTime() - 1000, // 1 seconds ago - duration: 2000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.TEMPLATE, - - name: 'LT', - templateType: 'html', - data: { - f0: 'Hello', - f1: 'World', - foo: { - bar: 'baz', - }, - }, - useStopCommand: true, - }, + const command = { + command: Commands.Play, + params: { + channel: 1, + layer: 2, + clip: 'asdf', }, - { - id: 'obj1', - enable: { - start: mockTime.getCurrentTime() - 1000, // 1 seconds ago - duration: 2000, - }, - layer: 'myLayer1', - content: { - deviceType: DeviceType.CASPARCG, - type: TimelineContentTypeCasparCg.TEMPLATE, - - name: 'LT', - templateType: 'html', - data: { - f1: 'Universe', - foo: { - bar1: 'bazinga', - }, - }, - useStopCommand: true, - }, + context: { + context: '', + layerId: '', }, - ], - myLayerMapping - ) - - await mockTime.advanceTimeToTicks(10200) + } - // one command has been sent: - expect(commandReceiver0).toHaveBeenCalledTimes(1) - // console.log(getMockCall(commandReceiver0, 0, 1).params) - expect(getMockCall(commandReceiver0, 0, 1).params).toMatchObject({ - channel: 2, - layer: 42, + dev + .sendCommand({ + command, + context: '', + tlObjId: '', + }) + .catch((e) => { + throw e + }) - template: 'LT', - cgLayer: 1, - playOnLoad: true, - data: { f0: 'Hello', f1: 'Universe', foo: { bar: 'baz', bar1: 'bazinga' } }, + expect(mockDo).toHaveBeenCalledTimes(1) + expect(mockDo).toHaveBeenCalledWith(command) }) - commandReceiver0.mockClear() - - // advance time to end of clip: - await mockTime.advanceTimeToTicks(11200) - - // two commands have been sent: - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(getMockCall(commandReceiver0, 0, 1).command).toBe(Commands.CgStop) }) }) -// describe('CasparCG - Custom transitions', () => { -// const mockTime = new MockTime() -// beforeEach(() => { -// mockTime.init() -// }) -// test('FILL', async () => { -// const commandReceiver0: any = jest.fn(async () => { -// return Promise.resolve() -// }) -// const myLayerMapping0: Mapping = { -// device: DeviceType.CASPARCG, -// deviceId: 'myCCG', -// channel: 2, -// layer: 42, -// } -// const myLayerMapping: Mappings = { -// myLayer0: myLayerMapping0, -// } - -// const myConductor = new Conductor({ -// multiThreadedResolver: false, -// getCurrentTime: mockTime.getCurrentTime, -// }) -// await myConductor.init() -// await myConductor.addDevice('myCCG', { -// type: DeviceType.CASPARCG, -// options: { -// host: '127.0.0.1', -// retryInterval: undefined, // disable retries explicitly, we will manually trigger them -// }, -// commandReceiver: commandReceiver0, -// }) -// myConductor.setTimelineAndMappings([], myLayerMapping) -// await mockTime.advanceTimeToTicks(10000) - -// expect(commandReceiver0).toHaveBeenCalledTimes(0) - -// commandReceiver0.mockClear() - -// myConductor.setTimelineAndMappings([ -// { -// id: 'video0', -// enable: { -// start: mockTime.getCurrentTime(), // 10000 -// }, -// layer: 'myLayer0', -// content: { -// deviceType: DeviceType.CASPARCG, -// type: TimelineContentTypeCasparCg.MEDIA, - -// file: 'amb', -// // transitions: { -// // inTransition: { -// // type: Transition.CUT, -// // duration: 56 * 40 -// // } -// // } -// mixer: { -// changeTransition: { -// type: Transition.TSR_TRANSITION, -// customOptions: { -// updateInterval: 1000 / 25, -// acceleration: 0.000002, -// }, -// }, -// fill: { -// x: 0, -// y: 0, -// xScale: 1, -// yScale: 1, -// }, -// }, -// }, -// keyframes: [ -// { -// id: 'kf0', -// enable: { -// start: 1000, // 11000 -// }, -// content: { -// mixer: { -// fill: { -// x: 0.5, -// y: 0.5, -// xScale: 0.5, -// yScale: 0.5, -// }, -// }, -// }, -// }, -// ], -// }, -// ]) - -// await mockTime.advanceTimeToTicks(10500) - -// // // one command has been sent: -// expect(commandReceiver0).toHaveBeenCalledTimes(2) -// expect(getMockCall(commandReceiver0, 0, 1).params).toMatchObject({ -// channel: 2, -// layer: 42, -// -// clip: 'amb', -// seek: 0, -// }) -// expect(getMockCall(commandReceiver0, 1, 1).params).toMatchObject({ -// channel: 2, -// layer: 42, -// keyword: 'FILL', -// x: 0, -// y: 0, -// xScale: 1, -// yScale: 1, -// }) - -// commandReceiver0.mockClear() -// await mockTime.advanceTimeToTicks(13500) - -// expect( -// commandReceiver0.mock.calls.map((call) => { -// const o = { -// ...call[1].params, -// } -// delete o.layer -// delete o.channel -// delete o.keyword -// return o -// }) -// ).toMatchSnapshot() - -// expect(getMockCall(commandReceiver0, 58, 1).params).toMatchObject({ -// channel: 2, -// layer: 42, -// keyword: 'FILL', -// x: 0.5, -// y: 0.5, -// xScale: 0.5, -// yScale: 0.5, -// }) - -// expect(commandReceiver0).toHaveBeenCalledTimes(59) - -// commandReceiver0.mockClear() -// await mockTime.advanceTimeToTicks(13500) - -// expect(commandReceiver0).toHaveBeenCalledTimes(0) -// }) -// }) +function createTimelineState( + objs: Record< + string, + { + id: string + layer: string + content: TimelineContentCasparCGAny + instance: any + isLookahead?: boolean + lookaheadForLayer?: string + } + > +): Timeline.TimelineState { + return { + time: 10, + layers: objs as any, + nextEvents: [], + } +} diff --git a/packages/timeline-state-resolver/src/integrations/casparCG/actions.ts b/packages/timeline-state-resolver/src/integrations/casparCG/actions.ts new file mode 100644 index 000000000..659748828 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/casparCG/actions.ts @@ -0,0 +1,79 @@ +import got from 'got' +import { t } from '../../lib' +import { ActionExecutionResultCode, ActionExecutionResult, CasparCGOptions } from 'timeline-state-resolver-types' +import { CasparCG } from 'casparcg-connection' + +export async function clearAllChannels(connection: CasparCG, resetCb: () => void) { + if (!connection || !connection.connected) { + return { + result: ActionExecutionResultCode.Error, + response: t('Cannot restart CasparCG without a connection'), + } + } + + const { request, error } = await connection.info({}) + + if (error) { + return { + result: ActionExecutionResultCode.Error, + } + } + + // the amount of lines returned equals the amount of channels + const response = await request + await Promise.allSettled( + response.data.map(async (_, i) => connection.clear({ channel: i + 1 }).then(async ({ request }) => request)) + ) + + resetCb() // emits a resetFromState event + + return { + result: ActionExecutionResultCode.Ok, + } +} + +export async function restartServer(options: CasparCGOptions | undefined) { + if (!options) { + return { result: ActionExecutionResultCode.Error, response: t('CasparCGDevice._connectionOptions is not set!') } + } + if (!options.launcherHost) { + return { + result: ActionExecutionResultCode.Error, + response: t('CasparCGDevice: config.launcherHost is not set!'), + } + } + if (!options.launcherPort) { + return { + result: ActionExecutionResultCode.Error, + response: t('CasparCGDevice: config.launcherPort is not set!'), + } + } + if (!options.launcherProcess) { + return { + result: ActionExecutionResultCode.Error, + response: t('CasparCGDevice: config.launcherProcess is not set!'), + } + } + + return new Promise((resolve) => { + const url = `http://${options.launcherHost}:${options.launcherPort}/processes/${options.launcherProcess}/restart` + got + .post(url) + .then((response) => { + if (response.statusCode === 200) { + resolve({ result: ActionExecutionResultCode.Ok }) + } else { + resolve({ + result: ActionExecutionResultCode.Error, + response: t('Bad reply: [{{statusCode}}] {{body}}', { + statusCode: response.statusCode, + body: response.body, + }), + }) + } + }) + .catch((error) => { + resolve({ result: ActionExecutionResultCode.Error, response: error }) + }) + }) +} diff --git a/packages/timeline-state-resolver/src/integrations/casparCG/diff.ts b/packages/timeline-state-resolver/src/integrations/casparCG/diff.ts new file mode 100644 index 000000000..ab07722b4 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/casparCG/diff.ts @@ -0,0 +1,160 @@ +import { + AMCPCommandWithContext, + CasparCGState, + EmptyLayer, + Layer, + LayerContentType, + State as CcgState, +} from 'casparcg-state' +import { CommandWithContext } from '../../service/device' +import { CasparCGDeviceState, TrackedLayer } from './state' +import { DeviceType, Mapping, MappingCasparCGType, SomeMappingCasparCG } from 'timeline-state-resolver-types' +import { literal } from '../../devices/device' +import { InternalState as CcgInternalState } from 'casparcg-state/dist/lib/stateObjectStorage' +import { Commands, PlayCommand, PlayDecklinkCommand, PlayHtmlCommand, PlayRouteCommand } from 'casparcg-connection' + +export interface CasparCGCommand extends CommandWithContext { + command: AMCPCommandWithContext +} + +export function diffStates( + currentState: CasparCGDeviceState | undefined, + expectedState: CasparCGDeviceState, + fps: number +): CasparCGCommand[] { + // get addresses + const addresses = new Set([...Object.keys(currentState ?? {}), ...Object.keys(expectedState ?? {})]).values() + + const commands = [] as CasparCGCommand[] + + for (const addr of addresses) { + commands.push( + ...diffTrackerStatesLayer( + addr, + currentState?.[addr], + expectedState[addr], + expectedState[addr]?.time ?? currentState?.[addr]?.time ?? 0, + fps + ) + ) + } + + return orderCommands(commands) +} + +function diffTrackerStatesLayer( + addr: string, + currentLayer: TrackedLayer | undefined, + expectedLayer: TrackedLayer | undefined, + time: number, + fps: number +): CasparCGCommand[] { + const [channel, layer] = addr.split('-').map((v) => parseInt(v)) + const mapping: Mapping = { + device: DeviceType.CASPARCG, + deviceId: '', + + options: { + mappingType: MappingCasparCGType.Layer, + + channel, + layer, + }, + } + + let newLayer: Layer = getEmptyLayer(addr, mapping) + let oldLayer: Layer = getEmptyLayer(addr, mapping) + + if (expectedLayer?.layer) { + newLayer = expectedLayer.layer + } + if (currentLayer?.layer) { + oldLayer = currentLayer.layer + } + + if (expectedLayer?.lookahead) { + newLayer.nextUp = expectedLayer.lookahead + } + if (currentLayer?.lookahead) { + oldLayer.nextUp = currentLayer.lookahead + } + + const commands = CasparCGState.diffStatesOrderedCommands( + getInternalStateFromLayer(oldLayer, mapping, fps), + getStateFromLayer(newLayer, mapping, fps), + time + ) + + return commands.map((c) => ({ + command: c, + context: c.context.context, + tlObjId: c.context.layerId, + })) +} + +function getEmptyLayer(addr: string, mapping: Mapping) { + return literal({ + id: `${addr}_empty_base`, + layerNo: mapping.options.layer, + content: LayerContentType.NOTHING, + playing: false, + }) +} +function getInternalStateFromLayer(layer: Layer, mapping: Mapping, fps: number): CcgInternalState { + return literal({ + channels: { + [mapping.options.channel]: { + channelNo: mapping.options.channel, + videoMode: 'PAL', // hardcoded but doesn't look like ccg-state is using it anyway + fps: fps, + layers: { + [mapping.options.layer]: layer, + }, + }, + }, + }) +} +function getStateFromLayer(layer: Layer, mapping: Mapping, fps: number): CcgState { + return literal(getInternalStateFromLayer(layer, mapping, fps)) +} + +function orderCommands(commands: CasparCGCommand[]): Array { + const fastCommands: Array = [] // fast to exec, and direct visual impact: PLAY 1-10 + const slowCommands: Array = [] // slow to exec, but direct visual impact: PLAY 1-10 FILE (needs to have all commands for that layer in the right order) + const lowPrioCommands: Array = [] // slow to exec, and no direct visual impact: LOADBG 1-10 FILE + + let containsSlowCommand = false + + // filter out lowPrioCommands + for (let i = 0; i < commands.length; i++) { + if ( + commands[i].command.command === Commands.Loadbg || + commands[i].command.command === Commands.LoadbgDecklink || + commands[i].command.command === Commands.LoadbgRoute || + commands[i].command.command === Commands.LoadbgHtml + ) { + lowPrioCommands.push(commands[i]) + commands.splice(i, 1) + i-- // next entry now has the same index as this one. + } else if ( + (commands[i].command.command === Commands.Play && (commands[i].command.params as PlayCommand['params']).clip) || + (commands[i].command.command === Commands.PlayDecklink && + (commands[i].command.params as PlayDecklinkCommand['params']).device) || + (commands[i].command.command === Commands.PlayRoute && + (commands[i].command.params as PlayRouteCommand['params']).route) || + (commands[i].command.command === Commands.PlayHtml && + (commands[i].command.params as PlayHtmlCommand['params']).url) || + commands[i].command.command === Commands.Load + ) { + containsSlowCommand = true + } + } + + if (containsSlowCommand) { + slowCommands.push(...commands) + } else { + fastCommands.push(...commands) + } + + return [...fastCommands, ...slowCommands, ...lowPrioCommands] +} diff --git a/packages/timeline-state-resolver/src/integrations/casparCG/index.ts b/packages/timeline-state-resolver/src/integrations/casparCG/index.ts index a6b13c0da..51d35c396 100644 --- a/packages/timeline-state-resolver/src/integrations/casparCG/index.ts +++ b/packages/timeline-state-resolver/src/integrations/casparCG/index.ts @@ -1,120 +1,51 @@ -import * as _ from 'underscore' -import * as deepMerge from 'deepmerge' -import { DeviceWithState, CommandWithContext, DeviceStatus, StatusCode, literal } from '../../devices/device' -import { AMCPCommand, BasicCasparCGAPI, ClearCommand, Commands, Response } from 'casparcg-connection' import { - DeviceType, - TimelineContentTypeCasparCg, - SomeMappingCasparCG, + ActionExecutionResult, + CasparCGActions, CasparCGOptions, - TimelineContentCCGProducerBase, - ResolvedTimelineObjectInstanceExtended, - DeviceOptionsCasparCG, + DeviceStatus, Mappings, - TimelineContentCasparCGAny, - TSRTimelineObjProps, + StatusCode, Timeline, TSRTimelineContent, - ActionExecutionResult, - ActionExecutionResultCode, - CasparCGActions, - MappingCasparCGLayer, - Mapping, } from 'timeline-state-resolver-types' +import { Device, DeviceEvents } from '../../service/device' -import { - CasparCGState, - AMCPCommandWithContext, - LayerBase, - MediaLayer, - InputLayer, - TemplateLayer, - HtmlPageLayer, - RouteLayer, - RecordLayer, - EmptyLayer, - LayerContentType, - TransitionObject, - State, - NextUp, - Transition as StateTransition, - Mixer, -} from 'casparcg-state' -import { InternalState } from 'casparcg-state/dist/lib/stateObjectStorage' -import { DoOnTime, SendMode } from '../../devices/doOnTime' -import got from 'got' -import { InternalTransitionHandler } from '../../devices/transitions/transitionHandler' import Debug from 'debug' -import { endTrace, startTrace, t } from '../../lib' +import EventEmitter = require('eventemitter3') +import { AMCPCommand, CasparCG, Commands, Response } from 'casparcg-connection' +import { CasparCGDeviceState, convertTimelineStateToAddressStates } from './state' +import { CasparCGCommand, diffStates } from './diff' +import { clearAllChannels, restartServer } from './actions' const debug = Debug('timeline-state-resolver:casparcg') -const MEDIA_RETRY_INTERVAL = 10 * 1000 // default time in ms between checking whether a file needs to be retried loading - -export interface DeviceOptionsCasparCGInternal extends DeviceOptionsCasparCG { - commandReceiver?: CommandReceiver - /** Allow skipping the resync upon connection, for unit tests */ - skipVirginCheck?: boolean -} -export type CommandReceiver = (time: number, cmd: AMCPCommand, context: string, timelineObjId: string) => Promise -/** - * This class is used to interface with CasparCG installations. It creates - * device states from timeline states and then diffs these states to generate - * commands. It depends on the DoOnTime class to execute the commands timely or, - * optionally, uses the CasparCG command scheduling features. - */ -export class CasparCGDevice extends DeviceWithState { - private _ccg: BasicCasparCGAPI - private _commandReceiver: CommandReceiver - private _doOnTime: DoOnTime - private initOptions?: CasparCGOptions - private _connected = false +export class CasparCGDevice + extends EventEmitter + implements Device +{ + private _connection: CasparCG + private _options: CasparCGOptions | undefined private _queueOverflow = false - private _transitionHandler: InternalTransitionHandler = new InternalTransitionHandler() - private _retryTimeout: NodeJS.Timeout - private _retryTime: number | null = null - private _currentState: InternalState = { channels: {} } - - constructor(deviceId: string, deviceOptions: DeviceOptionsCasparCGInternal, getCurrentTime: () => Promise) { - super(deviceId, deviceOptions, getCurrentTime) - if (deviceOptions.options) { - if (deviceOptions.commandReceiver) this._commandReceiver = deviceOptions.commandReceiver - else this._commandReceiver = this._defaultCommandReceiver.bind(this) - } - - this._doOnTime = new DoOnTime( - () => { - return this.getCurrentTime() - }, - SendMode.BURST, - this._deviceOptions - ) - this.handleDoOnTime(this._doOnTime, 'CasparCG') - } + async init(options: CasparCGOptions): Promise { + this._options = options - /** - * Initiates the connection with CasparCG through the ccg-connection lib and - * initializes CasparCG State library. - */ - async init(initOptions: CasparCGOptions): Promise { - this.initOptions = initOptions - this._ccg = new BasicCasparCGAPI({ - host: initOptions.host, - port: initOptions.port, + // first setup a connection and handle it's events + this._connection = new CasparCG({ + host: options.host, + port: options.port, }) + this._connection.connect() - this._ccg.on('connect', () => { - this.makeReady(false) // always make sure timecode is correct, setting it can never do bad - .catch((e) => this.emit('error', 'casparCG.makeReady', e)) + this._connection.on('connect', () => { + this.emit('connectionChanged', this.getStatus()) + // do a virgin check Promise.resolve() .then(async () => { - if (this.deviceOptions.skipVirginCheck) return false - // a "virgin server" was just restarted (so it is cleared & black). // Otherwise it was probably just a loss of connection - const { error, request } = await this._ccg.executeCommand({ command: Commands.Info, params: {} }) + const { error, request } = await this._connection.executeCommand({ command: Commands.Info, params: {} }) if (error) return true const response = await request @@ -126,7 +57,7 @@ export class CasparCGDevice extends DeviceWithState { // Finally we can report it as connected - this._connected = true - this._connectionChanged() + this.emit('connectionChanged', this.getStatus()) if (doResync) { - this._currentState = { channels: {} } - this.clearStates() - this.emit('resetResolver') + // this.emit('resetFromState', { layers: {}, lookaheads: {} }) + this.emit('resetResolver') // todo use correct reset } }) .catch((e) => { this.emit('error', 'connect state resync failed', e) - // Some unknwon error occured, report the connection as failed - this._connected = false - this._connectionChanged() + // Some unknown error occurred, report the connection as failed + this.emit('connectionChanged', this.getStatus()) }) }) - this._ccg.on('disconnect', () => { - this._connected = false - this._connectionChanged() + this._connection.on('disconnect', () => { + this.emit('connectionChanged', this.getStatus()) }) - const { error, request } = await this._ccg.executeCommand({ command: Commands.Info, params: {} }) - if (error) { - return false // todo - should this throw? - } - const response = await request - - if (response?.data[0]) { - response.data.forEach((obj) => { - this._currentState.channels[obj.channel] = { - channelNo: obj.channel, - videoMode: obj.format.toUpperCase(), - fps: obj.frameRate, - layers: {}, - } - }) - } else { - return false // not being able to get channel count is a problem for us - } - - if (typeof initOptions.retryInterval === 'number' && initOptions.retryInterval >= 0) { - this._retryTime = initOptions.retryInterval || MEDIA_RETRY_INTERVAL - this._retryTimeout = setTimeout(() => this._assertIntendedState(), this._retryTime) - } - - return true - } - - /** - * Terminates the device safely such that things can be garbage collected. - */ - async terminate(): Promise { - this._doOnTime.dispose() - this._transitionHandler.terminate() - clearTimeout(this._retryTimeout) - return new Promise((resolve) => { - if (!this._ccg) { - resolve() - return - } else if (!this._ccg.connected) { - this._ccg.once('disconnect', () => { - resolve() - this._ccg.removeAllListeners() - }) - this._ccg.disconnect() - } else { - this._ccg.removeAllListeners() - resolve() - } + this._connection.on('error', (e) => { + this.emit('error', 'Error in casparcg-connection', e) }) - } - /** 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 CasparCG commands by comparing the newState against the oldState, or the current device state. - */ - handleState(newState: Timeline.TimelineState, newMappings: Mappings) { - super.onHandleState(newState, newMappings) - - const previousStateTime = Math.max(this.getCurrentTime(), newState.time) - - const oldCasparState = (this.getStateBefore(previousStateTime) || { state: { channels: {} } }).state - - const convertTrace = startTrace(`device:convertState`, { deviceId: this.deviceId }) - const newCasparState = this.convertStateToCaspar(newState, newMappings) - this.emit('timeTrace', endTrace(convertTrace)) - - const diffTrace = startTrace(`device:diffState`, { deviceId: this.deviceId }) - const commandsToAchieveState = CasparCGState.diffStatesOrderedCommands( - oldCasparState as InternalState, - newCasparState, - 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, newState.time) - - // store the new state, for later use: - this.setState(newCasparState, newState.time) - } - - /** - * Clear any scheduled commands after this time - * @param clearAfterTime - */ - clearFuture(clearAfterTime: number) { - this._doOnTime.clearQueueAfter(clearAfterTime) - } - get canConnect(): boolean { - return true - } - get connected(): boolean { - // Returns connection status - return this._ccg ? this._ccg.connected : false - } - - get deviceType() { - return DeviceType.CASPARCG - } - get deviceName(): string { - if (this._ccg) { - return 'CasparCG ' + this.deviceId + ' ' + this._ccg.host + ':' + this._ccg.port - } else { - return 'Uninitialized CasparCG ' + this.deviceId - } - } - - private convertObjectToCasparState( - mappings: Mappings, - layer: Timeline.ResolvedTimelineObjectInstance, - mapping: MappingCasparCGLayer, - isForeground: boolean - ): LayerBase { - let startTime = layer.instance.originalStart || layer.instance.start - if (startTime === 0) startTime = 1 // @todo: startTime === 0 will make ccg-state seek to the current time - - const layerProps = layer as Timeline.ResolvedTimelineObjectInstance & TSRTimelineObjProps - const content = layer.content as TimelineContentCasparCGAny - - let stateLayer: LayerBase | null = null - if (content.type === TimelineContentTypeCasparCg.MEDIA) { - const holdOnFirstFrame = !isForeground || layerProps.isLookahead - const loopingPlayTime = content.loop && !content.seek && !content.inPoint && !content.length - - stateLayer = literal({ - id: layer.id, - layerNo: mapping.layer, - content: LayerContentType.MEDIA, - media: content.file, - playTime: !holdOnFirstFrame && (content.noStarttime || loopingPlayTime) ? null : startTime, - - pauseTime: holdOnFirstFrame ? startTime : content.pauseTime || null, - playing: !layerProps.isLookahead && (content.playing !== undefined ? content.playing : isForeground), - - looping: content.loop, - seek: content.seek, - inPoint: content.inPoint, - length: content.length, - - channelLayout: content.channelLayout, - clearOn404: true, - - vfilter: content.videoFilter, - afilter: content.audioFilter, - }) - // this.emitDebug(stateLayer) - } else if (content.type === TimelineContentTypeCasparCg.IP) { - stateLayer = literal({ - id: layer.id, - layerNo: mapping.layer, - content: LayerContentType.MEDIA, - media: content.uri, - channelLayout: content.channelLayout, - playTime: null, // ip inputs can't be seeked // layer.resolved.startTime || null, - playing: true, - seek: 0, // ip inputs can't be seeked - vfilter: content.videoFilter, - afilter: content.audioFilter, - }) - } else if (content.type === TimelineContentTypeCasparCg.INPUT) { - stateLayer = literal({ - id: layer.id, - layerNo: mapping.layer, - content: LayerContentType.INPUT, - media: 'decklink', - input: { - device: content.device, - channelLayout: content.channelLayout, - format: content.deviceFormat, - }, - playing: true, - playTime: null, - - vfilter: content.videoFilter || content.filter, - afilter: content.audioFilter, - }) - } else if (content.type === TimelineContentTypeCasparCg.TEMPLATE) { - stateLayer = literal({ - id: layer.id, - layerNo: mapping.layer, - content: LayerContentType.TEMPLATE, - media: content.name, - - playTime: startTime || null, - playing: true, - - templateType: content.templateType || 'html', - templateData: content.data, - cgStop: content.useStopCommand, - }) - } else if (content.type === TimelineContentTypeCasparCg.HTMLPAGE) { - stateLayer = literal({ - id: layer.id, - layerNo: mapping.layer, - content: LayerContentType.HTMLPAGE, - media: content.url, - - playTime: startTime || null, - playing: true, - }) - } else if (content.type === TimelineContentTypeCasparCg.ROUTE) { - if (content.mappedLayer) { - const routeMapping = mappings[content.mappedLayer] as Mapping - if (routeMapping && routeMapping.deviceId === this.deviceId) { - content.channel = routeMapping.options.channel - content.layer = routeMapping.options.layer - } - } - stateLayer = literal({ - id: layer.id, - layerNo: mapping.layer, - content: LayerContentType.ROUTE, - media: 'route', - route: { - channel: content.channel || 0, - layer: content.layer, - channelLayout: content.channelLayout, - }, - mode: content.mode || undefined, - delay: content.delay || undefined, - playing: true, - playTime: null, // layer.resolved.startTime || null, - - vfilter: content.videoFilter, - afilter: content.audioFilter, - }) - } else if (content.type === TimelineContentTypeCasparCg.RECORD) { - if (startTime) { - stateLayer = literal({ - id: layer.id, - layerNo: mapping.layer, - content: LayerContentType.RECORD, - media: content.file, - encoderOptions: content.encoderOptions, - playing: true, - playTime: startTime, - }) - } - } - - // if no appropriate layer could be created, make it an empty layer - if (!stateLayer) { - const l: EmptyLayer = { - id: layer.id, - layerNo: mapping.layer, - content: LayerContentType.NOTHING, - playing: false, - } - stateLayer = l - } // now it holds that stateLayer is truthy - - const baseContent = content as TimelineContentCCGProducerBase - if (baseContent.transitions) { - // add transitions to the layer obj - switch (baseContent.type) { - case TimelineContentTypeCasparCg.MEDIA: - case TimelineContentTypeCasparCg.IP: - case TimelineContentTypeCasparCg.TEMPLATE: - case TimelineContentTypeCasparCg.INPUT: - case TimelineContentTypeCasparCg.ROUTE: - case TimelineContentTypeCasparCg.HTMLPAGE: { - // create transition object - const media = stateLayer.media - const transitions = {} as any - if (baseContent.transitions.inTransition) { - transitions.inTransition = new StateTransition(baseContent.transitions.inTransition) - } - if (baseContent.transitions.outTransition) { - transitions.outTransition = new StateTransition(baseContent.transitions.outTransition) - } - // todo - not a fan of this type assertion but think it's ok - stateLayer.media = new TransitionObject(media as string, { - inTransition: transitions.inTransition, - outTransition: transitions.outTransition, - }) - break - } - default: - // create transition using mixer - break - } - } - if ('mixer' in content && content.mixer) { - // add mixer properties - // just pass through values here: - const mixer: Mixer = {} - _.each(content.mixer, (value, property) => { - mixer[property] = value - }) - stateLayer.mixer = mixer - } - - stateLayer.layerNo = mapping.layer - return stateLayer + return Promise.resolve(true) // This device doesn't have any initialization procedure } - - /** - * Takes a timeline state and returns a CasparCG State that will work with the state lib. - * @param timelineState The timeline state to generate from. - */ - convertStateToCaspar(timelineState: Timeline.TimelineState, mappings: Mappings): State { - const caspar: State = { - channels: {}, - } - - _.each(mappings, (foundMapping, layerName) => { - if ( - foundMapping && - foundMapping.device === DeviceType.CASPARCG && - foundMapping.deviceId === this.deviceId && - _.has(foundMapping.options, 'channel') && - _.has(foundMapping.options, 'layer') - ) { - const mapping = foundMapping as Mapping - mapping.options.channel = Number(mapping.options.channel) || 1 - mapping.options.layer = Number(mapping.options.layer) || 0 - - // create a channel in state if necessary, or reuse existing channel - const channel = caspar.channels[mapping.options.channel] || { channelNo: mapping.options.channel, layers: {} } - channel.channelNo = mapping.options.channel - channel.fps = this.initOptions ? this.initOptions.fps || 25 : 25 - caspar.channels[channel.channelNo] = channel - - let foregroundObj: ResolvedTimelineObjectInstanceExtended | undefined = timelineState.layers[layerName] - let backgroundObj = _.last( - _.filter(timelineState.layers, (obj) => { - // Takes the last one, to be consistent with previous behaviour - const objExt: ResolvedTimelineObjectInstanceExtended = obj - return !!objExt.isLookahead && objExt.lookaheadForLayer === layerName - }) - ) - - // If lookahead is on the same layer, then ensure objects are treated as such - if (foregroundObj && foregroundObj.isLookahead) { - backgroundObj = foregroundObj - foregroundObj = undefined - } - - // create layer of appropriate type - const foregroundStateLayer = foregroundObj - ? this.convertObjectToCasparState(mappings, foregroundObj, mapping.options, true) - : undefined - const backgroundStateLayer = backgroundObj - ? this.convertObjectToCasparState(mappings, backgroundObj, mapping.options, false) - : undefined - - debug( - `${layerName} (${mapping.options.channel}-${mapping.options.layer}): FG keys: ${Object.entries( - foregroundStateLayer || {} - ) - .map((e) => e[0] + ': ' + e[1]) - .join(', ')}` - ) - debug( - `${layerName} (${mapping.options.channel}-${mapping.options.layer}): BG keys: ${Object.entries( - backgroundStateLayer || {} - ) - .map((e) => e[0] + ': ' + e[1]) - .join(', ')}` - ) - - const merge = >(o1: T, o2: T) => { - const o = { - ...o1, - } - Object.entries(o2).forEach(([key, value]) => { - if (value !== undefined) { - o[key as keyof T] = value - } - }) - return o - } - - if (foregroundStateLayer) { - const currentTemplateData = (channel.layers[mapping.options.layer] as any as TemplateLayer | undefined) - ?.templateData - const foregroundTemplateData = (foregroundStateLayer as any as TemplateLayer | undefined)?.templateData - channel.layers[mapping.options.layer] = merge(channel.layers[mapping.options.layer], { - ...foregroundStateLayer, - ...(_.isObject(currentTemplateData) && _.isObject(foregroundTemplateData) - ? { templateData: deepMerge(currentTemplateData, foregroundTemplateData) } - : {}), - nextUp: backgroundStateLayer - ? merge( - (channel.layers[mapping.options.layer] || {}).nextUp!, - literal({ - ...(backgroundStateLayer as NextUp), - auto: false, - }) - ) - : undefined, - }) - } else if (backgroundStateLayer) { - if (mapping.options.previewWhenNotOnAir) { - channel.layers[mapping.options.layer] = merge(channel.layers[mapping.options.layer], { - ...channel.layers[mapping.options.layer], - ...backgroundStateLayer, - playing: false, - }) - } else { - channel.layers[mapping.options.layer] = merge( - channel.layers[mapping.options.layer], - literal({ - id: `${backgroundStateLayer.id}_empty_base`, - layerNo: mapping.options.layer, - content: LayerContentType.NOTHING, - playing: false, - nextUp: literal({ - ...(backgroundStateLayer as NextUp), - auto: false, - }), - }) - ) - } - } - } - }) - - return caspar - } - - /** - * Prepares the physical device for playout. If amcp scheduling is used this - * tries to sync the timecode. If {@code okToDestroyStuff === true} this clears - * all channels and resets our states. - * @param okToDestroyStuff Whether it is OK to restart the device - */ - async makeReady(okToDestroyStuff?: boolean): Promise { - // reset our own state(s): - if (okToDestroyStuff) { - await this.clearAllChannels() - } - } - - private async clearAllChannels(): Promise { - if (!this._ccg.connected) { - return { - result: ActionExecutionResultCode.Error, - response: t('Cannot restart CasparCG without a connection'), - } - } - - const { error, request } = await this._ccg.executeCommand({ command: Commands.Info, params: {} }) - if (error) { - return { result: ActionExecutionResultCode.Error } - } - const response = await request - if (!response?.data[0]) { - return { result: ActionExecutionResultCode.Error } - } - - await Promise.all( - response.data.map(async (_, i) => { - await this._commandReceiver( - this.getCurrentTime(), - literal({ - command: Commands.Clear, - params: { - channel: i + 1, - }, - }), - 'clearAllChannels', - '' - ) - }) - ) - - this.clearStates() - this._currentState = { channels: {} } - response.data.forEach((obj) => { - this._currentState.channels[obj.channel] = { - channelNo: obj.channel, - videoMode: obj.format.toUpperCase(), - fps: obj.frameRate, - layers: {}, - } - }) - - this.emit('resetResolver') - - return { - result: ActionExecutionResultCode.Ok, - } - } - - async executeAction(id: CasparCGActions): Promise { - switch (id) { - case CasparCGActions.ClearAllChannels: - return this.clearAllChannels() - case CasparCGActions.RestartServer: - return this.restartCasparCG() - default: - return { - result: ActionExecutionResultCode.Error, - response: t('Action "{{id}}" not found', { id }), - } - } - } - - /** - * Attemps to restart casparcg over the HTTP API provided by CasparCG launcher. - */ - private async restartCasparCG(): Promise { - if (!this.initOptions) { - return { result: ActionExecutionResultCode.Error, response: t('CasparCGDevice._connectionOptions is not set!') } - } - if (!this.initOptions.launcherHost) { - return { result: ActionExecutionResultCode.Error, response: t('CasparCGDevice: config.launcherHost is not set!') } - } - if (!this.initOptions.launcherPort) { - return { result: ActionExecutionResultCode.Error, response: t('CasparCGDevice: config.launcherPort is not set!') } - } - if (!this.initOptions.launcherProcess) { - return { - result: ActionExecutionResultCode.Error, - response: t('CasparCGDevice: config.launcherProcess is not set!'), - } - } - - const url = `http://${this.initOptions?.launcherHost}:${this.initOptions?.launcherPort}/processes/${this.initOptions?.launcherProcess}/restart` - return got - .post(url, { - timeout: { - request: 5000, // Arbitary, long enough for realistic scenarios - }, - }) - .then((response) => { - if (response.statusCode === 200) { - return { result: ActionExecutionResultCode.Ok } - } else { - return { - result: ActionExecutionResultCode.Error, - response: t('Bad reply: [{{statusCode}}] {{body}}', { - statusCode: response.statusCode, - body: response.body, - }), - } - } - }) - .catch((error) => { - return { - result: ActionExecutionResultCode.Error, - response: t('{{message}}', { - message: error.toString(), - }), - } - }) + async terminate(): Promise { + this._connection.discard() } - getStatus(): DeviceStatus { - let statusCode = StatusCode.GOOD - const messages: Array = [] - - if (statusCode === StatusCode.GOOD) { - if (!this._connected) { - statusCode = StatusCode.BAD - messages.push(`CasparCG disconnected`) - } - } - if (this._queueOverflow) { - statusCode = StatusCode.BAD - messages.push('Command queue overflow: CasparCG server has to be restarted') - } - - return { - statusCode: statusCode, - messages: messages, - active: this.isActive, - } + convertTimelineStateToDeviceState( + state: Timeline.TimelineState, + newMappings: Mappings + ): CasparCGDeviceState { + return convertTimelineStateToAddressStates(state, newMappings) } - /** - * Use either AMCP Command Scheduling or the doOnTime to execute commands at - * {@code time}. - * @param commandsToAchieveState Commands to be added to queue - * @param time Point in time to send commands at - */ - private _addToQueue(commandsToAchieveState: Array, time: number) { - _.each(commandsToAchieveState, (cmd: AMCPCommandWithContext) => { - this._doOnTime.queue( - time, - undefined, - async (c: { command: AMCPCommand; cmd: AMCPCommandWithContext }) => { - return this._commandReceiver(time, c.command, c.cmd.context.context, c.cmd.context.layerId) - }, - { command: { command: cmd.command, params: cmd.params }, cmd: cmd } - ) - }) + diffStates(oldState: CasparCGDeviceState | undefined, newState: CasparCGDeviceState): Array { + return diffStates(oldState, newState, this._options?.fps || 25) } - /** - * Sends a command over a casparcg-connection instance - * @param time deprecated - * @param cmd Command to execute - */ - private async _defaultCommandReceiver( - time: number, - cmd: AMCPCommand, - context: string, - timelineObjId: string - ): Promise { - // do no retry while we are sending commands, instead always retry closely after: - if (!context.match(/\[RETRY\]/i)) { - clearTimeout(this._retryTimeout) - if (this._retryTime) this._retryTimeout = setTimeout(() => this._assertIntendedState(), this._retryTime) - } + async sendCommand(cwc: CasparCGCommand): Promise { + const command = cwc.command as AMCPCommand + this.emit('debug', cwc) + debug(command) - const cwc: CommandWithContext = { - context, - timelineObjId, - command: JSON.stringify(cmd), - } - this.emitDebug(cwc) + if (!this._connection.connected) return - const { request, error } = await this._ccg.executeCommand(cmd) + const { request, error } = await this._connection.executeCommand(command) if (error) { this.emit('commandError', error, cwc) } @@ -801,25 +134,23 @@ export class CasparCGDevice extends DeviceWithState= 400) { // this is an error code: - let errorString = `${response.responseCode} ${cmd.command} ${response.type}: ${response.type}` + let errorString = `${response.responseCode} ${command.command} ${response.type}: ${response.message}` - if (Object.keys(cmd.params).length) { - errorString += ' ' + JSON.stringify(cmd.params) + if (Object.keys(command.params).length) { + errorString += ' ' + JSON.stringify(command.params) } this.emit('commandError', new Error(errorString), cwc) @@ -830,130 +161,37 @@ export class CasparCGDevice extends DeviceWithState fg => nextUp cleared - confirmedChannelState.layers[command.params.layer] = { - ...expectedChannelState.layers[command.params.layer], - nextUp: undefined, // auto next + stop means bg -> fg => nextUp cleared - } - } else { - // stop does not affect nextup - confirmedChannelState.layers[command.params.layer] = { - ...expectedChannelState.layers[command.params.layer], - nextUp: confirmedChannelState.layers[command.params.layer]?.nextUp, - } - } - break - case Commands.Resume: - // resume does not affect nextup - confirmedChannelState.layers[command.params.layer] = { - ...expectedChannelState.layers[command.params.layer], - nextUp: confirmedChannelState.layers[command.params.layer]?.nextUp, - } - break - case Commands.Clear: - // Remove both the background and foreground - delete confirmedChannelState.layers[command.params.layer] - break - default: { - // Never hit - // const _a: never = command.params.name - break - } - } - } - } - } + get connected(): boolean { + return this._connection.connected } - - /** - * This function takes the current timeline-state, and diffs it with the known - * CasparCG state. If any media has failed to load, it will create a diff with - * the intended (timeline) state and that command will be executed. - */ - private _assertIntendedState() { - if (this._retryTime) { - this._retryTimeout = setTimeout(() => this._assertIntendedState(), this._retryTime) + getStatus(): Omit { + const status = { + statusCode: StatusCode.GOOD, + messages: [] as string[], } - const tlState = this.getState(this.getCurrentTime()) - - if (!tlState) return // no state implies any state is correct - - const ccgState = tlState.state - - const diff = CasparCGState.diffStates(this._currentState, ccgState, this.getCurrentTime()) - - const cmd: Array = [] - for (const layer of diff) { - // filter out media commands - for (let i = 0; i < layer.cmds.length; i++) { - if ( - // todo - shall we pass decklinks etc. as well? - layer.cmds[i].command === Commands.Loadbg || - layer.cmds[i].command === Commands.Load || - (layer.cmds[i].command === Commands.Play && 'clip' in (layer.cmds[i].params as Record)) - ) { - layer.cmds[i].context.context += ' [RETRY]' - cmd.push(layer.cmds[i]) - } - } + if (this._queueOverflow) { + status.statusCode = StatusCode.BAD + status.messages.push('Command queue overflow: CasparCG server has to be restarted') } - if (cmd.length > 0) { - this._addToQueue(cmd, this.getCurrentTime()) + if (!this._connection.connected) { + status.statusCode = StatusCode.BAD } + + return status } - private _connectionChanged() { - this.emit('connectionChanged', this.getStatus()) + actions: Record< + CasparCGActions, + (id: CasparCGActions, payload: Record) => Promise + > = { + [CasparCGActions.ClearAllChannels]: async () => { + // return clearAllChannels(this._connection, () => this.emit('resetFromState', { layers: {}, lookaheads: {} })) + return clearAllChannels(this._connection, () => this.emit('resetResolver')) // todo use correct reset + }, + [CasparCGActions.RestartServer]: async () => { + return restartServer(this._options) + }, } } diff --git a/packages/timeline-state-resolver/src/integrations/casparCG/state.ts b/packages/timeline-state-resolver/src/integrations/casparCG/state.ts new file mode 100644 index 000000000..3c897e8c3 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/casparCG/state.ts @@ -0,0 +1,297 @@ +import { + EmptyLayer, + HtmlPageLayer, + InputLayer, + Layer, + LayerContentType, + MediaLayer, + RecordLayer, + RouteLayer, + TemplateLayer, + TransitionObject, + Transition as StateTransition, + Mixer, + NextUp, +} from 'casparcg-state' +import deepmerge = require('deepmerge') +import { + DeviceType, + Mapping, + MappingCasparCGLayer, + Mappings, + ResolvedTimelineObjectInstanceExtended, + SomeMappingCasparCG, + TSRTimelineContent, + TSRTimelineObjProps, + Timeline, + TimelineContentCCGProducerBase, + TimelineContentCasparCGAny, + TimelineContentTypeCasparCg, +} from 'timeline-state-resolver-types' +import { literal } from '../../devices/device' + +export interface TrackedLayer { + layer: Layer | undefined + lookahead: NextUp | undefined + time: number +} + +export type CasparCGDeviceState = { [address: string]: TrackedLayer } + +export function convertTimelineStateToAddressStates( + state: Timeline.TimelineState, + newMappings: Mappings +): CasparCGDeviceState { + const deviceState: CasparCGDeviceState = {} + + for (const layer of Object.values>(state.layers)) { + const lookaheadLayer = isLookaheadLayer(layer) + const mapping = newMappings[lookaheadLayer ?? layer.layer] + if (!isValidCasparCGMapping(mapping)) continue + + const address = mappingToAddress(mapping) + const layerState = convertObjectToCasparState(newMappings, layer, mapping.options, true) + + const addressState: TrackedLayer = deviceState[address] ?? { + layer: undefined, + lookahead: undefined, + time: state.time, + } + + if (lookaheadLayer) { + const old = addressState.lookahead + addressState.lookahead = literal({ ...(old ?? {}), ...(layerState as NextUp) }) + } else { + const old: Layer | undefined = addressState.layer + addressState.layer = { ...(old ?? {}), ...(layerState as Layer) } + + // deep merge template data + if ( + old?.content === LayerContentType.TEMPLATE && + old?.templateData && + typeof old?.templateData !== 'string' && + layerState.content === LayerContentType.TEMPLATE && + layerState.templateData && + typeof layerState.templateData !== 'string' + ) { + ;(deviceState.layers[address] as TemplateLayer).templateData = deepmerge( + old.templateData, + layerState.templateData + ) + } + } + + deviceState[address] = addressState + } + + return deviceState +} + +function isLookaheadLayer( + layer: Timeline.ResolvedTimelineObjectInstance +): string | number | undefined { + const l: ResolvedTimelineObjectInstanceExtended = layer + + return l.isLookahead ? l.lookaheadForLayer : undefined +} + +function isValidCasparCGMapping(mapping: Mapping): mapping is Mapping { + return !!mapping && mapping.device === DeviceType.CASPARCG +} + +export function mappingToAddress(mapping: Mapping): string { + return mapping.options.channel + '-' + mapping.options.layer +} + +function convertObjectToCasparState( + mappings: Mappings, + layer: Timeline.ResolvedTimelineObjectInstance, + mapping: MappingCasparCGLayer, + isForeground: boolean +): Layer | NextUp { + let startTime = layer.instance.originalStart || layer.instance.start + if (startTime === 0) startTime = 1 // @todo: startTime === 0 will make ccg-state seek to the current time + + const layerProps = layer as Timeline.ResolvedTimelineObjectInstance & TSRTimelineObjProps + const content = layer.content as TimelineContentCasparCGAny + + let stateLayer: Layer | null = null + if (content.type === TimelineContentTypeCasparCg.MEDIA) { + const holdOnFirstFrame = !isForeground || layerProps.isLookahead + const loopingPlayTime = content.loop && !content.seek && !content.inPoint && !content.length + + stateLayer = literal({ + id: layer.id, + layerNo: mapping.layer, + content: LayerContentType.MEDIA, + media: content.file, + playTime: !holdOnFirstFrame && (content.noStarttime || loopingPlayTime) ? null : startTime, + + pauseTime: holdOnFirstFrame ? startTime : content.pauseTime || null, + playing: !layerProps.isLookahead && (content.playing !== undefined ? content.playing : isForeground), + + looping: content.loop, + seek: content.seek, + inPoint: content.inPoint, + length: content.length, + + channelLayout: content.channelLayout, + clearOn404: true, + + vfilter: content.videoFilter, + afilter: content.audioFilter, + }) + // this.emitDebug(stateLayer) + } else if (content.type === TimelineContentTypeCasparCg.IP) { + stateLayer = literal({ + id: layer.id, + layerNo: mapping.layer, + content: LayerContentType.MEDIA, + media: content.uri, + channelLayout: content.channelLayout, + playTime: null, // ip inputs can't be seeked // layer.resolved.startTime || null, + playing: true, + seek: 0, // ip inputs can't be seeked + + vfilter: content.videoFilter, + afilter: content.audioFilter, + }) + } else if (content.type === TimelineContentTypeCasparCg.INPUT) { + stateLayer = literal({ + id: layer.id, + layerNo: mapping.layer, + content: LayerContentType.INPUT, + media: 'decklink', + input: { + device: content.device, + channelLayout: content.channelLayout, + format: content.deviceFormat, + }, + playing: true, + playTime: null, + + vfilter: content.videoFilter || content.filter, + afilter: content.audioFilter, + }) + } else if (content.type === TimelineContentTypeCasparCg.TEMPLATE) { + stateLayer = literal({ + id: layer.id, + layerNo: mapping.layer, + content: LayerContentType.TEMPLATE, + media: content.name, + + playTime: startTime || null, + playing: true, + + templateType: content.templateType || 'html', + templateData: content.data, + cgStop: content.useStopCommand, + }) + } else if (content.type === TimelineContentTypeCasparCg.HTMLPAGE) { + stateLayer = literal({ + id: layer.id, + layerNo: mapping.layer, + content: LayerContentType.HTMLPAGE, + media: content.url, + + playTime: startTime || null, + playing: true, + }) + } else if (content.type === TimelineContentTypeCasparCg.ROUTE) { + if (content.mappedLayer) { + const routeMapping = mappings[content.mappedLayer] as Mapping + if (routeMapping) { + content.channel = routeMapping.options.channel + content.layer = routeMapping.options.layer + } + } + stateLayer = literal({ + id: layer.id, + layerNo: mapping.layer, + content: LayerContentType.ROUTE, + media: 'route', + route: { + channel: content.channel || 0, + layer: content.layer, + channelLayout: content.channelLayout, + }, + mode: content.mode || undefined, + delay: content.delay || undefined, + playing: true, + playTime: null, // layer.resolved.startTime || null, + + vfilter: content.videoFilter, + afilter: content.audioFilter, + }) + } else if (content.type === TimelineContentTypeCasparCg.RECORD) { + if (startTime) { + stateLayer = literal({ + id: layer.id, + layerNo: mapping.layer, + content: LayerContentType.RECORD, + media: content.file, + encoderOptions: content.encoderOptions, + playing: true, + playTime: startTime, + }) + } + } + + // if no appropriate layer could be created, make it an empty layer + if (!stateLayer) { + const l: EmptyLayer = { + id: layer.id, + layerNo: mapping.layer, + content: LayerContentType.NOTHING, + playing: false, + } + stateLayer = l + } // now it holds that stateLayer is truthy + + const baseContent = content as TimelineContentCCGProducerBase + if (baseContent.transitions) { + // add transitions to the layer obj + switch (baseContent.type) { + case TimelineContentTypeCasparCg.MEDIA: + case TimelineContentTypeCasparCg.IP: + case TimelineContentTypeCasparCg.TEMPLATE: + case TimelineContentTypeCasparCg.INPUT: + case TimelineContentTypeCasparCg.ROUTE: + case TimelineContentTypeCasparCg.HTMLPAGE: { + // create transition object + const media = stateLayer.media + const transitions = {} as any + if (baseContent.transitions.inTransition) { + transitions.inTransition = new StateTransition(baseContent.transitions.inTransition) + } + if (baseContent.transitions.outTransition) { + transitions.outTransition = new StateTransition(baseContent.transitions.outTransition) + } + // todo - not a fan of this type assertion but think it's ok + stateLayer.media = new TransitionObject(media as string, { + inTransition: transitions.inTransition, + outTransition: transitions.outTransition, + }) + break + } + default: + // create transition using mixer + break + } + } + if ('mixer' in content && content.mixer) { + // add mixer properties + // just pass through values here: + const mixer: Mixer = {} + // the mixer object is apparently too complex so we lose typings here, at the same time we cannot just reassign because the types are subtly different + // an alternative would be to add _transition to the tsr types, but that forces the tsr user to change property. + // or casparcg-state should remove the _transition property since it is unused anyway? + for (const [k, v] of Object.entries(content.mixer)) { + mixer[k] = v + } + stateLayer.mixer = mixer + } + + stateLayer.layerNo = mapping.layer + return stateLayer +} diff --git a/packages/timeline-state-resolver/src/service/device.ts b/packages/timeline-state-resolver/src/service/device.ts index b283310fb..51f8814d1 100644 --- a/packages/timeline-state-resolver/src/service/device.ts +++ b/packages/timeline-state-resolver/src/service/device.ts @@ -69,7 +69,7 @@ export abstract class Device { /** * This method takes in a Timeline State that describes a point - * in time on the timeline and returns a decice state that + * in time on the timeline and returns a device state that * describes how the device should be according to the timeline state * * @param state State obj from timeline diff --git a/packages/timeline-state-resolver/src/service/devices.ts b/packages/timeline-state-resolver/src/service/devices.ts index 0249545d8..4186bb375 100644 --- a/packages/timeline-state-resolver/src/service/devices.ts +++ b/packages/timeline-state-resolver/src/service/devices.ts @@ -6,6 +6,7 @@ import { ShotokuDevice } from '../integrations/shotoku' import { HTTPWatcherDevice } from '../integrations/httpWatcher' import { AbstractDevice } from '../integrations/abstract' import { TcpSendDevice } from '../integrations/tcpSend' +import { CasparCGDevice } from '../integrations/casparCG' export interface DeviceEntry { deviceClass: new (context: DeviceContextAPI) => Device @@ -16,6 +17,7 @@ export interface DeviceEntry { export type ImplementedServiceDeviceTypes = | DeviceType.ABSTRACT + | DeviceType.CASPARCG | DeviceType.HTTPSEND | DeviceType.HTTPWATCHER | DeviceType.OSC @@ -30,6 +32,12 @@ export const DevicesDict: Record = { deviceName: (deviceId: string) => 'Abstract ' + deviceId, executionMode: () => 'salvo', }, + [DeviceType.CASPARCG]: { + deviceClass: CasparCGDevice, + canConnect: true, + deviceName: (deviceId: string) => 'CasparCG ' + deviceId, + executionMode: () => 'salvo', + }, [DeviceType.HTTPSEND]: { deviceClass: HTTPSendDevice, canConnect: false, From 2817b12e8a04d6cc40b351574690a829b07fc679 Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Wed, 11 Oct 2023 09:40:37 +0200 Subject: [PATCH 2/2] chore: changes after rebase --- .../casparCG/__tests__/casparcg.spec.ts | 13 ++--- .../src/integrations/casparCG/actions.ts | 49 +++++++++++-------- .../src/integrations/casparCG/diff.ts | 6 +-- .../src/integrations/casparCG/index.ts | 38 +++++++------- 4 files changed, 55 insertions(+), 51 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/casparCG/__tests__/casparcg.spec.ts b/packages/timeline-state-resolver/src/integrations/casparCG/__tests__/casparcg.spec.ts index 80175f53b..443f1e1ea 100644 --- a/packages/timeline-state-resolver/src/integrations/casparCG/__tests__/casparcg.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/casparCG/__tests__/casparcg.spec.ts @@ -14,9 +14,10 @@ import { CasparCGDeviceState } from '../state' import { CasparCGCommand } from '../diff' import { Commands } from 'casparcg-connection' import { Layer, LayerContentType } from 'casparcg-state' +import { getDeviceContext } from '../../../integrations/__tests__/testlib' async function getInitialisedOscDevice() { - const dev = new CasparCGDevice() + const dev = new CasparCGDevice(getDeviceContext()) await dev.init({ host: 'localhost', port: 8082 }) return dev } @@ -169,7 +170,7 @@ describe('CasparCG Device', () => { context: { layerId: '1-10_empty_base', context: 'Nextup media (amb)' }, }, context: 'Nextup media (amb)', - tlObjId: '1-10_empty_base', // note - this makes no sense but is an issue in casparcg-state + timelineObjId: '1-10_empty_base', // note - this makes no sense but is an issue in casparcg-state }, ] ) @@ -192,7 +193,7 @@ describe('CasparCG Device', () => { context: { layerId: 'obj0', context: 'VFilter diff ("undefined", "undefined") (content: media!=)' }, }, context: 'VFilter diff ("undefined", "undefined") (content: media!=)', - tlObjId: 'obj0', + timelineObjId: 'obj0', }, ] ) @@ -215,7 +216,7 @@ describe('CasparCG Device', () => { context: { layerId: 'obj0', context: 'No new content ()' }, }, context: 'No new content ()', - tlObjId: 'obj0', + timelineObjId: 'obj0', }, { command: { @@ -224,7 +225,7 @@ describe('CasparCG Device', () => { context: { layerId: 'obj0', context: 'Clear old stuff' }, }, context: 'Clear old stuff', - tlObjId: 'obj0', + timelineObjId: 'obj0', }, ] ) @@ -252,7 +253,7 @@ describe('CasparCG Device', () => { .sendCommand({ command, context: '', - tlObjId: '', + timelineObjId: '', }) .catch((e) => { throw e diff --git a/packages/timeline-state-resolver/src/integrations/casparCG/actions.ts b/packages/timeline-state-resolver/src/integrations/casparCG/actions.ts index 659748828..54719d23d 100644 --- a/packages/timeline-state-resolver/src/integrations/casparCG/actions.ts +++ b/packages/timeline-state-resolver/src/integrations/casparCG/actions.ts @@ -1,6 +1,6 @@ import got from 'got' import { t } from '../../lib' -import { ActionExecutionResultCode, ActionExecutionResult, CasparCGOptions } from 'timeline-state-resolver-types' +import { ActionExecutionResultCode, CasparCGOptions } from 'timeline-state-resolver-types' import { CasparCG } from 'casparcg-connection' export async function clearAllChannels(connection: CasparCG, resetCb: () => void) { @@ -55,25 +55,32 @@ export async function restartServer(options: CasparCGOptions | undefined) { } } - return new Promise((resolve) => { - const url = `http://${options.launcherHost}:${options.launcherPort}/processes/${options.launcherProcess}/restart` - got - .post(url) - .then((response) => { - if (response.statusCode === 200) { - resolve({ result: ActionExecutionResultCode.Ok }) - } else { - resolve({ - result: ActionExecutionResultCode.Error, - response: t('Bad reply: [{{statusCode}}] {{body}}', { - statusCode: response.statusCode, - body: response.body, - }), - }) + const url = `http://${options.launcherHost}:${options.launcherPort}/processes/${options.launcherProcess}/restart` + return got + .post(url, { + timeout: { + request: 5000, // Arbitrary, long enough for realistic scenarios + }, + }) + .then((response) => { + if (response.statusCode === 200) { + return { result: ActionExecutionResultCode.Ok } + } else { + return { + result: ActionExecutionResultCode.Error, + response: t('Bad reply: [{{statusCode}}] {{body}}', { + statusCode: response.statusCode, + body: response.body, + }), } - }) - .catch((error) => { - resolve({ result: ActionExecutionResultCode.Error, response: error }) - }) - }) + } + }) + .catch((error) => { + return { + result: ActionExecutionResultCode.Error, + response: t('{{message}}', { + message: error.toString(), + }), + } + }) } diff --git a/packages/timeline-state-resolver/src/integrations/casparCG/diff.ts b/packages/timeline-state-resolver/src/integrations/casparCG/diff.ts index ab07722b4..53d874887 100644 --- a/packages/timeline-state-resolver/src/integrations/casparCG/diff.ts +++ b/packages/timeline-state-resolver/src/integrations/casparCG/diff.ts @@ -88,14 +88,14 @@ function diffTrackerStatesLayer( return commands.map((c) => ({ command: c, context: c.context.context, - tlObjId: c.context.layerId, + timelineObjId: c.context.layerId, })) } function getEmptyLayer(addr: string, mapping: Mapping) { return literal({ id: `${addr}_empty_base`, - layerNo: mapping.options.layer, + layerNo: Number(mapping.options.layer), content: LayerContentType.NOTHING, playing: false, }) @@ -104,7 +104,7 @@ function getInternalStateFromLayer(layer: Layer, mapping: Mapping({ channels: { [mapping.options.channel]: { - channelNo: mapping.options.channel, + channelNo: Number(mapping.options.channel), videoMode: 'PAL', // hardcoded but doesn't look like ccg-state is using it anyway fps: fps, layers: { diff --git a/packages/timeline-state-resolver/src/integrations/casparCG/index.ts b/packages/timeline-state-resolver/src/integrations/casparCG/index.ts index 51d35c396..164f634a9 100644 --- a/packages/timeline-state-resolver/src/integrations/casparCG/index.ts +++ b/packages/timeline-state-resolver/src/integrations/casparCG/index.ts @@ -8,20 +8,16 @@ import { Timeline, TSRTimelineContent, } from 'timeline-state-resolver-types' -import { Device, DeviceEvents } from '../../service/device' +import { Device } from '../../service/device' import Debug from 'debug' -import EventEmitter = require('eventemitter3') import { AMCPCommand, CasparCG, Commands, Response } from 'casparcg-connection' import { CasparCGDeviceState, convertTimelineStateToAddressStates } from './state' import { CasparCGCommand, diffStates } from './diff' import { clearAllChannels, restartServer } from './actions' const debug = Debug('timeline-state-resolver:casparcg') -export class CasparCGDevice - extends EventEmitter - implements Device -{ +export class CasparCGDevice extends Device { private _connection: CasparCG private _options: CasparCGOptions | undefined private _queueOverflow = false @@ -37,7 +33,7 @@ export class CasparCGDevice this._connection.connect() this._connection.on('connect', () => { - this.emit('connectionChanged', this.getStatus()) + this.context.connectionChanged(this.getStatus()) // do a virgin check Promise.resolve() @@ -76,32 +72,32 @@ export class CasparCGDevice return !channelResults.find((ch) => ch.data['stage']) }) .catch((e) => { - this.emit('error', 'connect virgin check failed', e) + this.context.logger.error('connect virgin check failed', e) // Something failed, force the resync as glitching playback is better than black output return true }) .then((doResync) => { // Finally we can report it as connected - this.emit('connectionChanged', this.getStatus()) + this.context.connectionChanged(this.getStatus()) if (doResync) { // this.emit('resetFromState', { layers: {}, lookaheads: {} }) - this.emit('resetResolver') // todo use correct reset + this.context.resetResolver() // todo - use the correct reset } }) .catch((e) => { - this.emit('error', 'connect state resync failed', e) + this.context.logger.error('connect state resync failed', e) // Some unknown error occurred, report the connection as failed - this.emit('connectionChanged', this.getStatus()) + this.context.connectionChanged(this.getStatus()) }) }) this._connection.on('disconnect', () => { - this.emit('connectionChanged', this.getStatus()) + this.context.connectionChanged(this.getStatus()) }) this._connection.on('error', (e) => { - this.emit('error', 'Error in casparcg-connection', e) + this.context.logger.error('Error in casparcg-connection', e) }) return Promise.resolve(true) // This device doesn't have any initialization procedure @@ -121,14 +117,14 @@ export class CasparCGDevice } async sendCommand(cwc: CasparCGCommand): Promise { const command = cwc.command as AMCPCommand - this.emit('debug', cwc) + this.context.logger.debug(cwc) debug(command) if (!this._connection.connected) return const { request, error } = await this._connection.executeCommand(command) if (error) { - this.emit('commandError', error, cwc) + this.context.commandError(error, cwc) } try { @@ -139,10 +135,10 @@ export class CasparCGDevice if (response.responseCode === 504 && !this._queueOverflow) { this._queueOverflow = true - this.emit('connectionChanged', this.getStatus()) + this.context.connectionChanged(this.getStatus()) } else if (this._queueOverflow) { this._queueOverflow = false - this.emit('connectionChanged', this.getStatus()) + this.context.connectionChanged(this.getStatus()) } if (response.responseCode >= 400) { @@ -153,11 +149,11 @@ export class CasparCGDevice errorString += ' ' + JSON.stringify(command.params) } - this.emit('commandError', new Error(errorString), cwc) + this.context.commandError(new Error(errorString), cwc) } } catch (e) { // This shouldn't really happen - this.emit('commandError', Error('Command not sent: ' + e), cwc) + this.context.commandError(Error('Command not sent: ' + e), cwc) } } @@ -188,7 +184,7 @@ export class CasparCGDevice > = { [CasparCGActions.ClearAllChannels]: async () => { // return clearAllChannels(this._connection, () => this.emit('resetFromState', { layers: {}, lookaheads: {} })) - return clearAllChannels(this._connection, () => this.emit('resetResolver')) // todo use correct reset + return clearAllChannels(this._connection, () => this.context.resetResolver()) // todo use correct reset }, [CasparCGActions.RestartServer]: async () => { return restartServer(this._options)