Skip to content

Commit 9cedcc7

Browse files
committed
Merge branch 'feat--Reduce-ingest-visual-noise' into bbc-main-autonext
2 parents 10ea380 + 51e8dba commit 9cedcc7

3 files changed

Lines changed: 153 additions & 5 deletions

File tree

packages/webui/src/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { type PropsWithChildren } from 'react'
22
import { Meteor } from 'meteor/meteor'
3+
import _ from 'underscore'
34
import { withTracker } from '../../../lib/ReactMeteorData/react-meteor-data.js'
45
import { protectString } from '@sofie-automation/shared-lib/dist/lib/protectedString'
56
import type { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist/RundownPlaylist'
@@ -32,6 +33,103 @@ const LOW_RESOLUTION_TIMING_DECIMATOR = 15
3233

3334
const CURRENT_TIME_GRANULARITY = 1000 / 60
3435

36+
/**
37+
* Metadata-only fields that don't affect timing calculations or visual display.
38+
* This is used by the UI to filter unnecessary timing recalculations when the database updates.
39+
* If only these fields change in the database, we skip timing recalculation on the client.
40+
* Any other field changes will trigger a timing update and visual refresh.
41+
*
42+
* Note: The database writes ALL fields (this filtering is UI-side only).
43+
*
44+
* RATIONALE: Non-timing ingest changes (like segment.identifier or rundownPlaylist.modified)
45+
* trigger CommitIngestOperation which rewrites the entire RundownPlaylist document via .replace().
46+
* This causes Meteor subscriptions to fire, updating the 'partInstances' prop reference, which
47+
* was previously causing cascading recalculations even when part durations hadn't changed.
48+
* This cascading effect created "visual noise" - rapid position shifts in the segment timeline.
49+
* By filtering out metadata-only changes, we prevent those unnecessary recalculations while
50+
* preserving database integrity (complete state always persisted).
51+
*/
52+
const TIMING_METADATA_ONLY_FIELDS = new Set<keyof DBRundownPlaylist>([
53+
// Identification
54+
'_id',
55+
'externalId',
56+
'studioId',
57+
'restoredFromSnapshotId',
58+
59+
// Timestamps and tracking
60+
'created',
61+
'modified',
62+
'resetTime',
63+
'lastIncorrectPartPlaybackReported',
64+
'lastTakeTime',
65+
66+
// Display configuration (doesn't affect timing calculations)
67+
'name',
68+
'rehearsal',
69+
'holdState',
70+
'timeOfDayCountdowns',
71+
72+
// Data storage
73+
'privateData',
74+
'publicData',
75+
'notes',
76+
'privatePlayoutPersistentState',
77+
'publicPlayoutPersistentState',
78+
79+
// AB session tracking
80+
'trackedAbSessions',
81+
'assignedAbSessions',
82+
83+
// Offset (used in logic but not timing display)
84+
'nextTimeOffset',
85+
'queuedSegmentId',
86+
87+
// UI editing state
88+
'rundownRanksAreSetInSofie',
89+
90+
// T-timers (internal state, not display)
91+
'tTimers',
92+
])
93+
94+
/**
95+
* Check if timing-relevant fields in the playlist have changed.
96+
* This is used to prevent unnecessary timing recalculations when only metadata fields change.
97+
*
98+
* Fields in TIMING_METADATA_ONLY_FIELDS are ignored.
99+
* Any other field changes will return true.
100+
*
101+
* @param oldPlaylist The previous playlist state
102+
* @param newPlaylist The new playlist state
103+
* @returns true if any non-metadata fields changed, false if only metadata changed
104+
*/
105+
function didTimingFieldsChange(
106+
oldPlaylist: DBRundownPlaylist | undefined,
107+
newPlaylist: DBRundownPlaylist | undefined
108+
): boolean {
109+
// If either is undefined, they're different
110+
if (!oldPlaylist || !newPlaylist) {
111+
return oldPlaylist !== newPlaylist
112+
}
113+
114+
// Check all fields except metadata-only fields
115+
for (const field of Object.keys(oldPlaylist) as Array<keyof DBRundownPlaylist>) {
116+
// Skip metadata-only fields
117+
if (TIMING_METADATA_ONLY_FIELDS.has(field)) {
118+
continue
119+
}
120+
121+
const oldValue = oldPlaylist[field]
122+
const newValue = newPlaylist[field]
123+
124+
// Use deep equality comparison
125+
if (!_.isEqual(oldValue, newValue)) {
126+
return true
127+
}
128+
}
129+
130+
return false
131+
}
132+
35133
/**
36134
* RundownTimingProvider properties.
37135
* @interface IRundownTimingProviderProps
@@ -230,11 +328,13 @@ export const RundownTimingProvider = withTracker<
230328
Meteor.clearInterval(this.refreshTimer)
231329
this.refreshTimer = Meteor.setInterval(this.onRefreshTimer, this.refreshTimerInterval)
232330
}
233-
if (
234-
prevProps.partInstances !== this.props.partInstances ||
235-
prevProps.playlist?.nextPartInfo?.partInstanceId !== this.props.playlist?.nextPartInfo?.partInstanceId ||
236-
prevProps.playlist?.currentPartInfo?.partInstanceId !== this.props.playlist?.currentPartInfo?.partInstanceId
237-
) {
331+
332+
// Only recalculate timing if timing-relevant fields actually changed
333+
// This prevents unnecessary recalculations when only metadata (modified, notes, etc.) changes
334+
const partInstancesChanged = prevProps.partInstances !== this.props.partInstances
335+
const timingFieldsChanged = didTimingFieldsChange(prevProps.playlist, this.props.playlist)
336+
337+
if (partInstancesChanged || timingFieldsChanged) {
238338
this.refreshDecimator = 0 // Force LR update
239339
this.lastSyncedTime = 0 // Force synced update
240340
this.onRefreshTimer()

packages/webui/src/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
import { computeSegmentDuration, getPartInstanceTimingId } from '../../lib/rundownTiming.js'
3030
import { RundownViewShelf } from '../RundownView/RundownViewShelf.js'
3131
import type { PartInstanceId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids'
32+
import type { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist/RundownPlaylist'
3233
import { catchError, useDebounce } from '../../lib/lib.js'
3334
import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub'
3435
import { useSubscription, useTracker } from '../../lib/ReactMeteorData/ReactMeteorData.js'
@@ -137,6 +138,26 @@ export function SegmentTimelineContainer(props: Readonly<IProps>): JSX.Element {
137138
return <SegmentTimelineContainerContent {...props} />
138139
}
139140

141+
/**
142+
* Check if timing-relevant playlist fields changed in SegmentTimelineContainer.
143+
* Used to prevent unnecessary expensive operations when only metadata changes.
144+
*/
145+
function didPlaylistTimingChange(
146+
prevPlaylist: DBRundownPlaylist | undefined,
147+
currPlaylist: DBRundownPlaylist | undefined
148+
): boolean {
149+
if (!prevPlaylist || !currPlaylist) return prevPlaylist !== currPlaylist
150+
151+
// Check only timing-relevant fields
152+
return (
153+
prevPlaylist.currentPartInfo?.partInstanceId !== currPlaylist.currentPartInfo?.partInstanceId ||
154+
prevPlaylist.nextPartInfo?.partInstanceId !== currPlaylist.nextPartInfo?.partInstanceId ||
155+
prevPlaylist.activationId !== currPlaylist.activationId ||
156+
prevPlaylist.queuedSegmentId !== currPlaylist.queuedSegmentId ||
157+
!_.isEqual(prevPlaylist.outOfOrderTiming, currPlaylist.outOfOrderTiming)
158+
)
159+
}
160+
140161
const SegmentTimelineContainerContent = withResolvedSegment(
141162
class SegmentTimelineContainerContent extends React.Component<IProps & ITrackedResolvedSegmentProps, IState> {
142163
static contextType = RundownTimingProviderContext
@@ -227,6 +248,16 @@ const SegmentTimelineContainerContent = withResolvedSegment(
227248
}
228249

229250
componentDidUpdate(prevProps: IProps & ITrackedResolvedSegmentProps) {
251+
// Skip expensive calculations if only metadata changed (not timing)
252+
const playlistTimingChanged = didPlaylistTimingChange(prevProps.playlist, this.props.playlist)
253+
const partInstancesChanged = prevProps.ownCurrentPartInstance !== this.props.ownCurrentPartInstance ||
254+
prevProps.ownNextPartInstance !== this.props.ownNextPartInstance
255+
256+
// Early exit: if no timing-relevant fields changed, skip the entire update
257+
if (!playlistTimingChanged && !partInstancesChanged) {
258+
return
259+
}
260+
230261
let isLiveSegment = false
231262
let isNextSegment = false
232263
let currentLivePart: PartExtended | undefined = undefined
@@ -584,6 +615,10 @@ const SegmentTimelineContainerContent = withResolvedSegment(
584615
})
585616
}
586617

618+
onAirLineRefresh = (e: TimingEvent) => {
619+
this.applyAirLineRefresh(e)
620+
}
621+
587622
visibleChanged = (entries: IntersectionObserverEntry[]) => {
588623
if (this.isUnmounted) return
589624
// Add a small debounce to ensure UI has settled before checking

packages/webui/src/client/ui/SegmentTimeline/TimelineGrid.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,19 @@ export class TimelineGrid extends React.Component<ITimelineGridProps> {
426426
this.initialize()
427427
}
428428

429+
const timingRelevantChanged =
430+
prevProps.isLiveSegment !== this.props.isLiveSegment ||
431+
prevProps.partInstances !== this.props.partInstances ||
432+
prevProps.currentPartInstanceId !== this.props.currentPartInstanceId ||
433+
prevProps.timeScale !== this.props.timeScale ||
434+
prevProps.frameRate !== this.props.frameRate ||
435+
prevProps.scrollLeft !== this.props.scrollLeft
436+
437+
// Skip expensive operations if only non-timing props changed
438+
if (!timingRelevantChanged) {
439+
return
440+
}
441+
429442
if (
430443
prevProps.isLiveSegment !== this.props.isLiveSegment ||
431444
prevProps.partInstances !== this.props.partInstances ||

0 commit comments

Comments
 (0)