Skip to content

fix: use current time when replaying resync state after CasparCG reconnect#436

Draft
dedicatedbroadcastsolutions wants to merge 6 commits intoSofie-Automation:mainfrom
dedicatedbroadcastsolutions:fix/casparcg_reconnect_resolve
Draft

fix: use current time when replaying resync state after CasparCG reconnect#436
dedicatedbroadcastsolutions wants to merge 6 commits intoSofie-Automation:mainfrom
dedicatedbroadcastsolutions:fix/casparcg_reconnect_resolve

Conversation

@dedicatedbroadcastsolutions
Copy link
Copy Markdown

@dedicatedbroadcastsolutions dedicatedbroadcastsolutions commented Feb 17, 2026

About the Contributor
This pull request is posted on behalf of Dedicated Broadcast Solutions.

Type of Contribution
Bug fix

Related Issue
Fixes #435

Current Behavior
When a CasparCG device disconnects and reconnects, TSR may replay the last state-before-now using a stale historical state time.
For absolute-time schedules, the reconnected device can resume near its old playback position and remain offset from continuously running devices by approximately the outage duration.

New Behavior
During resync/state replay, TSR normalizes replayed past states to current resolver time before sending them to device handlers.
This ensures reconnect playback aligns with current schedule time and uses the most current available resolver offset.

Root Cause
Resync replay reused original state.time for a past state instead of refreshing to current time at replay, causing seek/play calculations to be based on stale timing context.

Implementation Summary
Added replay-time normalization for past states in Conductor resync/datastore replay paths.
Reused a single helper to keep behavior consistent across replay entry points.
Added regression test to verify that resync replay uses current time for state-before-now.
Testing
Ran focused test suite:
yarn run -T jest [conductor.spec.ts] --runInBand --forceExit
Added regression coverage for resync replay timing behavior.
Impact
Eliminates reconnect drift caused by stale replay timestamps.
Most visible improvement in CasparCG absolute-time playout scenarios.
No intended behavior change for future states; only replayed past-state timestamp handling is adjusted.
Risk Assessment
Low-to-moderate risk:

Change is targeted to replay/resync timing logic.
Covered by focused regression test.
Does not alter core timeline resolution algorithm.

Notes
Related PRs #413 and #424 were reviewed and do not address this reconnect stale-time replay path.

Overview

Fixes a bug where the Timeline State Resolver (TSR) could replay stale historical state timestamps during device reconnect/resync (notably CasparCG), causing resumed playback to be offset by the outage duration. Replayed past-state timestamps are now normalized to the current resolver time during Conductor resync/datastore replay paths.

Problem

On device disconnect + reconnect, TSR could replay a prior "state-before-now" using the original historical state.time rather than the current resolver time (including current clock offset). This caused reconnected devices to resume at an old playback position and remain offset from continuously running devices by roughly the disconnect duration.

Solution

  • Added a single private helper _getReplayStateWithCurrentTime(state, now) that fills a state from the datastore and clamps its timestamp to at most the current resolver time when replaying past states.
  • Added _getReplayStatesForDevice(deviceId, now) to centralize selection of prior/future states relative to a consistent sampled now.
  • Replaced direct fillStateFromDatastore usage in replay entry points (setDatastore and resyncDeviceStates) with the new helpers and captured now once per replay to ensure consistent selection and filling.
  • Tightened fix-now logic to only set enable.start when enable is a non-array object (avoids unsafe property writes on malformed enables).
  • Defensive hardening in integrations:
    • CasparCG: per-channel FPS detection/cache, getChannelFps(...) usage, and safer templateData merging (only deep-merge when both sides are object-like).
    • Pharos: normalize string-or-array params via Array.isArray to avoid fragile union-variable mutation patterns.

Changes

packages/timeline-state-resolver/src/conductor.ts

  • Added _getReplayStateWithCurrentTime(state, now) and _getReplayStatesForDevice(deviceId, now).
  • Use a single sampled now = this.getCurrentTime() in setDatastore and resyncDeviceStates and use helpers to select/fill replay states so replayed "state-before-now" timestamps are aligned to current resolver time.
  • Strengthened fix-now type checks to only mutate enable.start when enable is a plain non-array object.
  • (+30, -14 lines)

packages/timeline-state-resolver/src/tests/conductor.spec.ts

  • Extracted shared createConductor(options) test factory (multiThreadedResolver: false, getCurrentTime from mock time) and replaced inline Conductor instantiation across tests.
  • Added regression test "resync states uses current time for state before now" (appears in two locations in the test file set) to assert that resyncDeviceStates replays past-state using the resynced current time.
  • (+72, -16 lines)

packages/timeline-state-resolver/src/integrations/casparCG/index.ts

  • Added per-channel FPS detection cache (_detectedChannelFps), updateDetectedFpsFromInfo(infoEntries) and getChannelFps(channel) with priority: initOptions.fps → per-channel detected FPS → first detected FPS → default 25.
  • Use detected channel FPS when initializing channel state and converting states to CasparCG.
  • Safer templateData merging: only deep-merge when both current and foreground templateData are object-like.
  • Call updateDetectedFpsFromInfo on Info responses and refresh FPS on clearAllChannels/connect flows.
  • (+46, -5 lines; private members/methods added; public API unchanged)

packages/timeline-state-resolver/src/integrations/pharos/connection.ts

  • Replace _.isArray checks with native Array.isArray in getTextSlot and getLuaVariables to normalize handling of string-or-array parameters.
  • (+4, -4 lines)

Tests

  • Added regression test verifying resync replay uses current time for state-before-now.
  • Focused test run: yarn run -T jest [conductor.spec.ts] --runInBand --forceExit reported by contributor.

Impact

Reconnected devices now realign to current schedule time and resolver offset when replaying past states, eliminating reconnect drift for absolute-time playout (e.g., CasparCG). Future-state scheduling and core timeline resolution are unchanged.

Risk

Low-to-moderate: changes are focused and small, limited to replay/resync paths and defensive input guards. Covered by a targeted regression test. Fixes #435.

@dedicatedbroadcastsolutions dedicatedbroadcastsolutions requested a review from a team as a code owner February 17, 2026 10:34
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 17, 2026

Walkthrough

Replays of device states are now computed using a single captured "now" and prior states are clamped to that current time before being reapplied. Per-channel FPS detection and safer templateData merging added for CasparCG; tests updated; minor lodash -> native array checks in Pharos.

Changes

Cohort / File(s) Summary
State Replay Timing Fix
packages/timeline-state-resolver/src/conductor.ts
Added _getReplayStateWithCurrentTime(state, now) and _getReplayStatesForDevice(deviceId, now); capture now = this.getCurrentTime() in setDatastore()/resyncDeviceStates() and use helpers to select/fill states; tightened fixNow enable-type checks.
Test Coverage
packages/timeline-state-resolver/src/__tests__/conductor.spec.ts
Added createConductor() test helper and replaced inline instantiation; added test "resync states uses current time for state before now" validating resync uses captured current time.
CasparCG integration: per-channel FPS & templateData merge
packages/timeline-state-resolver/src/integrations/casparCG/index.ts
Added private _detectedChannelFps, updateDetectedFpsFromInfo(...), and getChannelFps(...); wire FPS detection into Info handling and state conversion; only deep-merge templateData when both are plain objects.
Pharos integration: remove lodash array check
packages/timeline-state-resolver/src/integrations/pharos/connection.ts
Replaced _.isArray(...) with Array.isArray(...) in getTextSlot and getLuaVariables (no behavior change).

Sequence Diagram(s)

(Skipped)

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested labels

bug

Suggested reviewers

  • nytamin
  • chandrashekar-nallamilli
  • Julusian

Poem

🐇 I hopped through timelines, nose to the ground,
Found ticks that wandered and turned around.
I captured a "now" and tucked old times in place,
So reconnecting devices all match the race.
Hooray for synced play — carrots for grace!

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Out of Scope Changes check ❓ Inconclusive The Pharos pharos/connection.ts changes (Array.isArray replacements) appear tangential to the main reconnect/resync timing objective, though described as defensive hardening for parameter handling. Clarify whether the Pharos API parameter hardening changes are necessary to resolve issue #435 or should be addressed in a separate PR focused on parameter validation.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main fix: using current time for replayed resync state after CasparCG reconnect, which matches the core objective in the changeset.
Linked Issues check ✅ Passed The PR fully addresses issue #435 by implementing replay time normalization in conductor.ts and CasparCG integration to ensure reconnected devices use current resolver time instead of stale historical timestamps.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/timeline-state-resolver/src/conductor.ts (1)

820-837: ⚠️ Potential issue | 🟡 Minor

Fix potential crash in resyncDeviceStates when called before device state is initialized.

If resyncStates event fires when a device first connects (before any timeline state has been sent via setDatastore or setTimeline), this._deviceStates[deviceId] will be undefined, causing a TypeError when spreading on lines 826–827.

Add a guard to return early if the device has no state:

🛡️ Proposed fix
 	private resyncDeviceStates(deviceId: string) {
 		this._actionQueue
 			.add(() => {
+				if (!this._deviceStates[deviceId]?.length) return
 				const now = this.getCurrentTime()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/timeline-state-resolver/src/conductor.ts` around lines 820 - 837,
The resyncDeviceStates method can crash when this._deviceStates[deviceId] is
undefined; add a guard at the start of resyncDeviceStates to return early if
this._deviceStates[deviceId] is falsy (or not an array) before using
spread/reverse/filter, so the subsequent logic (creating toBeFilled, calling
_getReplayStateWithCurrentTime and
connectionManager.getConnection(deviceId)?.device.handleState) only runs when
device state exists.
🧹 Nitpick comments (1)
packages/timeline-state-resolver/src/conductor.ts (1)

797-812: now is recomputed per device — intentional?

now is captured inside the for (const deviceId of affectedDevices) loop (Line 798), so each device may see a slightly different current time. If consistent cross-device timing is desired, move const now = this.getCurrentTime() before the loop. This is a minor nit — the difference is negligible in practice.

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

In `@packages/timeline-state-resolver/src/conductor.ts` around lines 797 - 812,
The current time is recomputed per device inside the for (const deviceId of
affectedDevices) loop by calling this.getCurrentTime(), causing tiny
inconsistencies; move the timestamp capture outside the loop (compute const now
= this.getCurrentTime() before iterating affectedDevices) and then use that same
now when calling this._getReplayStateWithCurrentTime(s, now) and when invoking
connectionManager.getConnection(deviceId)?.device.handleState(...), ensuring all
devices use a consistent reference time.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/timeline-state-resolver/src/conductor.ts`:
- Around line 820-837: The resyncDeviceStates method can crash when
this._deviceStates[deviceId] is undefined; add a guard at the start of
resyncDeviceStates to return early if this._deviceStates[deviceId] is falsy (or
not an array) before using spread/reverse/filter, so the subsequent logic
(creating toBeFilled, calling _getReplayStateWithCurrentTime and
connectionManager.getConnection(deviceId)?.device.handleState) only runs when
device state exists.

---

Nitpick comments:
In `@packages/timeline-state-resolver/src/conductor.ts`:
- Around line 797-812: The current time is recomputed per device inside the for
(const deviceId of affectedDevices) loop by calling this.getCurrentTime(),
causing tiny inconsistencies; move the timestamp capture outside the loop
(compute const now = this.getCurrentTime() before iterating affectedDevices) and
then use that same now when calling this._getReplayStateWithCurrentTime(s, now)
and when invoking
connectionManager.getConnection(deviceId)?.device.handleState(...), ensuring all
devices use a consistent reference time.

@dedicatedbroadcastsolutions dedicatedbroadcastsolutions marked this pull request as draft February 20, 2026 14:47
@dedicatedbroadcastsolutions
Copy link
Copy Markdown
Author

I have updated this with the latest fix. Note, I built the fix and tested it on Release 51 and have ported it from there using AI so we need to verify everything works correctly on this master branch before merging.

PR update: additional branch-alignment fixes (fix-only from Release 51)

I added a small set of targeted hardening changes that were needed when porting the reconnect-resync fix from Release 51 onto main. These are intentionally narrow and focused on runtime safety/correctness.

Core reconnect/resync timing fix (primary change)

What changed: replayed “state-before-now” states are normalized to current resolver time during replay/resync.
Why: previously, replay could use stale historical timestamps, which made absolute-time playout resume offset after reconnect.
Impact: reconnected devices re-align to current schedule time instead of drifting by outage duration.
Conductor safety hardening

What changed: added a stricter guard before mutating enable.start for “now” triggers.
Why: previous logic only checked “not array”; malformed/null enable values could still cause unsafe property writes.
Impact: no behavior change for valid timelines; safer handling of edge-case object shapes.
CasparCG runtime hardening

What changed: detect/cache channel FPS from Caspar INFO, use detected FPS when building state, and improve template-data merge guards.
Why: avoids assuming static/default FPS in reconnect scenarios and prevents unsafe deep-merge paths when template data is not object-like.
Impact: more accurate seek/timing behavior and better runtime robustness during reconnect/replay paths.
Pharos API parameter hardening

What changed: explicit normalization of string-or-array params (names, vars) using Array.isArray.
Why: makes parameter handling unambiguous and avoids fragile union-variable mutation patterns.
Impact: same external behavior, clearer and safer request parameter construction.
Scope / risk

Scope is intentionally small: replay timing correctness + defensive runtime guards.
No intended behavior change to future-state scheduling logic.
Risk is low-to-moderate and concentrated in reconnect/replay and input-guard paths.

@dedicatedbroadcastsolutions dedicatedbroadcastsolutions marked this pull request as ready for review February 20, 2026 20:55
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/timeline-state-resolver/src/integrations/casparCG/index.ts (1)

666-673: clearAllChannels bypasses the new getChannelFps priority chain.

Unlike the connect handler (line 147), clearAllChannels still writes fps: obj.frameRate directly and never calls updateDetectedFpsFromInfo, so after a clearAllChannels action the _currentState FPS doesn't reflect initOptions.fps or detected-FPS priority—creating a divergence from convertStateToCaspar which uses getChannelFps.

♻️ Suggested fix for consistency
+   this.updateDetectedFpsFromInfo(response.data as InfoEntry[] | undefined)

    this.clearStates()
    this._currentState = { channels: {} }
    response.data.forEach((obj) => {
        this._currentState.channels[obj.channel] = {
            channelNo: obj.channel,
            videoMode: this.getVideMode(obj),
-           fps: obj.frameRate,
+           fps: this.getChannelFps(obj.channel),
            layers: {},
        }
    })
🤖 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 666 - 673, clearAllChannels currently sets fps directly from
obj.frameRate, bypassing the fps priority chain; modify clearAllChannels so it
does not assign fps: obj.frameRate but instead invokes the existing priority
logic (call updateDetectedFpsFromInfo with the new channel state and the info
object, or set fps via getChannelFps/getVideMode flow used elsewhere) so the
_currentState FPS matches convertStateToCaspar and the connect handler behavior;
update references in clearAllChannels to use updateDetectedFpsFromInfo (and/or
getChannelFps) for each channel created.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/timeline-state-resolver/src/integrations/casparCG/index.ts`:
- Around line 666-673: clearAllChannels currently sets fps directly from
obj.frameRate, bypassing the fps priority chain; modify clearAllChannels so it
does not assign fps: obj.frameRate but instead invokes the existing priority
logic (call updateDetectedFpsFromInfo with the new channel state and the info
object, or set fps via getChannelFps/getVideMode flow used elsewhere) so the
_currentState FPS matches convertStateToCaspar and the connect handler behavior;
update references in clearAllChannels to use updateDetectedFpsFromInfo (and/or
getChannelFps) for each channel created.

@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
packages/timeline-state-resolver/src/conductor.ts (1)

746-751: Pre-existing in-place .reverse() mutation in _setDeviceState.

Lines 748 and 750 both call .reverse() directly on this._deviceStates[deviceId], which mutates the array in-place. The first .reverse() flips the array for the .find(), and the second .reverse() flips it back for the .filter(). While the double-reverse cancels out, this is fragile — any code that runs concurrently or any interruption between these calls could see the array in a reversed state.

The new _getReplayStatesForDevice helper correctly uses [...deviceStates].reverse(). Consider applying the same pattern here for consistency, though this is a pre-existing issue outside the PR scope.

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

In `@packages/timeline-state-resolver/src/conductor.ts` around lines 746 - 751,
The code in _setDeviceState mutates this._deviceStates[deviceId] by calling
.reverse() directly; replace both in-place reversals with non-mutating copies
(e.g. [...this._deviceStates[deviceId]].reverse() or
this._deviceStates[deviceId].slice().reverse()) so the .find() and .filter()
operate on reversed copies instead of mutating the stored array—mirror the
approach used in _getReplayStatesForDevice to avoid race/consistency issues.
packages/timeline-state-resolver/src/integrations/casparCG/index.ts (1)

1013-1025: FPS fallback chain is reasonable but the "first detected" fallback is non-deterministic.

Object.values(this._detectedChannelFps).find((fps) => fps > 0) on line 1021 returns the first value from the object's iteration order. While V8 iterates integer keys in ascending numeric order, this is an implementation detail. If channels genuinely have different frame rates, this fallback could silently pick the wrong one.

This is a low-risk edge case since most CasparCG installations use uniform FPS across channels, but worth documenting with a comment.

🤖 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 1013 - 1025, The getChannelFps method uses a fallback chain that
ultimately picks the first positive FPS from
Object.values(this._detectedChannelFps), which is non-deterministic across JS
engines; add a concise comment above the getChannelFps function (and near the
Object.values(...) use) stating that the "first detected" fallback depends on
object iteration order, may pick an arbitrary channel's FPS if channels differ,
and that this is intentional/acceptable for now (or how to change it to a
deterministic choice such as preferring the lowest/highest/index 0 if desired);
reference getChannelFps and this._detectedChannelFps in the comment so future
readers can find and understand the behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/timeline-state-resolver/src/integrations/casparCG/index.ts`:
- Around line 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.

---

Nitpick comments:
In `@packages/timeline-state-resolver/src/conductor.ts`:
- Around line 746-751: The code in _setDeviceState mutates
this._deviceStates[deviceId] by calling .reverse() directly; replace both
in-place reversals with non-mutating copies (e.g.
[...this._deviceStates[deviceId]].reverse() or
this._deviceStates[deviceId].slice().reverse()) so the .find() and .filter()
operate on reversed copies instead of mutating the stored array—mirror the
approach used in _getReplayStatesForDevice to avoid race/consistency issues.

In `@packages/timeline-state-resolver/src/integrations/casparCG/index.ts`:
- Around line 1013-1025: The getChannelFps method uses a fallback chain that
ultimately picks the first positive FPS from
Object.values(this._detectedChannelFps), which is non-deterministic across JS
engines; add a concise comment above the getChannelFps function (and near the
Object.values(...) use) stating that the "first detected" fallback depends on
object iteration order, may pick an arbitrary channel's FPS if channels differ,
and that this is intentional/acceptable for now (or how to change it to a
deterministic choice such as preferring the lowest/highest/index 0 if desired);
reference getChannelFps and this._detectedChannelFps in the comment so future
readers can find and understand the behavior.

Comment on lines +999 to +1025
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
}
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Feb 20, 2026

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.

Copy link
Copy Markdown
Author

@dedicatedbroadcastsolutions dedicatedbroadcastsolutions Feb 21, 2026

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.

@dedicatedbroadcastsolutions
Copy link
Copy Markdown
Author

AI assesment of adjustments. We still need to human review and validate this PR.

Validation update after addressing CodeRabbit notes:

Kept the interlaced CasparCG FPS behavior intentionally field-rate based [channelRate], and added clarifying comments to avoid future confusion.
Applied the safe [Conductor] cleanup to avoid in-place [.reverse()] mutation in [_setDeviceState]

Updated CasparCG test expectations to match the intentional interlaced field-rate behavior.
Focused verification:

yarn workspace timeline-state-resolver run -T jest casparcg.spec.ts --runInBand --forceExit ✅ (23/23)
yarn workspace timeline-state-resolver run -T jest src/tests/conductor.spec.ts --runInBand --forceExit ✅ (6/6)
Full yarn unit status:

Fails in OBS tests under Node 24 with obs-websocket-js close-event crash ([TypeError: Cannot read properties of undefined (reading 'reason')] while running [obs.spec.ts]
Same OBS spec passes under Node 20:
nvm use 20 && corepack enable && corepack yarn workspace timeline-state-resolver run -T jest src/integrations/obs/tests/obs.spec.ts --runInBand --forceExit ✅ (9/9)
Conclusion: PR changes are validated in touched areas; remaining full-suite failure appears unrelated and Node-24/runtime

@nytamin nytamin self-requested a review February 23, 2026 06:55
@nytamin
Copy link
Copy Markdown
Member

nytamin commented Feb 24, 2026

Hey @dedicatedbroadcastsolutions !

Thanks for this PR. You seem to not have used the PR-template when creating the PR. The template includes a few control questions to ensure the PR is ready for review.

Before we spend time reviewing this, could you verify that:

  • PR is ready to be reviewed.
  • The functionality has been tested by the author.
  • Relevant unit tests has been added / updated.
  • Relevant documentation (code comments, system documentation) has been added / updated.

If you haven't verified the functionality of the PR (for example, using Quick-TSR), please mark it as a draft so we know it's not ready for review just yet.

@dedicatedbroadcastsolutions
Copy link
Copy Markdown
Author

Sorry, I tried to follow the PR template but apparently failed on a few points.

I have tested the functionality of the version of this based on release 52 as that is what the develop branch of superconductor currently uses. I have not yet tested this PR directly since rebasing it on main as noted in my comment above.

I believe the relevant tests have been updated, but will verify that unit tests are included in that.

Code comments have been updated, but I am not familiar with how to update system documentation. I need to look into that and could use some guidance.

I will try to set up a Quick TSR test before submitting for official review.

@dedicatedbroadcastsolutions dedicatedbroadcastsolutions marked this pull request as draft February 25, 2026 02:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug Report: Casparcg Device resend happens with wrong currenttime

2 participants