Skip to content
36 changes: 36 additions & 0 deletions packages/blueprints-integration/src/context/tTimersContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
20 changes: 20 additions & 0 deletions packages/corelib/src/dataModel/RundownPlaylist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions packages/corelib/src/worker/studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReadonlyDeep<IBlueprintPart[]>> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -45,6 +46,7 @@ export class SyncIngestUpdateToPartInstanceContext
implements ISyncIngestUpdateToPartInstanceContext
{
readonly #context: JobContext
readonly #playoutModel: PlayoutModel
readonly #proposedPieceInstances: Map<PieceInstanceId, ReadonlyDeep<PieceInstance>>
readonly #tTimersService: TTimersService
readonly #changedTTimers = new Map<RundownTTimerIndex, RundownTTimer>()
Expand All @@ -61,6 +63,7 @@ export class SyncIngestUpdateToPartInstanceContext

constructor(
context: JobContext,
playoutModel: PlayoutModel,
contextInfo: ContextInfo,
studio: ReadonlyDeep<JobStudio>,
showStyleCompound: ReadonlyDeep<ProcessedShowStyleCompound>,
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/job-worker/src/blueprints/context/adlibActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReadonlyDeep<IBlueprintPart[]>> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<RundownTTimer[]>,
emitChange: (updatedTimer: ReadonlyDeep<RundownTTimer>) => void
emitChange: (updatedTimer: ReadonlyDeep<RundownTTimer>) => 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 {
Expand All @@ -50,6 +62,8 @@ export class TTimersService {

export class PlaylistTTimerImpl implements IPlaylistTTimer {
readonly #emitChange: (updatedTimer: ReadonlyDeep<RundownTTimer>) => void
readonly #playoutModel: PlayoutModel
readonly #jobContext: JobContext

#timer: ReadonlyDeep<RundownTTimer>

Expand Down Expand Up @@ -96,9 +110,18 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer {
}
}

constructor(timer: ReadonlyDeep<RundownTTimer>, emitChange: (updatedTimer: ReadonlyDeep<RundownTTimer>) => void) {
constructor(
timer: ReadonlyDeep<RundownTTimer>,
emitChange: (updatedTimer: ReadonlyDeep<RundownTTimer>) => void,
playoutModel: PlayoutModel,
jobContext: JobContext
) {
this.#timer = timer
this.#emitChange = emitChange
this.#playoutModel = playoutModel
this.#jobContext = jobContext

validateTTimerIndex(timer.index)
}

setLabel(label: string): void {
Expand Down Expand Up @@ -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>(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<TimerState>({ paused: true, duration: time - getCurrentTime() })
: literal<TimerState>({ 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<TimerState>({ paused: true, duration })
: literal<TimerState>({ paused: false, zeroTime: getCurrentTime() + duration })

this.#timer = {
...this.#timer,
anchorPartId: undefined, // Clear automatic anchor
estimateState,
}
this.#emitChange(this.#timer)
}
}
Loading
Loading