Skip to content
88 changes: 72 additions & 16 deletions packages/timeline-state-resolver/src/__tests__/conductor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ describe('Conductor', () => {
return mockDevice
}

function createConductor(options: Partial<ConstructorParameters<typeof Conductor>[0]> = {}): Conductor {
return new Conductor({
multiThreadedResolver: false,
getCurrentTime: mockTime.getCurrentTime,
...options,
})
}

test('Abstract-device functionality', async () => {
const myLayerMapping0: Mapping<SomeMappingAbstract> = {
device: DeviceType.ABSTRACT,
Expand All @@ -73,10 +81,7 @@ describe('Conductor', () => {
...device1Mappings,
}

const conductor = new Conductor({
multiThreadedResolver: false,
getCurrentTime: mockTime.getCurrentTime,
})
const conductor = createConductor()

try {
await conductor.init()
Expand Down Expand Up @@ -230,10 +235,7 @@ describe('Conductor', () => {
myLayer0: myLayerMapping0,
}

const conductor = new Conductor({
multiThreadedResolver: false,
getCurrentTime: mockTime.getCurrentTime,
})
const conductor = createConductor()

try {
await conductor.init()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -583,6 +579,66 @@ describe('Conductor', () => {
// }
})

test('resync states uses current time for state before now', async () => {
const myLayerMapping0: Mapping<SomeMappingAbstract> = {
device: DeviceType.ABSTRACT,
deviceId: 'device0',
options: {},
}
const myLayerMapping: Mappings = {
myLayer0: myLayerMapping0,
}

const conductor = createConductor()

try {
await conductor.init()
await addConnections(conductor.connectionManager, {
device0: {
type: DeviceType.ABSTRACT,
options: {},
},
})

const device0 = await getMockDeviceWrapper(conductor, 'device0')
const handleStateMock = device0.handleState as unknown as jest.Mock<Promise<void>, [unknown, unknown]>
handleStateMock.mockResolvedValue(undefined)

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
const conductorWithResync = conductor as unknown as { resyncDeviceStates: (deviceId: string) => void }
conductorWithResync.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([
Expand Down
44 changes: 30 additions & 14 deletions packages/timeline-state-resolver/src/conductor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,8 +536,8 @@ export class Conductor extends EventEmitter<ConductorEvents> {
_.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]
}
}
}
Expand Down Expand Up @@ -765,6 +765,27 @@ export class Conductor extends EventEmitter<ConductorEvents> {
return this.connectionManager.getConnection(deviceId)?.device.handleState(filledState, mappings)
}

private _getReplayStateWithCurrentTime(state: DeviceState, now: number): Timeline.TimelineState<TSRTimelineContent> {
const filledState = fillStateFromDatastore(state.state, this._datastore)

if (state.time <= now && filledState.time < now) {
filledState.time = now
}

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(() => {
Expand All @@ -784,15 +805,13 @@ export class Conductor extends EventEmitter<ConductorEvents> {

this._datastore = newStore

const now = this.getCurrentTime()

for (const deviceId of affectedDevices) {
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
])
const toBeFilled = this._getReplayStatesForDevice(deviceId, now)

for (const s of toBeFilled) {
const filledState = fillStateFromDatastore(s.state, this._datastore)
const filledState = this._getReplayStateWithCurrentTime(s, now)

this.connectionManager
.getConnection(deviceId)
Expand All @@ -809,14 +828,11 @@ export class Conductor extends EventEmitter<ConductorEvents> {
private resyncDeviceStates(deviceId: string) {
this._actionQueue
.add(() => {
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
])
const now = this.getCurrentTime()
const toBeFilled = this._getReplayStatesForDevice(deviceId, now)

for (const s of toBeFilled) {
const filledState = fillStateFromDatastore(s.state, this._datastore)
const filledState = this._getReplayStateWithCurrentTime(s, now)

this.connectionManager
.getConnection(deviceId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export class CasparCGDevice extends DeviceWithState<State, CasparCGDeviceTypes,
private _retryTimeout: NodeJS.Timeout | undefined
private _retryTime: number | null = null
private _currentState: InternalState = { channels: {} }
private _detectedChannelFps: { [channel: number]: number } = {}

constructor(deviceId: string, deviceOptions: DeviceOptionsCasparCGInternal, getCurrentTime: () => Promise<number>) {
super(deviceId, deviceOptions, getCurrentTime)
Expand Down Expand Up @@ -131,6 +132,7 @@ export class CasparCGDevice extends DeviceWithState<State, CasparCGDeviceTypes,
if (error) return true

const response = await request
this.updateDetectedFpsFromInfo(response?.data as InfoEntry[] | undefined)

const channelPromises: Promise<Response<InfoChannelEntry | undefined>>[] = []
const channelLength: number = response?.data?.['length'] ?? 0
Expand All @@ -142,7 +144,7 @@ export class CasparCGDevice extends DeviceWithState<State, CasparCGDeviceTypes,
this._currentState.channels[obj.channel] = {
channelNo: obj.channel,
videoMode: this.getVideMode(obj),
fps: obj.frameRate,
fps: this.getChannelFps(obj.channel),
layers: {},
}
}
Expand Down Expand Up @@ -515,7 +517,7 @@ export class CasparCGDevice extends DeviceWithState<State, CasparCGDeviceTypes,
// 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
channel.fps = this.getChannelFps(mapping.options.channel)
caspar.channels[channel.channelNo] = channel

let foregroundObj: ResolvedTimelineObjectInstanceExtended | undefined = timelineState.layers[layerName]
Expand Down Expand Up @@ -572,10 +574,20 @@ export class CasparCGDevice extends DeviceWithState<State, CasparCGDeviceTypes,
const currentTemplateData = (channel.layers[mapping.options.layer] as any as TemplateLayer | undefined)
?.templateData
const foregroundTemplateData = (foregroundStateLayer as any as TemplateLayer | undefined)?.templateData
const hasObjectTemplateData =
typeof currentTemplateData === 'object' &&
currentTemplateData !== null &&
typeof foregroundTemplateData === 'object' &&
foregroundTemplateData !== null
channel.layers[mapping.options.layer] = merge(channel.layers[mapping.options.layer], {
...foregroundStateLayer,
...(_.isObject(currentTemplateData) && _.isObject(foregroundTemplateData)
? { templateData: deepMerge(currentTemplateData, foregroundTemplateData) }
...(hasObjectTemplateData
? {
templateData: deepMerge(
currentTemplateData as Record<string, any>,
foregroundTemplateData as Record<string, any>
),
}
: {}),
nextUp: backgroundStateLayer
? merge(
Expand Down Expand Up @@ -632,6 +644,7 @@ export class CasparCGDevice extends DeviceWithState<State, CasparCGDeviceTypes,
if (!response?.data[0]) {
return { result: ActionExecutionResultCode.Error }
}
this.updateDetectedFpsFromInfo(response.data as InfoEntry[] | undefined)

await Promise.all(
response.data.map(async (_, i) => {
Expand All @@ -655,7 +668,7 @@ export class CasparCGDevice extends DeviceWithState<State, CasparCGDeviceTypes,
this._currentState.channels[obj.channel] = {
channelNo: obj.channel,
videoMode: this.getVideMode(obj),
fps: obj.frameRate,
fps: this.getChannelFps(obj.channel),
layers: {},
}
})
Expand Down Expand Up @@ -983,6 +996,34 @@ export class CasparCGDevice extends DeviceWithState<State, CasparCGDeviceTypes,
this.emit('connectionChanged', this.getStatus())
}

private updateDetectedFpsFromInfo(infoEntries: InfoEntry[] | undefined) {
if (!infoEntries?.length) return

for (const entry of infoEntries) {
const channelRate = Number(entry.channelRate) || 0
const frameRate = Number(entry.frameRate) || 0
const detected = entry.interlaced ? channelRate || frameRate : frameRate || channelRate

if (detected > 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
}
Comment on lines +999 to +1025

@coderabbitai coderabbitai Bot Feb 20, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, let's understand the repository structure
git ls-files | grep -E "(casparcg|InfoEntry)" | head -20

# Find InfoEntry type definition
rg -n "interface InfoEntry|type InfoEntry" --type=ts -A 15

# Check imports in the current file
head -50 packages/timeline-state-resolver/src/integrations/casparCG/index.ts | grep -E "import|from"

Repository: Sofie-Automation/sofie-timeline-state-resolver

Length of output: 498


🏁 Script executed:

#!/bin/bash
# Get full import statements from the file
sed -n '1,50p' packages/timeline-state-resolver/src/integrations/casparCG/index.ts

# Check the mock to understand InfoEntry structure
cat packages/timeline-state-resolver/src/__mocks__/casparcg-connection.ts

# Check the type definitions
cat packages/timeline-state-resolver-types/src/integrations/casparcg.ts

Repository: Sofie-Automation/sofie-timeline-state-resolver

Length of output: 15578


🏁 Script executed:

#!/bin/bash
# Search for uses of _detectedChannelFps
rg -n "_detectedChannelFps" --type=ts -B 2 -A 2

# Search for how getChannelFps is used
rg -n "getChannelFps" --type=ts -B 2 -A 2

# Search for seek-related calculations
rg -n "seek" --type=ts -i -B 2 -A 2 | head -100

# Search for millisecond to frame conversion
rg -n "/ fps|/ this\.fps|* fps|\* this\.fps" --type=ts -B 2 -A 2 | head -50

Repository: Sofie-Automation/sofie-timeline-state-resolver

Length of output: 13780


🏁 Script executed:

#!/bin/bash
# Search for fps usage in calculations (fixed regex)
rg -n "fps\s*[\*/]|[\*/]\s*fps" --type=ts -B 2 -A 2 | head -80

# Look for frame-related calculations
rg -n "frame.*fps|fps.*frame" --type=ts -i -B 2 -A 2 | head -80

# Check the CasparCGState type definition for how fps is used
rg -n "class CasparCGState|interface.*CasparCGState" --type=ts -A 20 | head -100

Repository: Sofie-Automation/sofie-timeline-state-resolver

Length of output: 1580


🏁 Script executed:

#!/bin/bash
# Check if casparcg-state is imported and how
rg -n "from.*casparcg-state|import.*CasparCGState" --type=ts -B 2 -A 5 | head -50

# Look for actual calculations using channel.fps or this.fps in casparCG integration
rg -n "channel\.fps|this\.fps|getChannelFps\(" packages/timeline-state-resolver/src/integrations/casparCG/index.ts -B 3 -A 3

# Check the casparcg-state package.json or README
fd "casparcg-state" --type d | head -5

Repository: Sofie-Automation/sofie-timeline-state-resolver

Length of output: 3055


🏁 Script executed:

#!/bin/bash
# Check package.json for casparcg-state version
cat package.json | jq '.dependencies["casparcg-state"]'

# Look for tests with interlaced content
rg -n "interlaced.*true|channelRate.*frameRate" --type=ts -B 3 -A 3

# Check for tests that verify fps behavior
rg -n "fps.*50|fps.*25|frameRate.*channelRate" packages/timeline-state-resolver/src/integrations/casparCG/__tests__/ -B 2 -A 2

# Look at the test file more carefully for interlaced scenarios
fd "casparcg.spec.ts" -exec wc -l {} \;

Repository: Sofie-Automation/sofie-timeline-state-resolver

Length of output: 4219


🏁 Script executed:

#!/bin/bash
# Let's check the test file more closely for any assertions about interlaced fps
cat packages/timeline-state-resolver/src/integrations/casparCG/__tests__/casparcg.spec.ts | head -250

# Also check if there are any issues or TODOs mentioning interlaced or fps semantics
rg -n "TODO|FIXME|BUG|interlaced" packages/timeline-state-resolver/src/integrations/casparCG/ --type=ts -B 1 -A 1

Repository: Sofie-Automation/sofie-timeline-state-resolver

Length of output: 7320


Fix interlaced FPS detection logic.

Line 1005 incorrectly uses channelRate (field rate) for interlaced content when it should use frameRate (frame rate). In broadcast terminology, 1080i50 means 50 fields/second, which equals 25 frames/second. Using the field rate (50) for the fps parameter will double all frame-based calculations (seek, duration conversions), producing incorrect playout timing.

Change line 1005 from:

const detected = entry.interlaced ? channelRate || frameRate : frameRate || channelRate

to:

const detected = frameRate || channelRate

Both interlaced and progressive formats should use frameRate as the canonical FPS value.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/timeline-state-resolver/src/integrations/casparCG/index.ts` around
lines 999 - 1025, The interlaced FPS detection in updateDetectedFpsFromInfo is
using channelRate for interlaced content which yields field-rate (e.g., 50 for
1080i50) instead of frame-rate; update the logic in updateDetectedFpsFromInfo
(which populates this._detectedChannelFps based on InfoEntry) to always prefer
entry.frameRate over entry.channelRate (i.e., detected = entry.frameRate ||
entry.channelRate) and remove the conditional on entry.interlaced so
getChannelFps and downstream frame-based calculations use canonical frame-rate
values.

@dedicatedbroadcastsolutions dedicatedbroadcastsolutions Feb 21, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current seek behavior for CasparCG requires field rate for interlaced content for proper seeking rather than frame rate. I decided the safest thing would be to patch here rather than patch casparcg and break other clients that use seek. I verified this in 1080i5994, but it probably holds true for 1080i50. Someone in PAL land should verify this. If casparcg differs between the two then it needs patched instead.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dedicatedbroadcastsolutions, thank you for the clarification! You're absolutely right—CasparCG uses field rate motion for interlaced video formats, so using channelRate (field rate) for interlaced content is the correct approach for proper seek behavior. I apologize for the confusion in my initial review.

Your decision to handle this requirement in TSR rather than patching CasparCG itself makes sense to maintain compatibility with other clients that depend on the current seek behavior.

Suggestion: Consider adding a brief comment above line 1005 to document this non-obvious requirement for future maintainers:

// For interlaced formats, use channelRate (field rate) as CasparCG's seek behavior
// is field-based, not frame-based. E.g., 1080i50 uses 50 (fields/sec) not 25 (frames/sec).
const detected = entry.interlaced ? channelRate || frameRate : frameRate || channelRate

This will help prevent similar confusion in future code reviews.


✏️ Learnings added
Learnt from: dedicatedbroadcastsolutions
Repo: Sofie-Automation/sofie-timeline-state-resolver PR: 436
File: packages/timeline-state-resolver/src/integrations/casparCG/index.ts:999-1025
Timestamp: 2026-02-21T00:34:10.968Z
Learning: In CasparCG integrations within packages/timeline-state-resolver/src/integrations/casparCG/index.ts, when detecting FPS from InfoEntry data for interlaced formats, use `channelRate` (field rate) rather than `frameRate` because CasparCG's seek behavior is field-based for interlaced content. For example, 1080i50 should use 50 fields/second, not 25 frames/second, to ensure correct seek positioning.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


private getVideMode(info: InfoEntry): string {
return `${info.format}${info.interlaced ? 'I' : 'P'}${info.frameRate}`
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,8 +316,8 @@ export class Pharos extends EventEmitter<PharosEvents> {
public async getTextSlot(names?: string | Array<string>): Promise<TextSlot> {
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)
}
Expand All @@ -335,8 +335,8 @@ export class Pharos extends EventEmitter<PharosEvents> {
public async getLuaVariables(vars?: string | Array<string>): Promise<LuaVariables> {
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)
}
Expand Down