Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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