From 41f1c1f2717adbacdf89bd19212e6bc48d874997 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 30 Jan 2026 15:26:10 +0000 Subject: [PATCH 01/11] feat: Add optional estimateState to T-Timer data type So we can measure if we are over or under time --- .../corelib/src/dataModel/RundownPlaylist.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index 93c4bb769c..e426fb3f8b 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -165,6 +165,26 @@ export interface RundownTTimer { */ state: TimerState | null + /** The estimated time when we expect to reach the anchor part, for calculating over/under diff. + * + * Based on scheduled durations of remaining parts and segments up to the anchor. + * The over/under diff is calculated as the difference between this estimate and the timer's target (state.zeroTime). + * + * Running means we are progressing towards the anchor (estimate moves with real time) + * Paused means we are pushing (e.g. overrunning the current segment, so the anchor is being delayed) + * + * Calculated automatically when anchorPartId is set, or can be set manually by a blueprint if custom logic is needed. + */ + estimateState?: TimerState + + /** The target Part that this timer is counting towards (the "timing anchor") + * + * This is typically a "break" part or other milestone in the rundown. + * When set, the server calculates estimateState based on when we expect to reach this part. + * If not set, estimateState is not calculated automatically but can still be set manually by a blueprint. + */ + anchorPartId?: PartId + /* * Future ideas: * allowUiControl: boolean From 3507ad4cd11089323b9db59092b3e893588aca5f Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 13:19:36 +0000 Subject: [PATCH 02/11] feat: Add function to Caclulate estimates for anchored T-Timers --- packages/job-worker/src/playout/tTimers.ts | 144 +++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index af86616f82..2f327550f1 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -4,9 +4,14 @@ import type { RundownTTimer, TimerState, } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { literal } from '@sofie-automation/corelib/dist/lib' import { getCurrentTime } from '../lib/index.js' import type { ReadonlyDeep } from 'type-fest' import * as chrono from 'chrono-node' +import { isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part' +import { PartId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { JobContext } from '../jobs/index.js' +import { PlayoutModel } from './model/PlayoutModel.js' export function validateTTimerIndex(index: number): asserts index is RundownTTimerIndex { if (isNaN(index) || index < 1 || index > 3) throw new Error(`T-timer index out of range: ${index}`) @@ -167,3 +172,142 @@ export function calculateNextTimeOfDayTarget(targetTime: string | number): numbe }) return parsed ? parsed.getTime() : null } + +/** + * Recalculate T-Timer estimates based on timing anchors + * + * For each T-Timer that has an anchorPartId set, this function: + * 1. Iterates through ordered parts from current/next onwards + * 2. Accumulates expected durations until the anchor part is reached + * 3. Updates estimateState with the calculated duration + * 4. Sets the estimate as running if we're progressing, or paused if pushing (overrunning) + * + * @param context Job context + * @param playoutModel The playout model containing the playlist and parts + */ +export function recalculateTTimerEstimates(context: JobContext, playoutModel: PlayoutModel): void { + const span = context.startSpan('recalculateTTimerEstimates') + + const playlist = playoutModel.playlist + const tTimers = playlist.tTimers + + // Find which timers have anchors that need calculation + const timerAnchors = new Map() + for (const timer of tTimers) { + if (timer.anchorPartId) { + const existingTimers = timerAnchors.get(timer.anchorPartId) ?? [] + existingTimers.push(timer.index) + timerAnchors.set(timer.anchorPartId, existingTimers) + } + } + + // If no timers have anchors, nothing to do + if (timerAnchors.size === 0) { + if (span) span.end() + return + } + + const currentPartInstance = playoutModel.currentPartInstance?.partInstance + const nextPartInstance = playoutModel.nextPartInstance?.partInstance + + // Get ordered parts to iterate through + const orderedParts = playoutModel.getAllOrderedParts() + + // Start from next part if available, otherwise current, otherwise first playable part + let startPartIndex: number | undefined + if (nextPartInstance) { + // We have a next part selected, start from there + startPartIndex = orderedParts.findIndex((p) => p._id === nextPartInstance.part._id) + } else if (currentPartInstance) { + // No next, but we have current - start from the part after current + const currentIndex = orderedParts.findIndex((p) => p._id === currentPartInstance.part._id) + if (currentIndex >= 0 && currentIndex < orderedParts.length - 1) { + startPartIndex = currentIndex + 1 + } + } + + // If we couldn't find a starting point, start from the first playable part + startPartIndex ??= orderedParts.findIndex((p) => isPartPlayable(p)) + + if (startPartIndex === undefined || startPartIndex < 0) { + // No parts to iterate through, clear estimates + for (const timer of tTimers) { + if (timer.anchorPartId) { + playoutModel.updateTTimer({ ...timer, estimateState: undefined }) + } + } + if (span) span.end() + return + } + + // Iterate through parts and accumulate durations + const playablePartsSlice = orderedParts.slice(startPartIndex).filter((p) => isPartPlayable(p)) + + const now = getCurrentTime() + let accumulatedDuration = 0 + + // Calculate remaining time for current part + // If not started, treat as if it starts now (elapsed = 0, remaining = full duration) + // Account for playOffset (e.g., from play-from-anywhere feature) + let isPushing = false + if (currentPartInstance) { + const currentPartDuration = + currentPartInstance.part.expectedDurationWithTransition ?? currentPartInstance.part.expectedDuration + if (currentPartDuration) { + const currentPartStartedPlayback = currentPartInstance.timings?.plannedStartedPlayback + const startedPlayback = + currentPartStartedPlayback && currentPartStartedPlayback <= now ? currentPartStartedPlayback : now + const playOffset = currentPartInstance.timings?.playOffset || 0 + const elapsed = now - startedPlayback - playOffset + const remaining = currentPartDuration - elapsed + + isPushing = remaining < 0 + accumulatedDuration = Math.max(0, remaining) + } + } + + for (const part of playablePartsSlice) { + // Add this part's expected duration to the accumulator + const partDuration = part.expectedDurationWithTransition ?? part.expectedDuration ?? 0 + accumulatedDuration += partDuration + + // Check if this part is an anchor for any timer + const timersForThisPart = timerAnchors.get(part._id) + if (timersForThisPart) { + for (const timerIndex of timersForThisPart) { + const timer = tTimers[timerIndex - 1] + + // Update the timer's estimate + const estimateState: TimerState = isPushing + ? literal({ + paused: true, + duration: accumulatedDuration, + }) + : literal({ + paused: false, + zeroTime: now + accumulatedDuration, + }) + + playoutModel.updateTTimer({ ...timer, estimateState }) + } + // Remove this anchor since we've processed it + timerAnchors.delete(part._id) + } + + // Early exit if we've resolved all timers + if (timerAnchors.size === 0) { + break + } + } + + // Clear estimates for any timers whose anchors weren't found (e.g., anchor is in the past or removed) + // Any remaining entries in timerAnchors are anchors that weren't reached + for (const timerIndices of timerAnchors.values()) { + for (const timerIndex of timerIndices) { + const timer = tTimers[timerIndex - 1] + playoutModel.updateTTimer({ ...timer, estimateState: undefined }) + } + } + + if (span) span.end() +} From 5ee6cf24df12b5d6f5529c08181a9a0f8eeeec66 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 15:45:27 +0000 Subject: [PATCH 03/11] feat: Add RecalculateTTimerEstimates job and integrate into playout workflow --- packages/corelib/src/worker/studio.ts | 8 ++++ packages/job-worker/src/ingest/commit.ts | 21 ++++++--- packages/job-worker/src/playout/setNext.ts | 4 ++ .../job-worker/src/playout/tTimersJobs.ts | 44 +++++++++++++++++++ .../job-worker/src/workers/studio/jobs.ts | 3 ++ 5 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 packages/job-worker/src/playout/tTimersJobs.ts diff --git a/packages/corelib/src/worker/studio.ts b/packages/corelib/src/worker/studio.ts index 6eb045fc5e..18516b1d66 100644 --- a/packages/corelib/src/worker/studio.ts +++ b/packages/corelib/src/worker/studio.ts @@ -126,6 +126,12 @@ export enum StudioJobs { */ OnTimelineTriggerTime = 'onTimelineTriggerTime', + /** + * Recalculate T-Timer estimates based on current playlist state + * Called after setNext, takes, and ingest changes to update timing anchor estimates + */ + RecalculateTTimerEstimates = 'recalculateTTimerEstimates', + /** * Update the timeline with a regenerated Studio Baseline * Has no effect if a Playlist is active @@ -412,6 +418,8 @@ export type StudioJobFunc = { [StudioJobs.OnPlayoutPlaybackChanged]: (data: OnPlayoutPlaybackChangedProps) => void [StudioJobs.OnTimelineTriggerTime]: (data: OnTimelineTriggerTimeProps) => void + [StudioJobs.RecalculateTTimerEstimates]: () => void + [StudioJobs.UpdateStudioBaseline]: () => string | false [StudioJobs.CleanupEmptyPlaylists]: () => void diff --git a/packages/job-worker/src/ingest/commit.ts b/packages/job-worker/src/ingest/commit.ts index 47e26f850c..31f6ce0313 100644 --- a/packages/job-worker/src/ingest/commit.ts +++ b/packages/job-worker/src/ingest/commit.ts @@ -29,6 +29,7 @@ import { clone, groupByToMapFunc } from '@sofie-automation/corelib/dist/lib' import { PlaylistLock } from '../jobs/lock.js' import { syncChangesToPartInstances } from './syncChangesToPartInstance.js' import { ensureNextPartIsValid } from './updateNext.js' +import { recalculateTTimerEstimates } from '../playout/tTimers.js' import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' import { getTranslatedMessage, ServerTranslatedMesssages } from '../notes.js' import _ from 'underscore' @@ -234,6 +235,16 @@ export async function CommitIngestOperation( // update the quickloop in case we did any changes to things involving marker playoutModel.updateQuickLoopState() + // wait for the ingest changes to save + await pSaveIngest + + // do some final playout checks, which may load back some Parts data + // Note: This should trigger a timeline update, one is already queued in the `deferAfterSave` above + await ensureNextPartIsValid(context, playoutModel) + + // Recalculate T-Timer estimates after ingest changes + recalculateTTimerEstimates(context, playoutModel) + playoutModel.deferAfterSave(() => { // Run in the background, we don't want to hold onto the lock to do this context @@ -248,13 +259,6 @@ export async function CommitIngestOperation( triggerUpdateTimelineAfterIngestData(context, playoutModel.playlistId) }) - // wait for the ingest changes to save - await pSaveIngest - - // do some final playout checks, which may load back some Parts data - // Note: This should trigger a timeline update, one is already queued in the `deferAfterSave` above - await ensureNextPartIsValid(context, playoutModel) - // save the final playout changes await playoutModel.saveAllToDatabase() } finally { @@ -613,6 +617,9 @@ export async function updatePlayoutAfterChangingRundownInPlaylist( const shouldUpdateTimeline = await ensureNextPartIsValid(context, playoutModel) + // Recalculate T-Timer estimates after playlist changes + recalculateTTimerEstimates(context, playoutModel) + if (playoutModel.playlist.activationId || shouldUpdateTimeline) { triggerUpdateTimelineAfterIngestData(context, playoutModel.playlistId) } diff --git a/packages/job-worker/src/playout/setNext.ts b/packages/job-worker/src/playout/setNext.ts index 8739e289a3..45209a6494 100644 --- a/packages/job-worker/src/playout/setNext.ts +++ b/packages/job-worker/src/playout/setNext.ts @@ -33,6 +33,7 @@ import { import { NoteSeverity } from '@sofie-automation/blueprints-integration' import { convertNoteToNotification } from '../notifications/util.js' import { PersistentPlayoutStateStore } from '../blueprints/context/services/PersistantStateStore.js' +import { recalculateTTimerEstimates } from './tTimers.js' /** * Set or clear the nexted part, from a given PartInstance, or SelectNextPartResult @@ -96,6 +97,9 @@ export async function setNextPart( await cleanupOrphanedItems(context, playoutModel) + // Recalculate T-Timer estimates based on the new next part + recalculateTTimerEstimates(context, playoutModel) + if (span) span.end() } diff --git a/packages/job-worker/src/playout/tTimersJobs.ts b/packages/job-worker/src/playout/tTimersJobs.ts new file mode 100644 index 0000000000..b1fede7642 --- /dev/null +++ b/packages/job-worker/src/playout/tTimersJobs.ts @@ -0,0 +1,44 @@ +import { JobContext } from '../jobs/index.js' +import { recalculateTTimerEstimates } from './tTimers.js' +import { runWithPlayoutModel, runWithPlaylistLock } from './lock.js' + +/** + * Handle RecalculateTTimerEstimates job + * This is called after setNext, takes, and ingest changes to update T-Timer estimates + * Since this job doesn't take a playlistId parameter, it finds the active playlist in the studio + */ +export async function handleRecalculateTTimerEstimates(context: JobContext): Promise { + // Find active playlists in this studio (projection to just get IDs) + const activePlaylistIds = await context.directCollections.RundownPlaylists.findFetch( + { + studioId: context.studioId, + activationId: { $exists: true }, + }, + { + projection: { + _id: 1, + }, + } + ) + + if (activePlaylistIds.length === 0) { + // No active playlist, nothing to do + return + } + + // Process each active playlist (typically there's only one) + for (const playlistRef of activePlaylistIds) { + await runWithPlaylistLock(context, playlistRef._id, async (lock) => { + // Fetch the full playlist object + const playlist = await context.directCollections.RundownPlaylists.findOne(playlistRef._id) + if (!playlist) { + // Playlist was removed between query and lock + return + } + + await runWithPlayoutModel(context, playlist, lock, null, async (playoutModel) => { + recalculateTTimerEstimates(context, playoutModel) + }) + }) + } +} diff --git a/packages/job-worker/src/workers/studio/jobs.ts b/packages/job-worker/src/workers/studio/jobs.ts index be5d81787d..7b66526a4d 100644 --- a/packages/job-worker/src/workers/studio/jobs.ts +++ b/packages/job-worker/src/workers/studio/jobs.ts @@ -49,6 +49,7 @@ import { handleActivateAdlibTesting } from '../../playout/adlibTesting.js' import { handleExecuteBucketAdLibOrAction } from '../../playout/bucketAdlibJobs.js' import { handleSwitchRouteSet } from '../../studio/routeSet.js' import { handleCleanupOrphanedExpectedPackageReferences } from '../../playout/expectedPackages.js' +import { handleRecalculateTTimerEstimates } from '../../playout/tTimersJobs.js' type ExecutableFunction = ( context: JobContext, @@ -87,6 +88,8 @@ export const studioJobHandlers: StudioJobHandlers = { [StudioJobs.OnPlayoutPlaybackChanged]: handleOnPlayoutPlaybackChanged, [StudioJobs.OnTimelineTriggerTime]: handleTimelineTriggerTime, + [StudioJobs.RecalculateTTimerEstimates]: handleRecalculateTTimerEstimates, + [StudioJobs.UpdateStudioBaseline]: handleUpdateStudioBaseline, [StudioJobs.CleanupEmptyPlaylists]: handleRemoveEmptyPlaylists, From 738022fb1812bbdb2b1463b90bfd135666939fb6 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 15:46:30 +0000 Subject: [PATCH 04/11] feat: add timeout for T-Timer recalculations when pushing expected to start --- packages/job-worker/src/playout/tTimers.ts | 32 ++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index 2f327550f1..15f2e27a37 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -9,9 +9,18 @@ import { getCurrentTime } from '../lib/index.js' import type { ReadonlyDeep } from 'type-fest' import * as chrono from 'chrono-node' import { isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part' -import { PartId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PartId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { JobContext } from '../jobs/index.js' import { PlayoutModel } from './model/PlayoutModel.js' +import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' +import { logger } from '../logging.js' +import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' + +/** + * Map of active setTimeout timeouts by studioId + * Used to clear previous timeout when recalculation is triggered before the timeout fires + */ +const activeTimeouts = new Map() export function validateTTimerIndex(index: number): asserts index is RundownTTimerIndex { if (isNaN(index) || index < 1 || index > 3) throw new Error(`T-timer index out of range: ${index}`) @@ -189,6 +198,14 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl const span = context.startSpan('recalculateTTimerEstimates') const playlist = playoutModel.playlist + + // Clear any existing timeout for this studio + const existingTimeout = activeTimeouts.get(playlist.studioId) + if (existingTimeout) { + clearTimeout(existingTimeout) + activeTimeouts.delete(playlist.studioId) + } + const tTimers = playlist.tTimers // Find which timers have anchors that need calculation @@ -204,7 +221,7 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl // If no timers have anchors, nothing to do if (timerAnchors.size === 0) { if (span) span.end() - return + return undefined } const currentPartInstance = playoutModel.currentPartInstance?.partInstance @@ -263,6 +280,17 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl isPushing = remaining < 0 accumulatedDuration = Math.max(0, remaining) + + // Schedule next recalculation for when current part ends (if not pushing and no autoNext) + if (!isPushing && !currentPartInstance.part.autoNext) { + const delay = remaining + 5 // 5ms buffer + const timeoutId = setTimeout(() => { + context.queueStudioJob(StudioJobs.RecalculateTTimerEstimates, undefined, undefined).catch((err) => { + logger.error(`Failed to queue T-Timer recalculation: ${stringifyError(err)}`) + }) + }, delay) + activeTimeouts.set(playlist.studioId, timeoutId) + } } } From 45bcd993ecf070913559827ce6c5fcbb85ef5df3 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 15:47:51 +0000 Subject: [PATCH 05/11] feat: queue initial T-Timer recalculation when job-worker restarts This will ensure a timeout is set for the next expected push start time. --- packages/job-worker/src/workers/studio/child.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/job-worker/src/workers/studio/child.ts b/packages/job-worker/src/workers/studio/child.ts index 57974fbb73..138bfd10d0 100644 --- a/packages/job-worker/src/workers/studio/child.ts +++ b/packages/job-worker/src/workers/studio/child.ts @@ -1,5 +1,6 @@ import { studioJobHandlers } from './jobs.js' import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' import { MongoClient } from 'mongodb' import { createMongoConnection, getMongoCollections, IDirectCollections } from '../../db/index.js' import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' @@ -75,6 +76,16 @@ export class StudioWorkerChild { } logger.info(`Studio thread for ${this.#studioId} initialised`) + + // Queue initial T-Timer recalculation to set up timers after startup + this.#queueJob( + getStudioQueueName(this.#studioId), + StudioJobs.RecalculateTTimerEstimates, + undefined, + undefined + ).catch((err) => { + logger.error(`Failed to queue initial T-Timer recalculation: ${err}`) + }) } async lockChange(lockId: string, locked: boolean): Promise { if (!this.#staticData) throw new Error('Worker not initialised') From ea990195a81b3c90f696549919289cb903a8eec1 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 16:10:48 +0000 Subject: [PATCH 06/11] feat(blueprints): Add blueprint interface methods for T-Timer estimate management Add three new methods to IPlaylistTTimer interface: - clearEstimate() - Clear both manual estimates and anchor parts - setEstimateAnchorPart(partId) - Set anchor part for automatic calculation - setEstimateTime(time, paused?) - Manually set estimate as timestamp - setEstimateDuration(duration, paused?) - Manually set estimate as duration When anchor part is set, automatically queues RecalculateTTimerEstimates job. Manual estimates clear anchor parts and vice versa. Updated TTimersService to accept JobContext for job queueing capability. Updated all blueprint context instantiations and tests. --- .../src/context/tTimersContext.ts | 36 ++++ .../blueprints/context/OnSetAsNextContext.ts | 2 +- .../src/blueprints/context/OnTakeContext.ts | 2 +- .../context/RundownActivationContext.ts | 2 +- .../SyncIngestUpdateToPartInstanceContext.ts | 15 +- .../src/blueprints/context/adlibActions.ts | 2 +- .../context/services/TTimersService.ts | 90 ++++++++- .../services/__tests__/TTimersService.test.ts | 188 ++++++++++++------ .../src/ingest/syncChangesToPartInstance.ts | 1 + 9 files changed, 263 insertions(+), 75 deletions(-) diff --git a/packages/blueprints-integration/src/context/tTimersContext.ts b/packages/blueprints-integration/src/context/tTimersContext.ts index 8747f450a2..cce8ca198d 100644 --- a/packages/blueprints-integration/src/context/tTimersContext.ts +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -71,6 +71,42 @@ export interface IPlaylistTTimer { * @returns True if the timer was restarted, false if it could not be restarted */ restart(): boolean + + /** + * Clear any estimate (manual or anchor-based) for this timer + * This removes both manual estimates set via setEstimateTime/setEstimateDuration + * and automatic estimates based on anchor parts set via setEstimateAnchorPart. + */ + clearEstimate(): void + + /** + * Set the anchor part for automatic estimate calculation + * When set, the server automatically calculates when we expect to reach this part + * based on remaining part durations, and updates the estimate accordingly. + * Clears any manual estimate set via setEstimateTime/setEstimateDuration. + * @param partId The ID of the part to use as timing anchor + */ + setEstimateAnchorPart(partId: string): void + + /** + * Manually set the estimate as an absolute timestamp + * Use this when you have custom logic for calculating when you expect to reach a timing point. + * Clears any anchor part set via setAnchorPart. + * @param time Unix timestamp (milliseconds) when we expect to reach the timing point + * @param paused If true, we're currently delayed/pushing (estimate won't update with time passing). + * If false (default), we're progressing normally (estimate counts down in real-time). + */ + setEstimateTime(time: number, paused?: boolean): void + + /** + * Manually set the estimate as a relative duration from now + * Use this when you want to express the estimate as "X milliseconds from now". + * Clears any anchor part set via setAnchorPart. + * @param duration Milliseconds until we expect to reach the timing point + * @param paused If true, we're currently delayed/pushing (estimate won't update with time passing). + * If false (default), we're progressing normally (estimate counts down in real-time). + */ + setEstimateDuration(duration: number, paused?: boolean): void } export type IPlaylistTTimerState = diff --git a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts index 0e2f530946..2a9ff33ad9 100644 --- a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts +++ b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts @@ -50,7 +50,7 @@ export class OnSetAsNextContext public readonly manuallySelected: boolean ) { super(contextInfo, context, showStyle, watchedPackages) - this.#tTimersService = TTimersService.withPlayoutModel(playoutModel) + this.#tTimersService = TTimersService.withPlayoutModel(playoutModel, context) } public get quickLoopInfo(): BlueprintQuickLookInfo | null { diff --git a/packages/job-worker/src/blueprints/context/OnTakeContext.ts b/packages/job-worker/src/blueprints/context/OnTakeContext.ts index f403d33723..dbf70196b5 100644 --- a/packages/job-worker/src/blueprints/context/OnTakeContext.ts +++ b/packages/job-worker/src/blueprints/context/OnTakeContext.ts @@ -66,7 +66,7 @@ export class OnTakeContext extends ShowStyleUserContext implements IOnTakeContex ) { super(contextInfo, _context, showStyle, watchedPackages) this.isTakeAborted = false - this.#tTimersService = TTimersService.withPlayoutModel(_playoutModel) + this.#tTimersService = TTimersService.withPlayoutModel(_playoutModel, _context) } async getUpcomingParts(limit: number = 5): Promise> { diff --git a/packages/job-worker/src/blueprints/context/RundownActivationContext.ts b/packages/job-worker/src/blueprints/context/RundownActivationContext.ts index 3f0b47cc1d..0e631d8833 100644 --- a/packages/job-worker/src/blueprints/context/RundownActivationContext.ts +++ b/packages/job-worker/src/blueprints/context/RundownActivationContext.ts @@ -48,7 +48,7 @@ export class RundownActivationContext extends RundownEventContext implements IRu this._previousState = options.previousState this._currentState = options.currentState - this.#tTimersService = TTimersService.withPlayoutModel(this._playoutModel) + this.#tTimersService = TTimersService.withPlayoutModel(this._playoutModel, this._context) } get previousState(): IRundownActivationContextState { diff --git a/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts b/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts index 3bbec8cdaa..61e2dcb486 100644 --- a/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts +++ b/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts @@ -3,6 +3,7 @@ import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceIns import { normalizeArrayToMap, omit } from '@sofie-automation/corelib/dist/lib' import { protectString, protectStringArray, unprotectStringArray } from '@sofie-automation/corelib/dist/protectedString' import { PlayoutPartInstanceModel } from '../../playout/model/PlayoutPartInstanceModel.js' +import { PlayoutModel } from '../../playout/model/PlayoutModel.js' import { ReadonlyDeep } from 'type-fest' import _ from 'underscore' import { ContextInfo } from './CommonContext.js' @@ -45,6 +46,7 @@ export class SyncIngestUpdateToPartInstanceContext implements ISyncIngestUpdateToPartInstanceContext { readonly #context: JobContext + readonly #playoutModel: PlayoutModel readonly #proposedPieceInstances: Map> readonly #tTimersService: TTimersService readonly #changedTTimers = new Map() @@ -61,6 +63,7 @@ export class SyncIngestUpdateToPartInstanceContext constructor( context: JobContext, + playoutModel: PlayoutModel, contextInfo: ContextInfo, studio: ReadonlyDeep, showStyleCompound: ReadonlyDeep, @@ -80,12 +83,18 @@ export class SyncIngestUpdateToPartInstanceContext ) this.#context = context + this.#playoutModel = playoutModel this.#partInstance = partInstance this.#proposedPieceInstances = normalizeArrayToMap(proposedPieceInstances, '_id') - this.#tTimersService = new TTimersService(playlist.tTimers, (updatedTimer) => { - this.#changedTTimers.set(updatedTimer.index, updatedTimer) - }) + this.#tTimersService = new TTimersService( + playlist.tTimers, + (updatedTimer) => { + this.#changedTTimers.set(updatedTimer.index, updatedTimer) + }, + this.#playoutModel, + this.#context + ) } getTimer(index: RundownTTimerIndex): IPlaylistTTimer { diff --git a/packages/job-worker/src/blueprints/context/adlibActions.ts b/packages/job-worker/src/blueprints/context/adlibActions.ts index 8c41cc7d7d..0544c90ecd 100644 --- a/packages/job-worker/src/blueprints/context/adlibActions.ts +++ b/packages/job-worker/src/blueprints/context/adlibActions.ts @@ -117,7 +117,7 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct private readonly partAndPieceInstanceService: PartAndPieceInstanceActionService ) { super(contextInfo, _context, showStyle, watchedPackages) - this.#tTimersService = TTimersService.withPlayoutModel(_playoutModel) + this.#tTimersService = TTimersService.withPlayoutModel(_playoutModel, _context) } async getUpcomingParts(limit: number = 5): Promise> { diff --git a/packages/job-worker/src/blueprints/context/services/TTimersService.ts b/packages/job-worker/src/blueprints/context/services/TTimersService.ts index b1eeafd49c..aee1064e57 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -3,7 +3,10 @@ import type { IPlaylistTTimerState, } from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' import type { RundownTTimer, RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { assertNever } from '@sofie-automation/corelib/dist/lib' +import type { TimerState } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import type { PartId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { assertNever, literal } from '@sofie-automation/corelib/dist/lib' +import { protectString } from '@sofie-automation/corelib/dist/protectedString' import type { PlayoutModel } from '../../../playout/model/PlayoutModel.js' import { ReadonlyDeep } from 'type-fest' import { @@ -14,27 +17,36 @@ import { restartTTimer, resumeTTimer, validateTTimerIndex, + recalculateTTimerEstimates, } from '../../../playout/tTimers.js' import { getCurrentTime } from '../../../lib/time.js' +import type { JobContext } from '../../../jobs/index.js' export class TTimersService { readonly timers: [PlaylistTTimerImpl, PlaylistTTimerImpl, PlaylistTTimerImpl] constructor( timers: ReadonlyDeep, - emitChange: (updatedTimer: ReadonlyDeep) => void + emitChange: (updatedTimer: ReadonlyDeep) => void, + playoutModel: PlayoutModel, + jobContext: JobContext ) { this.timers = [ - new PlaylistTTimerImpl(timers[0], emitChange), - new PlaylistTTimerImpl(timers[1], emitChange), - new PlaylistTTimerImpl(timers[2], emitChange), + new PlaylistTTimerImpl(timers[0], emitChange, playoutModel, jobContext), + new PlaylistTTimerImpl(timers[1], emitChange, playoutModel, jobContext), + new PlaylistTTimerImpl(timers[2], emitChange, playoutModel, jobContext), ] } - static withPlayoutModel(playoutModel: PlayoutModel): TTimersService { - return new TTimersService(playoutModel.playlist.tTimers, (updatedTimer) => { - playoutModel.updateTTimer(updatedTimer) - }) + static withPlayoutModel(playoutModel: PlayoutModel, jobContext: JobContext): TTimersService { + return new TTimersService( + playoutModel.playlist.tTimers, + (updatedTimer) => { + playoutModel.updateTTimer(updatedTimer) + }, + playoutModel, + jobContext + ) } getTimer(index: RundownTTimerIndex): IPlaylistTTimer { @@ -50,6 +62,8 @@ export class TTimersService { export class PlaylistTTimerImpl implements IPlaylistTTimer { readonly #emitChange: (updatedTimer: ReadonlyDeep) => void + readonly #playoutModel: PlayoutModel + readonly #jobContext: JobContext #timer: ReadonlyDeep @@ -96,9 +110,18 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { } } - constructor(timer: ReadonlyDeep, emitChange: (updatedTimer: ReadonlyDeep) => void) { + constructor( + timer: ReadonlyDeep, + emitChange: (updatedTimer: ReadonlyDeep) => void, + playoutModel: PlayoutModel, + jobContext: JobContext + ) { this.#timer = timer this.#emitChange = emitChange + this.#playoutModel = playoutModel + this.#jobContext = jobContext + + validateTTimerIndex(timer.index) } setLabel(label: string): void { @@ -168,4 +191,51 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { this.#emitChange(newTimer) return true } + + clearEstimate(): void { + this.#timer = { + ...this.#timer, + anchorPartId: undefined, + estimateState: undefined, + } + this.#emitChange(this.#timer) + } + + setEstimateAnchorPart(partId: string): void { + this.#timer = { + ...this.#timer, + anchorPartId: protectString(partId), + estimateState: undefined, // Clear manual estimate + } + this.#emitChange(this.#timer) + + // Recalculate estimates immediately since we already have the playout model + recalculateTTimerEstimates(this.#jobContext, this.#playoutModel) + } + + setEstimateTime(time: number, paused: boolean = false): void { + const estimateState: TimerState = paused + ? literal({ paused: true, duration: time - getCurrentTime() }) + : literal({ paused: false, zeroTime: time }) + + this.#timer = { + ...this.#timer, + anchorPartId: undefined, // Clear automatic anchor + estimateState, + } + this.#emitChange(this.#timer) + } + + setEstimateDuration(duration: number, paused: boolean = false): void { + const estimateState: TimerState = paused + ? literal({ paused: true, duration }) + : literal({ paused: false, zeroTime: getCurrentTime() + duration }) + + this.#timer = { + ...this.#timer, + anchorPartId: undefined, // Clear automatic anchor + estimateState, + } + this.#emitChange(this.#timer) + } } diff --git a/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts index 2fe7a21b29..9f8355cac6 100644 --- a/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts +++ b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts @@ -6,6 +6,11 @@ import type { RundownTTimer, RundownTTimerIndex } from '@sofie-automation/coreli import type { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { mock, MockProxy } from 'jest-mock-extended' import type { ReadonlyDeep } from 'type-fest' +import type { JobContext } from '../../../../jobs/index.js' + +function createMockJobContext(): MockProxy { + return mock() +} function createMockPlayoutModel(tTimers: [RundownTTimer, RundownTTimer, RundownTTimer]): MockProxy { const mockPlayoutModel = mock() @@ -42,8 +47,10 @@ describe('TTimersService', () => { it('should create three timer instances', () => { const timers = createEmptyTTimers() const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(timers) + const mockJobContext = createMockJobContext() - const service = new TTimersService(timers, updateFn) + const service = new TTimersService(timers, updateFn, mockPlayoutModel, mockJobContext) expect(service.timers).toHaveLength(3) expect(service.timers[0]).toBeInstanceOf(PlaylistTTimerImpl) @@ -54,8 +61,9 @@ describe('TTimersService', () => { it('from playout model', () => { const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) + const mockJobContext = createMockJobContext() - const service = TTimersService.withPlayoutModel(mockPlayoutModel) + const service = TTimersService.withPlayoutModel(mockPlayoutModel, mockJobContext) expect(service.timers).toHaveLength(3) const timer = service.getTimer(1) @@ -71,8 +79,10 @@ describe('TTimersService', () => { it('should return the correct timer for index 1', () => { const timers = createEmptyTTimers() const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(timers) + const mockJobContext = createMockJobContext() - const service = new TTimersService(timers, updateFn) + const service = new TTimersService(timers, updateFn, mockPlayoutModel, mockJobContext) const timer = service.getTimer(1) @@ -82,8 +92,10 @@ describe('TTimersService', () => { it('should return the correct timer for index 2', () => { const timers = createEmptyTTimers() const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(timers) + const mockJobContext = createMockJobContext() - const service = new TTimersService(timers, updateFn) + const service = new TTimersService(timers, updateFn, mockPlayoutModel, mockJobContext) const timer = service.getTimer(2) @@ -93,8 +105,10 @@ describe('TTimersService', () => { it('should return the correct timer for index 3', () => { const timers = createEmptyTTimers() const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(timers) + const mockJobContext = createMockJobContext() - const service = new TTimersService(timers, updateFn) + const service = new TTimersService(timers, updateFn, mockPlayoutModel, mockJobContext) const timer = service.getTimer(3) @@ -104,8 +118,10 @@ describe('TTimersService', () => { it('should throw for invalid index', () => { const timers = createEmptyTTimers() const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(timers) + const mockJobContext = createMockJobContext() - const service = new TTimersService(timers, updateFn) + const service = new TTimersService(timers, updateFn, mockPlayoutModel, mockJobContext) expect(() => service.getTimer(0 as RundownTTimerIndex)).toThrow('T-timer index out of range: 0') expect(() => service.getTimer(4 as RundownTTimerIndex)).toThrow('T-timer index out of range: 4') @@ -120,10 +136,11 @@ describe('TTimersService', () => { tTimers[1].mode = { type: 'countdown', duration: 60000, stopAtZero: true } tTimers[1].state = { paused: false, zeroTime: 65000 } - const timers = createEmptyTTimers() const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() - const service = new TTimersService(timers, updateFn) + const service = new TTimersService(tTimers, updateFn, mockPlayoutModel, mockJobContext) service.clearAllTimers() @@ -149,7 +166,9 @@ describe('PlaylistTTimerImpl', () => { it('should return the correct index', () => { const tTimers = createEmptyTTimers() const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[1], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[1], updateFn, mockPlayoutModel, mockJobContext) expect(timer.index).toBe(2) }) @@ -158,16 +177,19 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[1].label = 'Custom Label' const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[1], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[1], updateFn, mockPlayoutModel, mockJobContext) expect(timer.label).toBe('Custom Label') }) it('should return null state when no mode is set', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(timer.state).toBeNull() }) @@ -177,7 +199,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 15000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(timer.state).toEqual({ mode: 'freeRun', @@ -191,7 +215,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: true, duration: 3000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(timer.state).toEqual({ mode: 'freeRun', @@ -209,7 +235,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: false, zeroTime: 15000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(timer.state).toEqual({ mode: 'countdown', @@ -229,7 +257,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: true, duration: 2000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(timer.state).toEqual({ mode: 'countdown', @@ -249,7 +279,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: false, zeroTime: 20000 } // 10 seconds in the future const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(timer.state).toEqual({ mode: 'timeOfDay', @@ -270,7 +302,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: false, zeroTime: targetTimestamp } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(timer.state).toEqual({ mode: 'timeOfDay', @@ -285,9 +319,10 @@ describe('PlaylistTTimerImpl', () => { describe('setLabel', () => { it('should update the label', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.setLabel('New Label') @@ -306,7 +341,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 5000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.clearTimer() @@ -322,9 +359,10 @@ describe('PlaylistTTimerImpl', () => { describe('startCountdown', () => { it('should start a running countdown with default options', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.startCountdown(60000) @@ -342,9 +380,10 @@ describe('PlaylistTTimerImpl', () => { it('should start a paused countdown', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.startCountdown(30000, { startPaused: true, stopAtZero: false }) @@ -364,9 +403,10 @@ describe('PlaylistTTimerImpl', () => { describe('startFreeRun', () => { it('should start a running free-run timer', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.startFreeRun() @@ -382,9 +422,10 @@ describe('PlaylistTTimerImpl', () => { it('should start a paused free-run timer', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.startFreeRun({ startPaused: true }) @@ -402,9 +443,10 @@ describe('PlaylistTTimerImpl', () => { describe('startTimeOfDay', () => { it('should start a timeOfDay timer with time string', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.startTimeOfDay('15:30') @@ -425,9 +467,10 @@ describe('PlaylistTTimerImpl', () => { it('should start a timeOfDay timer with numeric timestamp', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const targetTimestamp = 1737331200000 timer.startTimeOfDay(targetTimestamp) @@ -449,9 +492,10 @@ describe('PlaylistTTimerImpl', () => { it('should start a timeOfDay timer with stopAtZero false', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.startTimeOfDay('18:00', { stopAtZero: false }) @@ -472,9 +516,10 @@ describe('PlaylistTTimerImpl', () => { it('should start a timeOfDay timer with 12-hour format', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) timer.startTimeOfDay('5:30pm') @@ -495,18 +540,20 @@ describe('PlaylistTTimerImpl', () => { it('should throw for invalid time string', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(() => timer.startTimeOfDay('invalid')).toThrow('Unable to parse target time for timeOfDay T-timer') }) it('should throw for empty time string', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) expect(() => timer.startTimeOfDay('')).toThrow('Unable to parse target time for timeOfDay T-timer') }) @@ -518,7 +565,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 5000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.pause() @@ -538,7 +587,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'countdown', duration: 60000, stopAtZero: true } tTimers[0].state = { paused: false, zeroTime: 70000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.pause() @@ -557,9 +608,10 @@ describe('PlaylistTTimerImpl', () => { it('should return false for timer with no mode', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.pause() @@ -576,7 +628,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: false, zeroTime: 20000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.pause() @@ -591,7 +645,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: true, duration: -3000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.resume() @@ -611,7 +667,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 5000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.resume() @@ -622,9 +680,10 @@ describe('PlaylistTTimerImpl', () => { it('should return false for timer with no mode', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.resume() @@ -641,7 +700,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: false, zeroTime: 20000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.resume() @@ -656,7 +717,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'countdown', duration: 60000, stopAtZero: true } tTimers[0].state = { paused: false, zeroTime: 40000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.restart() @@ -682,7 +745,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: true, duration: 15000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.restart() @@ -704,7 +769,9 @@ describe('PlaylistTTimerImpl', () => { tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 5000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.restart() @@ -721,7 +788,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: false, zeroTime: 5000 } // old target time const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.restart() @@ -750,7 +819,9 @@ describe('PlaylistTTimerImpl', () => { } tTimers[0].state = { paused: false, zeroTime: 5000 } const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.restart() @@ -760,9 +831,10 @@ describe('PlaylistTTimerImpl', () => { it('should return false for timer with no mode', () => { const tTimers = createEmptyTTimers() - const updateFn = jest.fn() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) const result = timer.restart() diff --git a/packages/job-worker/src/ingest/syncChangesToPartInstance.ts b/packages/job-worker/src/ingest/syncChangesToPartInstance.ts index afee746ca2..41de01b1bf 100644 --- a/packages/job-worker/src/ingest/syncChangesToPartInstance.ts +++ b/packages/job-worker/src/ingest/syncChangesToPartInstance.ts @@ -130,6 +130,7 @@ export class SyncChangesToPartInstancesWorker { const syncContext = new SyncIngestUpdateToPartInstanceContext( this.#context, + this.#playoutModel, { name: `Update to ${existingPartInstance.partInstance.part.externalId}`, identifier: `rundownId=${existingPartInstance.partInstance.part.rundownId},segmentId=${existingPartInstance.partInstance.part.segmentId}`, From fa73e101ce530a67f45d69bf6006dcf325b6d190 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Tue, 17 Feb 2026 22:24:02 +0000 Subject: [PATCH 07/11] feat: Add ignoreQuickLoop parameter to getOrderedPartsAfterPlayhead function --- packages/job-worker/src/playout/lookahead/util.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/job-worker/src/playout/lookahead/util.ts b/packages/job-worker/src/playout/lookahead/util.ts index 72bb201dc6..99d692d259 100644 --- a/packages/job-worker/src/playout/lookahead/util.ts +++ b/packages/job-worker/src/playout/lookahead/util.ts @@ -34,11 +34,16 @@ export function isPieceInstance(piece: Piece | PieceInstance | PieceInstancePiec /** * Excludes the previous, current and next part + * @param context Job context + * @param playoutModel The playout model + * @param partCount Maximum number of parts to return + * @param ignoreQuickLoop If true, ignores quickLoop markers and returns parts in linear order. Defaults to false for backwards compatibility. */ export function getOrderedPartsAfterPlayhead( context: JobContext, playoutModel: PlayoutModel, - partCount: number + partCount: number, + ignoreQuickLoop: boolean = false ): ReadonlyDeep[] { if (partCount <= 0) { return [] @@ -66,7 +71,7 @@ export function getOrderedPartsAfterPlayhead( null, orderedSegments, orderedParts, - { ignoreUnplayable: true, ignoreQuickLoop: false } + { ignoreUnplayable: true, ignoreQuickLoop } ) if (!nextNextPart) { // We don't know where to begin searching, so we can't do anything From b3dc403749943ce3b2d3d4d53d6e7d89791591dd Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Tue, 17 Feb 2026 22:24:19 +0000 Subject: [PATCH 08/11] feat: Refactor recalculateTTimerEstimates to use getOrderedPartsAfterPlayhead for improved part iteration --- packages/job-worker/src/playout/tTimers.ts | 28 ++++------------------ 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index 15f2e27a37..0615294d71 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -8,13 +8,13 @@ import { literal } from '@sofie-automation/corelib/dist/lib' import { getCurrentTime } from '../lib/index.js' import type { ReadonlyDeep } from 'type-fest' import * as chrono from 'chrono-node' -import { isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part' import { PartId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { JobContext } from '../jobs/index.js' import { PlayoutModel } from './model/PlayoutModel.js' import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' import { logger } from '../logging.js' import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' +import { getOrderedPartsAfterPlayhead } from './lookahead/util.js' /** * Map of active setTimeout timeouts by studioId @@ -225,28 +225,13 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl } const currentPartInstance = playoutModel.currentPartInstance?.partInstance - const nextPartInstance = playoutModel.nextPartInstance?.partInstance - // Get ordered parts to iterate through + // Get ordered parts after playhead (excludes previous, current, and next) + // Use ignoreQuickLoop=true to count parts linearly without loop-back behavior const orderedParts = playoutModel.getAllOrderedParts() + const playablePartsSlice = getOrderedPartsAfterPlayhead(context, playoutModel, orderedParts.length, true) - // Start from next part if available, otherwise current, otherwise first playable part - let startPartIndex: number | undefined - if (nextPartInstance) { - // We have a next part selected, start from there - startPartIndex = orderedParts.findIndex((p) => p._id === nextPartInstance.part._id) - } else if (currentPartInstance) { - // No next, but we have current - start from the part after current - const currentIndex = orderedParts.findIndex((p) => p._id === currentPartInstance.part._id) - if (currentIndex >= 0 && currentIndex < orderedParts.length - 1) { - startPartIndex = currentIndex + 1 - } - } - - // If we couldn't find a starting point, start from the first playable part - startPartIndex ??= orderedParts.findIndex((p) => isPartPlayable(p)) - - if (startPartIndex === undefined || startPartIndex < 0) { + if (playablePartsSlice.length === 0 && !currentPartInstance) { // No parts to iterate through, clear estimates for (const timer of tTimers) { if (timer.anchorPartId) { @@ -257,9 +242,6 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl return } - // Iterate through parts and accumulate durations - const playablePartsSlice = orderedParts.slice(startPartIndex).filter((p) => isPartPlayable(p)) - const now = getCurrentTime() let accumulatedDuration = 0 From 7cfea58be2176770e8c814f78e14ca255a4ab50b Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 22:49:19 +0000 Subject: [PATCH 09/11] test: Add tests for new T-Timers functions --- .../services/__tests__/TTimersService.test.ts | 224 ++++++++++++++++++ .../src/playout/__tests__/tTimersJobs.test.ts | 211 +++++++++++++++++ 2 files changed, 435 insertions(+) create mode 100644 packages/job-worker/src/playout/__tests__/tTimersJobs.test.ts diff --git a/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts index 9f8355cac6..8922d386cc 100644 --- a/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts +++ b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts @@ -842,4 +842,228 @@ describe('PlaylistTTimerImpl', () => { expect(updateFn).not.toHaveBeenCalled() }) }) + + describe('clearEstimate', () => { + it('should clear both anchorPartId and estimateState', () => { + const tTimers = createEmptyTTimers() + tTimers[0].anchorPartId = 'part1' as any + tTimers[0].estimateState = { paused: false, zeroTime: 50000 } + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.clearEstimate() + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + estimateState: undefined, + }) + }) + + it('should work when estimates are already cleared', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.clearEstimate() + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + estimateState: undefined, + }) + }) + }) + + describe('setEstimateAnchorPart', () => { + it('should set anchorPartId and clear estimateState', () => { + const tTimers = createEmptyTTimers() + tTimers[0].estimateState = { paused: false, zeroTime: 50000 } + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateAnchorPart('part123') + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: 'part123', + estimateState: undefined, + }) + }) + + it('should not queue job or throw error', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + // Should not throw + expect(() => timer.setEstimateAnchorPart('part456')).not.toThrow() + + // Job queue should not be called (recalculate is called directly) + expect(mockJobContext.queueStudioJob).not.toHaveBeenCalled() + }) + }) + + describe('setEstimateTime', () => { + it('should set estimateState with absolute time (not paused)', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateTime(50000, false) + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + estimateState: { paused: false, zeroTime: 50000 }, + }) + }) + + it('should set estimateState with absolute time (paused)', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateTime(50000, true) + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + estimateState: { paused: true, duration: 40000 }, // 50000 - 10000 (current time) + }) + }) + + it('should clear anchorPartId when setting manual estimate', () => { + const tTimers = createEmptyTTimers() + tTimers[0].anchorPartId = 'part1' as any + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateTime(50000) + + expect(updateFn).toHaveBeenCalledWith( + expect.objectContaining({ + anchorPartId: undefined, + }) + ) + }) + + it('should default paused to false when not provided', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateTime(50000) + + expect(updateFn).toHaveBeenCalledWith( + expect.objectContaining({ + estimateState: { paused: false, zeroTime: 50000 }, + }) + ) + }) + }) + + describe('setEstimateDuration', () => { + it('should set estimateState with relative duration (not paused)', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateDuration(30000, false) + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + estimateState: { paused: false, zeroTime: 40000 }, // 10000 (current) + 30000 (duration) + }) + }) + + it('should set estimateState with relative duration (paused)', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateDuration(30000, true) + + expect(updateFn).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + state: null, + anchorPartId: undefined, + estimateState: { paused: true, duration: 30000 }, + }) + }) + + it('should clear anchorPartId when setting manual estimate', () => { + const tTimers = createEmptyTTimers() + tTimers[0].anchorPartId = 'part1' as any + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateDuration(30000) + + expect(updateFn).toHaveBeenCalledWith( + expect.objectContaining({ + anchorPartId: undefined, + }) + ) + }) + + it('should default paused to false when not provided', () => { + const tTimers = createEmptyTTimers() + const updateFn = jest.fn() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const mockJobContext = createMockJobContext() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) + + timer.setEstimateDuration(30000) + + expect(updateFn).toHaveBeenCalledWith( + expect.objectContaining({ + estimateState: { paused: false, zeroTime: 40000 }, + }) + ) + }) + }) }) diff --git a/packages/job-worker/src/playout/__tests__/tTimersJobs.test.ts b/packages/job-worker/src/playout/__tests__/tTimersJobs.test.ts new file mode 100644 index 0000000000..e6623a952b --- /dev/null +++ b/packages/job-worker/src/playout/__tests__/tTimersJobs.test.ts @@ -0,0 +1,211 @@ +import { setupDefaultJobEnvironment, MockJobContext } from '../../__mocks__/context.js' +import { handleRecalculateTTimerEstimates } from '../tTimersJobs.js' +import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { literal } from '@sofie-automation/corelib/dist/lib' + +describe('tTimersJobs', () => { + let context: MockJobContext + + beforeEach(() => { + context = setupDefaultJobEnvironment() + }) + + describe('handleRecalculateTTimerEstimates', () => { + it('should handle studio with active playlists', async () => { + // Create an active playlist + const playlistId = protectString('playlist1') + + await context.directCollections.RundownPlaylists.insertOne( + literal({ + _id: playlistId, + externalId: 'test', + studioId: context.studioId, + name: 'Test Playlist', + created: 0, + modified: 0, + currentPartInfo: null, + nextPartInfo: null, + previousPartInfo: null, + rundownIdsInOrder: [], + timing: { + type: 'none' as any, + }, + activationId: protectString('activation1'), + rehearsal: false, + holdState: undefined, + tTimers: [ + { + index: 1, + label: 'Timer 1', + mode: null, + state: null, + }, + { + index: 2, + label: 'Timer 2', + mode: null, + state: null, + }, + { + index: 3, + label: 'Timer 3', + mode: null, + state: null, + }, + ], + }) + ) + + // Should complete without errors + await expect(handleRecalculateTTimerEstimates(context)).resolves.toBeUndefined() + }) + + it('should handle studio with no active playlists', async () => { + // Create an inactive playlist + const playlistId = protectString('playlist1') + + await context.directCollections.RundownPlaylists.insertOne( + literal({ + _id: playlistId, + externalId: 'test', + studioId: context.studioId, + name: 'Inactive Playlist', + created: 0, + modified: 0, + currentPartInfo: null, + nextPartInfo: null, + previousPartInfo: null, + rundownIdsInOrder: [], + timing: { + type: 'none' as any, + }, + activationId: undefined, // Not active + rehearsal: false, + holdState: undefined, + tTimers: [ + { + index: 1, + label: 'Timer 1', + mode: null, + state: null, + }, + { + index: 2, + label: 'Timer 2', + mode: null, + state: null, + }, + { + index: 3, + label: 'Timer 3', + mode: null, + state: null, + }, + ], + }) + ) + + // Should complete without errors (just does nothing) + await expect(handleRecalculateTTimerEstimates(context)).resolves.toBeUndefined() + }) + + it('should handle multiple active playlists', async () => { + // Create multiple active playlists + const playlistId1 = protectString('playlist1') + const playlistId2 = protectString('playlist2') + + await context.directCollections.RundownPlaylists.insertOne( + literal({ + _id: playlistId1, + externalId: 'test1', + studioId: context.studioId, + name: 'Active Playlist 1', + created: 0, + modified: 0, + currentPartInfo: null, + nextPartInfo: null, + previousPartInfo: null, + rundownIdsInOrder: [], + timing: { + type: 'none' as any, + }, + activationId: protectString('activation1'), + rehearsal: false, + holdState: undefined, + tTimers: [ + { + index: 1, + label: 'Timer 1', + mode: null, + state: null, + }, + { + index: 2, + label: 'Timer 2', + mode: null, + state: null, + }, + { + index: 3, + label: 'Timer 3', + mode: null, + state: null, + }, + ], + }) + ) + + await context.directCollections.RundownPlaylists.insertOne( + literal({ + _id: playlistId2, + externalId: 'test2', + studioId: context.studioId, + name: 'Active Playlist 2', + created: 0, + modified: 0, + currentPartInfo: null, + nextPartInfo: null, + previousPartInfo: null, + rundownIdsInOrder: [], + timing: { + type: 'none' as any, + }, + activationId: protectString('activation2'), + rehearsal: false, + holdState: undefined, + tTimers: [ + { + index: 1, + label: 'Timer 1', + mode: null, + state: null, + }, + { + index: 2, + label: 'Timer 2', + mode: null, + state: null, + }, + { + index: 3, + label: 'Timer 3', + mode: null, + state: null, + }, + ], + }) + ) + + // Should complete without errors, processing both playlists + await expect(handleRecalculateTTimerEstimates(context)).resolves.toBeUndefined() + }) + + it('should handle playlist deleted between query and lock', async () => { + // This test is harder to set up properly, but the function should handle it + // by checking if playlist exists after acquiring lock + await expect(handleRecalculateTTimerEstimates(context)).resolves.toBeUndefined() + }) + }) +}) From 6463a63798e90c362d8cb2e47e92a58014200c2f Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 5 Feb 2026 12:05:45 +0000 Subject: [PATCH 10/11] feat(T-Timers): Add segment budget timing support to estimate calculations Implements segment budget timing for T-Timer estimate calculations in recalculateTTimerEstimates(). When a segment has a budgetDuration set, the function now: - Uses the segment budget instead of individual part durations - Tracks budget consumption as parts are traversed - Ignores budget timing if the anchor is within the budget segment (anchor part uses normal part duration timing) This matches the front-end timing behavior in rundownTiming.ts and ensures server-side estimates align with UI countdown calculations for budget-controlled segments. --- packages/job-worker/src/playout/tTimers.ts | 137 +++++++++++++-------- 1 file changed, 89 insertions(+), 48 deletions(-) diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index 0615294d71..b1c9b6192e 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -8,7 +8,7 @@ import { literal } from '@sofie-automation/corelib/dist/lib' import { getCurrentTime } from '../lib/index.js' import type { ReadonlyDeep } from 'type-fest' import * as chrono from 'chrono-node' -import { PartId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PartId, SegmentId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { JobContext } from '../jobs/index.js' import { PlayoutModel } from './model/PlayoutModel.js' import { StudioJobs } from '@sofie-automation/corelib/dist/worker/studio' @@ -183,13 +183,17 @@ export function calculateNextTimeOfDayTarget(targetTime: string | number): numbe } /** - * Recalculate T-Timer estimates based on timing anchors + * Recalculate T-Timer estimates based on timing anchors using segment budget timing. * - * For each T-Timer that has an anchorPartId set, this function: - * 1. Iterates through ordered parts from current/next onwards - * 2. Accumulates expected durations until the anchor part is reached - * 3. Updates estimateState with the calculated duration - * 4. Sets the estimate as running if we're progressing, or paused if pushing (overrunning) + * Uses a single-pass algorithm with two accumulators: + * - totalAccumulator: Accumulated time across completed segments + * - segmentAccumulator: Accumulated time within current segment + * + * At each segment boundary: + * - If segment has a budget → use segment budget duration + * - Otherwise → use accumulated part durations + * + * Handles starting mid-segment with budget by calculating remaining budget time. * * @param context Job context * @param playoutModel The playout model containing the playlist and parts @@ -243,76 +247,113 @@ export function recalculateTTimerEstimates(context: JobContext, playoutModel: Pl } const now = getCurrentTime() - let accumulatedDuration = 0 - // Calculate remaining time for current part - // If not started, treat as if it starts now (elapsed = 0, remaining = full duration) - // Account for playOffset (e.g., from play-from-anywhere feature) + // Initialize accumulators + let totalAccumulator = 0 + let segmentAccumulator = 0 let isPushing = false + let currentSegmentId: SegmentId | undefined = undefined + + // Handle current part/segment if (currentPartInstance) { - const currentPartDuration = - currentPartInstance.part.expectedDurationWithTransition ?? currentPartInstance.part.expectedDuration - if (currentPartDuration) { - const currentPartStartedPlayback = currentPartInstance.timings?.plannedStartedPlayback - const startedPlayback = - currentPartStartedPlayback && currentPartStartedPlayback <= now ? currentPartStartedPlayback : now - const playOffset = currentPartInstance.timings?.playOffset || 0 - const elapsed = now - startedPlayback - playOffset - const remaining = currentPartDuration - elapsed - - isPushing = remaining < 0 - accumulatedDuration = Math.max(0, remaining) - - // Schedule next recalculation for when current part ends (if not pushing and no autoNext) - if (!isPushing && !currentPartInstance.part.autoNext) { - const delay = remaining + 5 // 5ms buffer - const timeoutId = setTimeout(() => { - context.queueStudioJob(StudioJobs.RecalculateTTimerEstimates, undefined, undefined).catch((err) => { - logger.error(`Failed to queue T-Timer recalculation: ${stringifyError(err)}`) - }) - }, delay) - activeTimeouts.set(playlist.studioId, timeoutId) + currentSegmentId = currentPartInstance.segmentId + const currentSegment = playoutModel.findSegment(currentPartInstance.segmentId) + const currentSegmentBudget = currentSegment?.segment.segmentTiming?.budgetDuration + + if (currentSegmentBudget === undefined) { + // Normal part duration timing + const currentPartDuration = + currentPartInstance.part.expectedDurationWithTransition ?? currentPartInstance.part.expectedDuration + if (currentPartDuration) { + const currentPartStartedPlayback = currentPartInstance.timings?.plannedStartedPlayback + const startedPlayback = + currentPartStartedPlayback && currentPartStartedPlayback <= now ? currentPartStartedPlayback : now + const playOffset = currentPartInstance.timings?.playOffset || 0 + const elapsed = now - startedPlayback - playOffset + const remaining = currentPartDuration - elapsed + + isPushing = remaining < 0 + totalAccumulator = Math.max(0, remaining) + } + } else { + // Segment budget timing - we're already inside a budgeted segment + const segmentStartedPlayback = + playlist.segmentsStartedPlayback?.[currentPartInstance.segmentId as unknown as string] + if (segmentStartedPlayback) { + const segmentElapsed = now - segmentStartedPlayback + const remaining = currentSegmentBudget - segmentElapsed + isPushing = remaining < 0 + totalAccumulator = Math.max(0, remaining) + } else { + totalAccumulator = currentSegmentBudget } } + + // Schedule next recalculation + if (!isPushing && !currentPartInstance.part.autoNext) { + const delay = totalAccumulator + 5 + const timeoutId = setTimeout(() => { + context.queueStudioJob(StudioJobs.RecalculateTTimerEstimates, undefined, undefined).catch((err) => { + logger.error(`Failed to queue T-Timer recalculation: ${stringifyError(err)}`) + }) + }, delay) + activeTimeouts.set(playlist.studioId, timeoutId) + } } + // Single pass through parts for (const part of playablePartsSlice) { - // Add this part's expected duration to the accumulator - const partDuration = part.expectedDurationWithTransition ?? part.expectedDuration ?? 0 - accumulatedDuration += partDuration + // Detect segment boundary + if (part.segmentId !== currentSegmentId) { + // Flush previous segment + if (currentSegmentId !== undefined) { + const lastSegment = playoutModel.findSegment(currentSegmentId) + const segmentBudget = lastSegment?.segment.segmentTiming?.budgetDuration + + // Use budget if it exists, otherwise use accumulated part durations + if (segmentBudget !== undefined) { + totalAccumulator += segmentBudget + } else { + totalAccumulator += segmentAccumulator + } + } + + // Reset for new segment + segmentAccumulator = 0 + currentSegmentId = part.segmentId + } - // Check if this part is an anchor for any timer + // Check if this part is an anchor const timersForThisPart = timerAnchors.get(part._id) if (timersForThisPart) { + const anchorTime = totalAccumulator + segmentAccumulator + for (const timerIndex of timersForThisPart) { const timer = tTimers[timerIndex - 1] - // Update the timer's estimate const estimateState: TimerState = isPushing ? literal({ paused: true, - duration: accumulatedDuration, + duration: anchorTime, }) : literal({ paused: false, - zeroTime: now + accumulatedDuration, + zeroTime: now + anchorTime, }) playoutModel.updateTTimer({ ...timer, estimateState }) } - // Remove this anchor since we've processed it + timerAnchors.delete(part._id) } - // Early exit if we've resolved all timers - if (timerAnchors.size === 0) { - break - } + // Accumulate this part's duration + const partDuration = part.expectedDurationWithTransition ?? part.expectedDuration ?? 0 + segmentAccumulator += partDuration } - // Clear estimates for any timers whose anchors weren't found (e.g., anchor is in the past or removed) - // Any remaining entries in timerAnchors are anchors that weren't reached - for (const timerIndices of timerAnchors.values()) { + // Clear estimates for unresolved anchors + for (const [, timerIndices] of timerAnchors.entries()) { for (const timerIndex of timerIndices) { const timer = tTimers[timerIndex - 1] playoutModel.updateTTimer({ ...timer, estimateState: undefined }) From 96601432360457597631c6dc8f1273d32cbcbe87 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 20 Feb 2026 10:51:13 +0000 Subject: [PATCH 11/11] Fix test by adding missing mocks --- .../src/ingest/__tests__/syncChangesToPartInstance.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts index 3f63fe8858..6fd99f4862 100644 --- a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts +++ b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts @@ -118,6 +118,9 @@ describe('SyncChangesToPartInstancesWorker', () => { { findPart: jest.fn(() => undefined), getGlobalPieces: jest.fn(() => []), + getAllOrderedParts: jest.fn(() => []), + getOrderedSegments: jest.fn(() => []), + findAdlibPiece: jest.fn(() => undefined), }, mockOptions )