From 7dc62cbb4f58347fc2268b595513340faf3d0c0f Mon Sep 17 00:00:00 2001 From: zcybercomputing Date: Tue, 17 Feb 2026 10:15:17 +0000 Subject: [PATCH 1/6] fix: use current time when replaying resync state --- .../src/__tests__/conductor.spec.ts | 61 +++++++++++++++++++ .../timeline-state-resolver/src/conductor.ts | 24 ++++++-- 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/packages/timeline-state-resolver/src/__tests__/conductor.spec.ts b/packages/timeline-state-resolver/src/__tests__/conductor.spec.ts index dcff0246c..91f066ee8 100644 --- a/packages/timeline-state-resolver/src/__tests__/conductor.spec.ts +++ b/packages/timeline-state-resolver/src/__tests__/conductor.spec.ts @@ -583,6 +583,67 @@ describe('Conductor', () => { // } }) + test('resync states uses current time for state before now', async () => { + const myLayerMapping0: Mapping = { + device: DeviceType.ABSTRACT, + deviceId: 'device0', + options: {}, + } + const myLayerMapping: Mappings = { + myLayer0: myLayerMapping0, + } + + const conductor = new Conductor({ + multiThreadedResolver: false, + getCurrentTime: mockTime.getCurrentTime, + }) + + try { + await conductor.init() + await addConnections(conductor.connectionManager, { + device0: { + type: DeviceType.ABSTRACT, + options: {}, + }, + }) + + const device0 = await getMockDeviceWrapper(conductor, 'device0') + device0.handleState.mockImplementation(async () => Promise.resolve()) + + conductor.setTimelineAndMappings( + [ + { + id: 'obj0', + enable: { + start: mockTime.now, + duration: 20000, + }, + layer: 'myLayer0', + content: { + deviceType: DeviceType.ABSTRACT, + foo: 'bar', + }, + }, + ], + myLayerMapping + ) + + await mockTime.advanceTimeTicks(200) + + await mockTime.advanceTimeToTicks(15000) + device0.handleState.mockClear() + + const resyncTime = mockTime.now + ;(conductor as any).resyncDeviceStates('device0') + await mockTime.tick() + + expect(device0.handleState).toHaveBeenCalled() + expect(getMockCall(device0.handleState, 0, 0).time).toEqual(resyncTime) + } finally { + await conductor.destroy() + } + }) + test('estimateResolveTime', () => { // Ensure that the resolveTime follows a certain curve: expect([ diff --git a/packages/timeline-state-resolver/src/conductor.ts b/packages/timeline-state-resolver/src/conductor.ts index eef8eafd3..b1863377c 100644 --- a/packages/timeline-state-resolver/src/conductor.ts +++ b/packages/timeline-state-resolver/src/conductor.ts @@ -765,6 +765,16 @@ export class Conductor extends EventEmitter { return this.connectionManager.getConnection(deviceId)?.device.handleState(filledState, mappings) } + private _getReplayStateWithCurrentTime(state: DeviceState, now: number): Timeline.TimelineState { + const filledState = fillStateFromDatastore(state.state, this._datastore) + + if (state.time <= now && filledState.time < now) { + filledState.time = now + } + + return filledState + } + setDatastore(newStore: Datastore) { this._actionQueue .add(() => { @@ -785,14 +795,15 @@ export class Conductor extends EventEmitter { this._datastore = newStore for (const deviceId of affectedDevices) { + const now = this.getCurrentTime() const toBeFilled = _.compact([ // shallow clone so we don't reverse the array in place - [...this._deviceStates[deviceId]].reverse().find((s) => s.time <= this.getCurrentTime()), // one state before now - ...this._deviceStates[deviceId].filter((s) => s.time > this.getCurrentTime()), // all states after now + [...this._deviceStates[deviceId]].reverse().find((s) => s.time <= now), // one state before now + ...this._deviceStates[deviceId].filter((s) => s.time > now), // all states after now ]) for (const s of toBeFilled) { - const filledState = fillStateFromDatastore(s.state, this._datastore) + const filledState = this._getReplayStateWithCurrentTime(s, now) this.connectionManager .getConnection(deviceId) @@ -809,14 +820,15 @@ export class Conductor extends EventEmitter { private resyncDeviceStates(deviceId: string) { this._actionQueue .add(() => { + const now = this.getCurrentTime() const toBeFilled = _.compact([ // shallow clone so we don't reverse the array in place - [...this._deviceStates[deviceId]].reverse().find((s) => s.time <= this.getCurrentTime()), // one state before now - ...this._deviceStates[deviceId].filter((s) => s.time > this.getCurrentTime()), // all states after now + [...this._deviceStates[deviceId]].reverse().find((s) => s.time <= now), // one state before now + ...this._deviceStates[deviceId].filter((s) => s.time > now), // all states after now ]) for (const s of toBeFilled) { - const filledState = fillStateFromDatastore(s.state, this._datastore) + const filledState = this._getReplayStateWithCurrentTime(s, now) this.connectionManager .getConnection(deviceId) From 0621939fbaf6dd868779fb16e6eff83152cd19bd Mon Sep 17 00:00:00 2001 From: zcybercomputing Date: Tue, 17 Feb 2026 11:13:00 +0000 Subject: [PATCH 2/6] test: satisfy lint and sonar in resync replay regression --- .../timeline-state-resolver/src/__tests__/conductor.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/timeline-state-resolver/src/__tests__/conductor.spec.ts b/packages/timeline-state-resolver/src/__tests__/conductor.spec.ts index 91f066ee8..e2c4c2ee6 100644 --- a/packages/timeline-state-resolver/src/__tests__/conductor.spec.ts +++ b/packages/timeline-state-resolver/src/__tests__/conductor.spec.ts @@ -608,7 +608,8 @@ describe('Conductor', () => { }) const device0 = await getMockDeviceWrapper(conductor, 'device0') - device0.handleState.mockImplementation(async () => Promise.resolve()) + const handleStateMock = device0.handleState as unknown as jest.Mock, [unknown, unknown]> + handleStateMock.mockResolvedValue(undefined) conductor.setTimelineAndMappings( [ @@ -634,7 +635,8 @@ describe('Conductor', () => { device0.handleState.mockClear() const resyncTime = mockTime.now - ;(conductor as any).resyncDeviceStates('device0') + const conductorWithResync = conductor as unknown as { resyncDeviceStates: (deviceId: string) => void } + conductorWithResync.resyncDeviceStates('device0') await mockTime.tick() expect(device0.handleState).toHaveBeenCalled() From 0dfb0167b8cbedf32748a35266da1102c355480f Mon Sep 17 00:00:00 2001 From: zcybercomputing Date: Fri, 20 Feb 2026 20:48:20 +0000 Subject: [PATCH 3/6] fix: port caspar reconnect runtime guards from release51 --- .../timeline-state-resolver/src/conductor.ts | 4 +- .../src/integrations/casparCG/index.ts | 48 +++++++++++++++++-- .../src/integrations/pharos/connection.ts | 8 ++-- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/timeline-state-resolver/src/conductor.ts b/packages/timeline-state-resolver/src/conductor.ts index b1863377c..c41b04016 100644 --- a/packages/timeline-state-resolver/src/conductor.ts +++ b/packages/timeline-state-resolver/src/conductor.ts @@ -536,8 +536,8 @@ export class Conductor extends EventEmitter { _.each(o.objectsFixed, (o) => (nowIdsTime[o.id] = o.time)) const fixNow = (o: TimelineObject) => { if (nowIdsTime[o.id]) { - if (!_.isArray(o.enable)) { - o.enable.start = nowIdsTime[o.id] + if (!_.isArray(o.enable) && typeof o.enable === 'object' && o.enable !== null) { + ;(o.enable as { start?: number }).start = nowIdsTime[o.id] } } } diff --git a/packages/timeline-state-resolver/src/integrations/casparCG/index.ts b/packages/timeline-state-resolver/src/integrations/casparCG/index.ts index f3b439bdc..93b170141 100644 --- a/packages/timeline-state-resolver/src/integrations/casparCG/index.ts +++ b/packages/timeline-state-resolver/src/integrations/casparCG/index.ts @@ -91,6 +91,7 @@ export class CasparCGDevice extends DeviceWithState Promise) { super(deviceId, deviceOptions, getCurrentTime) @@ -131,6 +132,7 @@ export class CasparCGDevice extends DeviceWithState>[] = [] const channelLength: number = response?.data?.['length'] ?? 0 @@ -142,7 +144,7 @@ export class CasparCGDevice extends DeviceWithState, + foregroundTemplateData as Record + ), + } : {}), nextUp: backgroundStateLayer ? merge( @@ -983,6 +995,34 @@ export class CasparCGDevice extends DeviceWithState 0) { + this._detectedChannelFps[entry.channel] = detected + } + } + } + + private getChannelFps(channel?: number): number { + if (this.initOptions?.fps && this.initOptions.fps > 0) return this.initOptions.fps + + if (channel !== undefined) { + const channelFps = this._detectedChannelFps[channel] + if (channelFps && channelFps > 0) return channelFps + } + + const firstDetectedFps = Object.values(this._detectedChannelFps).find((fps) => fps > 0) + if (firstDetectedFps) return firstDetectedFps + + return 25 + } + private getVideMode(info: InfoEntry): string { return `${info.format}${info.interlaced ? 'I' : 'P'}${info.frameRate}` } diff --git a/packages/timeline-state-resolver/src/integrations/pharos/connection.ts b/packages/timeline-state-resolver/src/integrations/pharos/connection.ts index d37e8b180..a0788bd43 100644 --- a/packages/timeline-state-resolver/src/integrations/pharos/connection.ts +++ b/packages/timeline-state-resolver/src/integrations/pharos/connection.ts @@ -316,8 +316,8 @@ export class Pharos extends EventEmitter { public async getTextSlot(names?: string | Array): Promise { const params: any = {} if (names) { - if (!_.isArray(names)) names = [names] - params.names = names.join(',') // TODO: test that this actually works + const namesArray: string[] = Array.isArray(names) ? names : [names] + params.names = namesArray.join(',') // TODO: test that this actually works } return this.request('text_slot', params) } @@ -335,8 +335,8 @@ export class Pharos extends EventEmitter { public async getLuaVariables(vars?: string | Array): Promise { const params: any = {} if (vars) { - if (!_.isArray(vars)) vars = [vars] - params.variables = vars.join(',') + const varsArray: string[] = Array.isArray(vars) ? vars : [vars] + params.variables = varsArray.join(',') } return this.request('lua', params) } From 278f62cf3aad76e14c2601653a87c483cc8d2a3b Mon Sep 17 00:00:00 2001 From: zcybercomputing Date: Fri, 20 Feb 2026 23:39:22 +0000 Subject: [PATCH 4/6] fix(conductor): dedupe replay selection and unify replay time sampling --- .../timeline-state-resolver/src/conductor.ts | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/timeline-state-resolver/src/conductor.ts b/packages/timeline-state-resolver/src/conductor.ts index c41b04016..d80e478da 100644 --- a/packages/timeline-state-resolver/src/conductor.ts +++ b/packages/timeline-state-resolver/src/conductor.ts @@ -775,6 +775,17 @@ export class Conductor extends EventEmitter { return filledState } + private _getReplayStatesForDevice(deviceId: string, now: number): DeviceState[] { + const deviceStates = this._deviceStates[deviceId] + if (!Array.isArray(deviceStates) || deviceStates.length === 0) return [] + + return _.compact([ + // shallow clone so we don't reverse the array in place + [...deviceStates].reverse().find((s) => s.time <= now), // one state before now + ...deviceStates.filter((s) => s.time > now), // all states after now + ]) + } + setDatastore(newStore: Datastore) { this._actionQueue .add(() => { @@ -794,13 +805,10 @@ export class Conductor extends EventEmitter { this._datastore = newStore + const now = this.getCurrentTime() + for (const deviceId of affectedDevices) { - const now = this.getCurrentTime() - const toBeFilled = _.compact([ - // shallow clone so we don't reverse the array in place - [...this._deviceStates[deviceId]].reverse().find((s) => s.time <= now), // one state before now - ...this._deviceStates[deviceId].filter((s) => s.time > now), // all states after now - ]) + const toBeFilled = this._getReplayStatesForDevice(deviceId, now) for (const s of toBeFilled) { const filledState = this._getReplayStateWithCurrentTime(s, now) @@ -821,11 +829,7 @@ export class Conductor extends EventEmitter { this._actionQueue .add(() => { const now = this.getCurrentTime() - const toBeFilled = _.compact([ - // shallow clone so we don't reverse the array in place - [...this._deviceStates[deviceId]].reverse().find((s) => s.time <= now), // one state before now - ...this._deviceStates[deviceId].filter((s) => s.time > now), // all states after now - ]) + const toBeFilled = this._getReplayStatesForDevice(deviceId, now) for (const s of toBeFilled) { const filledState = this._getReplayStateWithCurrentTime(s, now) From ff69e5bc26cafb6a098359a24d8229f58bb230ee Mon Sep 17 00:00:00 2001 From: zcybercomputing Date: Fri, 20 Feb 2026 23:39:34 +0000 Subject: [PATCH 5/6] fix(casparcg): align clearAllChannels fps with detected priority --- .../timeline-state-resolver/src/integrations/casparCG/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/timeline-state-resolver/src/integrations/casparCG/index.ts b/packages/timeline-state-resolver/src/integrations/casparCG/index.ts index 93b170141..490fe4591 100644 --- a/packages/timeline-state-resolver/src/integrations/casparCG/index.ts +++ b/packages/timeline-state-resolver/src/integrations/casparCG/index.ts @@ -644,6 +644,7 @@ export class CasparCGDevice extends DeviceWithState { @@ -667,7 +668,7 @@ export class CasparCGDevice extends DeviceWithState Date: Fri, 20 Feb 2026 23:39:41 +0000 Subject: [PATCH 6/6] test(conductor): extract shared conductor factory in spec --- .../src/__tests__/conductor.spec.ts | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/packages/timeline-state-resolver/src/__tests__/conductor.spec.ts b/packages/timeline-state-resolver/src/__tests__/conductor.spec.ts index e2c4c2ee6..3e090c81c 100644 --- a/packages/timeline-state-resolver/src/__tests__/conductor.spec.ts +++ b/packages/timeline-state-resolver/src/__tests__/conductor.spec.ts @@ -51,6 +51,14 @@ describe('Conductor', () => { return mockDevice } + function createConductor(options: Partial[0]> = {}): Conductor { + return new Conductor({ + multiThreadedResolver: false, + getCurrentTime: mockTime.getCurrentTime, + ...options, + }) + } + test('Abstract-device functionality', async () => { const myLayerMapping0: Mapping = { device: DeviceType.ABSTRACT, @@ -73,10 +81,7 @@ describe('Conductor', () => { ...device1Mappings, } - const conductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) + const conductor = createConductor() try { await conductor.init() @@ -230,10 +235,7 @@ describe('Conductor', () => { myLayer0: myLayerMapping0, } - const conductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) + const conductor = createConductor() try { await conductor.init() @@ -408,10 +410,7 @@ describe('Conductor', () => { myLayer0: myLayerMapping0, } - const conductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) + const conductor = createConductor() conductor.on('error', console.error) try { @@ -450,10 +449,7 @@ describe('Conductor', () => { myLayer0: myLayerMapping0, } - const conductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) + const conductor = createConductor() await conductor.init() await addConnections(conductor.connectionManager, { @@ -593,10 +589,7 @@ describe('Conductor', () => { myLayer0: myLayerMapping0, } - const conductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) + const conductor = createConductor() try { await conductor.init()