From 0958c2fafd02327f043ebfcca700bf09a4979e1c Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 26 Mar 2026 12:38:52 +0000 Subject: [PATCH 1/4] Move T-Timer types to blueprints integration so they can be shared Instead of having them in corelib --- .../src/context/index.ts | 1 + .../src/context/tTimersContext.ts | 70 +++++++++++++++++ .../corelib/src/dataModel/RundownPlaylist.ts | 78 ++----------------- .../context/services/TTimersService.ts | 7 +- packages/job-worker/src/playout/tTimers.ts | 8 +- 5 files changed, 82 insertions(+), 82 deletions(-) diff --git a/packages/blueprints-integration/src/context/index.ts b/packages/blueprints-integration/src/context/index.ts index a1cba0ab9f..28e9a4ed2d 100644 --- a/packages/blueprints-integration/src/context/index.ts +++ b/packages/blueprints-integration/src/context/index.ts @@ -11,3 +11,4 @@ export * from './rundownContext.js' export * from './showStyleContext.js' export * from './studioContext.js' export * from './syncIngestChangesContext.js' +export * from './tTimersContext.js' diff --git a/packages/blueprints-integration/src/context/tTimersContext.ts b/packages/blueprints-integration/src/context/tTimersContext.ts index 7b00d9258a..19206d91b2 100644 --- a/packages/blueprints-integration/src/context/tTimersContext.ts +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -1,5 +1,75 @@ export type IPlaylistTTimerIndex = 1 | 2 | 3 +export type RundownTTimerMode = RundownTTimerModeFreeRun | RundownTTimerModeCountdown | RundownTTimerModeTimeOfDay + +export interface RundownTTimerModeFreeRun { + readonly type: 'freeRun' +} +export interface RundownTTimerModeCountdown { + readonly type: 'countdown' + /** + * The original duration of the countdown in milliseconds, so that we know what value to reset to + */ + readonly duration: number + + /** + * If the countdown should stop at zero, or continue into negative values + */ + readonly stopAtZero: boolean +} +export interface RundownTTimerModeTimeOfDay { + readonly type: 'timeOfDay' + + /** + * The raw target string of the timer, as provided when setting the timer + * (e.g. "14:30", "2023-12-31T23:59:59Z", or a timestamp number) + */ + readonly targetRaw: string | number + + /** + * If the countdown should stop at zero, or continue into negative values + */ + readonly stopAtZero: boolean +} + +/** + * Timing state for a timer, optimized for efficient client rendering. + * When running, the client calculates current time from zeroTime. + * When paused, the duration is frozen and sent directly. + * pauseTime indicates when the timer should automatically pause (when current part ends and overrun begins). + * + * Client rendering logic: + * ```typescript + * if (state.paused === true) { + * // Manually paused by user or already pushing/overrun + * duration = state.duration + * } else if (state.pauseTime && now >= state.pauseTime) { + * // Auto-pause at overrun (current part ended) + * duration = state.zeroTime - state.pauseTime + * } else { + * // Running normally + * duration = state.zeroTime - now + * } + * ``` + */ +export type TimerState = + | { + /** Whether the timer is paused */ + paused: false + /** The absolute timestamp (ms) when the timer reaches/reached zero */ + zeroTime: number + /** Optional timestamp when the timer should pause (when current part ends) */ + pauseTime?: number | null + } + | { + /** Whether the timer is paused */ + paused: true + /** The frozen duration value in milliseconds */ + duration: number + /** Optional timestamp when the timer should pause (null when already paused/pushing) */ + pauseTime?: number | null + } + export interface ITTimersContext { /** * Get a T-timer by its index diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index 06cf6d3ff5..0641dd44b7 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -1,4 +1,10 @@ -import { Time, TimelinePersistentState, RundownPlaylistTiming } from '@sofie-automation/blueprints-integration' +import { + Time, + TimelinePersistentState, + RundownPlaylistTiming, + RundownTTimerMode, + TimerState, +} from '@sofie-automation/blueprints-integration' import { PartId, PieceInstanceInfiniteId, @@ -94,76 +100,6 @@ export interface QuickLoopProps { forceAutoNext: ForceQuickLoopAutoNext } -export type RundownTTimerMode = RundownTTimerModeFreeRun | RundownTTimerModeCountdown | RundownTTimerModeTimeOfDay - -export interface RundownTTimerModeFreeRun { - readonly type: 'freeRun' -} -export interface RundownTTimerModeCountdown { - readonly type: 'countdown' - /** - * The original duration of the countdown in milliseconds, so that we know what value to reset to - */ - readonly duration: number - - /** - * If the countdown should stop at zero, or continue into negative values - */ - readonly stopAtZero: boolean -} -export interface RundownTTimerModeTimeOfDay { - readonly type: 'timeOfDay' - - /** - * The raw target string of the timer, as provided when setting the timer - * (e.g. "14:30", "2023-12-31T23:59:59Z", or a timestamp number) - */ - readonly targetRaw: string | number - - /** - * If the countdown should stop at zero, or continue into negative values - */ - readonly stopAtZero: boolean -} - -/** - * Timing state for a timer, optimized for efficient client rendering. - * When running, the client calculates current time from zeroTime. - * When paused, the duration is frozen and sent directly. - * pauseTime indicates when the timer should automatically pause (when current part ends and overrun begins). - * - * Client rendering logic: - * ```typescript - * if (state.paused === true) { - * // Manually paused by user or already pushing/overrun - * duration = state.duration - * } else if (state.pauseTime && now >= state.pauseTime) { - * // Auto-pause at overrun (current part ended) - * duration = state.zeroTime - state.pauseTime - * } else { - * // Running normally - * duration = state.zeroTime - now - * } - * ``` - */ -export type TimerState = - | { - /** Whether the timer is paused */ - paused: false - /** The absolute timestamp (ms) when the timer reaches/reached zero */ - zeroTime: number - /** Optional timestamp when the timer should pause (when current part ends) */ - pauseTime?: number | null - } - | { - /** Whether the timer is paused */ - paused: true - /** The frozen duration value in milliseconds */ - duration: number - /** Optional timestamp when the timer should pause (null when already paused/pushing) */ - pauseTime?: number | null - } - /** * Calculate the current duration for a timer state. * Handles paused, auto-pause (pauseTime), and running states. diff --git a/packages/job-worker/src/blueprints/context/services/TTimersService.ts b/packages/job-worker/src/blueprints/context/services/TTimersService.ts index ab0a67452d..834ac3371a 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -1,12 +1,9 @@ import type { IPlaylistTTimer, IPlaylistTTimerState, -} from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' -import type { - RundownTTimer, - RundownTTimerIndex, TimerState, -} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +} from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' +import type { RundownTTimer, RundownTTimerIndex } 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, unprotectString } from '@sofie-automation/corelib/dist/protectedString' diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index dc6d9524a0..09e9dd0057 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -1,9 +1,5 @@ -import type { - RundownTTimerIndex, - RundownTTimerMode, - RundownTTimer, - TimerState, -} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import type { RundownTTimerIndex, RundownTTimer } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import type { RundownTTimerMode, TimerState } from '@sofie-automation/blueprints-integration' import { literal } from '@sofie-automation/corelib/dist/lib' import { getCurrentTime } from '../lib/index.js' import type { ReadonlyDeep } from 'type-fest' From 3d29f1d1cdb2d15f798b543b0d57ad8e25e1dd4c Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Mon, 23 Mar 2026 18:41:53 +0000 Subject: [PATCH 2/4] feat: align T-Timer blueprint API with DB structure Expose mode and state properties separately in IPlaylistTTimer, matching the RundownTTimer database model structure. This removes the computed IPlaylistTTimerState abstraction layer and allows blueprints to access timer.mode and timer.state directly, consistent with how the WebUI already works. Changes: - Add mode: RundownTTimerMode | null to IPlaylistTTimer - Add state: TimerState | null to IPlaylistTTimer - Remove IPlaylistTTimerState union and related interfaces - Simplify PlaylistTTimerImpl getters to return DB properties directly - Remove unnecessary tests --- .../src/context/tTimersContext.ts | 55 ++------ .../context/services/TTimersService.ts | 44 +------ .../services/__tests__/TTimersService.test.ts | 121 ------------------ 3 files changed, 16 insertions(+), 204 deletions(-) diff --git a/packages/blueprints-integration/src/context/tTimersContext.ts b/packages/blueprints-integration/src/context/tTimersContext.ts index 19206d91b2..a93b9ef786 100644 --- a/packages/blueprints-integration/src/context/tTimersContext.ts +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -90,11 +90,19 @@ export interface IPlaylistTTimer { /** The label of the T-timer */ readonly label: string + /** + * The current mode of the T-timer + * Null if the T-timer is not initialized + * This defines how the timer behaves + */ + readonly mode: RundownTTimerMode | null + /** * The current state of the T-timer * Null if the T-timer is not initialized + * This contains the timing information needed to calculate the current time of the timer */ - readonly state: IPlaylistTTimerState | null + readonly state: TimerState | null /** Set the label of the T-timer */ setLabel(label: string): void @@ -187,48 +195,3 @@ export interface IPlaylistTTimer { */ setProjectedDuration(duration: number, paused?: boolean): void } - -export type IPlaylistTTimerState = - | IPlaylistTTimerStateCountdown - | IPlaylistTTimerStateFreeRun - | IPlaylistTTimerStateTimeOfDay - -export interface IPlaylistTTimerStateCountdown { - /** The mode of the T-timer */ - readonly mode: 'countdown' - /** The current time of the countdown, in milliseconds */ - readonly currentTime: number - /** The total duration of the countdown, in milliseconds */ - readonly duration: number - /** Whether the timer is currently paused */ - readonly paused: boolean - - /** If the countdown is set to stop at zero, or continue into negative values */ - readonly stopAtZero: boolean -} -export interface IPlaylistTTimerStateFreeRun { - /** The mode of the T-timer */ - readonly mode: 'freeRun' - /** The current time of the freerun, in milliseconds */ - readonly currentTime: number - /** Whether the timer is currently paused */ - readonly paused: boolean -} - -export interface IPlaylistTTimerStateTimeOfDay { - /** The mode of the T-timer */ - readonly mode: 'timeOfDay' - /** The current remaining time of the timer, in milliseconds */ - readonly currentTime: number - /** The target timestamp of the timer, in milliseconds */ - readonly targetTime: number - - /** - * The raw target string of the timer, as provided when setting the timer - * (e.g. "14:30", "2023-12-31T23:59:59Z", or a timestamp number) - */ - readonly targetRaw: string | number - - /** If the countdown is set to stop at zero, or continue into negative values */ - readonly stopAtZero: boolean -} diff --git a/packages/job-worker/src/blueprints/context/services/TTimersService.ts b/packages/job-worker/src/blueprints/context/services/TTimersService.ts index 834ac3371a..24d14ce865 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -1,11 +1,11 @@ import type { IPlaylistTTimer, - IPlaylistTTimerState, + RundownTTimerMode, TimerState, } from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' import type { RundownTTimer, RundownTTimerIndex } 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 { literal } from '@sofie-automation/corelib/dist/lib' import { protectString, unprotectString } from '@sofie-automation/corelib/dist/protectedString' import type { PlayoutModel } from '../../../playout/model/PlayoutModel.js' import { ReadonlyDeep } from 'type-fest' @@ -73,41 +73,11 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { get label(): string { return this.#timer.label } - get state(): IPlaylistTTimerState | null { - const rawMode = this.#timer.mode - const rawState = this.#timer.state - - if (!rawMode || !rawState) return null - - const currentTime = rawState.paused ? rawState.duration : rawState.zeroTime - getCurrentTime() - - switch (rawMode.type) { - case 'countdown': - return { - mode: 'countdown', - currentTime, - duration: rawMode.duration, - paused: rawState.paused, - stopAtZero: rawMode.stopAtZero, - } - case 'freeRun': - return { - mode: 'freeRun', - currentTime, - paused: rawState.paused, - } - case 'timeOfDay': - return { - mode: 'timeOfDay', - currentTime, - targetTime: rawState.paused ? 0 : rawState.zeroTime, - targetRaw: rawMode.targetRaw, - stopAtZero: rawMode.stopAtZero, - } - default: - assertNever(rawMode) - return null - } + get mode(): RundownTTimerMode | null { + return this.#timer.mode + } + get state(): TimerState | null { + return this.#timer.state } constructor( 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 72236e2d51..d7f5237eb7 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 @@ -193,127 +193,6 @@ describe('PlaylistTTimerImpl', () => { expect(timer.state).toBeNull() }) - - it('should return running freeRun state', () => { - const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun' } - tTimers[0].state = { paused: false, zeroTime: 15000 } - const updateFn = jest.fn() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const mockJobContext = createMockJobContext() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) - - expect(timer.state).toEqual({ - mode: 'freeRun', - currentTime: 5000, // 10000 - 5000 - paused: false, // pauseTime is null = running - }) - }) - - it('should return paused freeRun state', () => { - const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun' } - tTimers[0].state = { paused: true, duration: 3000 } - const updateFn = jest.fn() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const mockJobContext = createMockJobContext() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) - - expect(timer.state).toEqual({ - mode: 'freeRun', - currentTime: 3000, // 8000 - 5000 - paused: true, // pauseTime is set = paused - }) - }) - - it('should return running countdown state', () => { - const tTimers = createEmptyTTimers() - tTimers[0].mode = { - type: 'countdown', - duration: 60000, - stopAtZero: true, - } - tTimers[0].state = { paused: false, zeroTime: 15000 } - const updateFn = jest.fn() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const mockJobContext = createMockJobContext() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) - - expect(timer.state).toEqual({ - mode: 'countdown', - currentTime: 5000, // 10000 - 5000 - duration: 60000, - paused: false, // pauseTime is null = running - stopAtZero: true, - }) - }) - - it('should return paused countdown state', () => { - const tTimers = createEmptyTTimers() - tTimers[0].mode = { - type: 'countdown', - duration: 60000, - stopAtZero: false, - } - tTimers[0].state = { paused: true, duration: 2000 } - const updateFn = jest.fn() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const mockJobContext = createMockJobContext() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) - - expect(timer.state).toEqual({ - mode: 'countdown', - currentTime: 2000, // 7000 - 5000 - duration: 60000, - paused: true, // pauseTime is set = paused - stopAtZero: false, - }) - }) - - it('should return timeOfDay state', () => { - const tTimers = createEmptyTTimers() - tTimers[0].mode = { - type: 'timeOfDay', - targetRaw: '15:30', - stopAtZero: true, - } - tTimers[0].state = { paused: false, zeroTime: 20000 } // 10 seconds in the future - const updateFn = jest.fn() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const mockJobContext = createMockJobContext() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) - - expect(timer.state).toEqual({ - mode: 'timeOfDay', - currentTime: 10000, // targetTime - getCurrentTime() = 20000 - 10000 - targetTime: 20000, - targetRaw: '15:30', - stopAtZero: true, - }) - }) - - it('should return timeOfDay state with numeric targetRaw', () => { - const tTimers = createEmptyTTimers() - const targetTimestamp = 1737331200000 - tTimers[0].mode = { - type: 'timeOfDay', - targetRaw: targetTimestamp, - stopAtZero: false, - } - tTimers[0].state = { paused: false, zeroTime: targetTimestamp } - const updateFn = jest.fn() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const mockJobContext = createMockJobContext() - const timer = new PlaylistTTimerImpl(tTimers[0], updateFn, mockPlayoutModel, mockJobContext) - - expect(timer.state).toEqual({ - mode: 'timeOfDay', - currentTime: targetTimestamp - 10000, // targetTime - getCurrentTime() - targetTime: targetTimestamp, - targetRaw: targetTimestamp, - stopAtZero: false, - }) - }) }) describe('setLabel', () => { From ddcafc8ab6619bc110ec94bc9575ac5fd95f0d40 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 25 Mar 2026 16:49:49 +0000 Subject: [PATCH 3/4] feat(blueprints): Add T-Timer duration/timing methods and expose rundown timing Add comprehensive T-Timer manipulation methods to IPlaylistTTimer interface: - setDuration(duration) - Reset timer to a specific duration - setDuration(options) - Independently update original and/or current duration - original: Duration timer resets to on restart() - current: Current countdown value - Preserves elapsed time when only original is provided Add T-Timer query methods: - getCurrentDuration() - Get current timer value in milliseconds - getZeroTime() - Get absolute timestamp of timer's zero point - getProjectedDuration() - Get projected countdown value (for over/under calculation) - getProjectedZeroTime() - Get projected zero time timestamp Add shared utility functions in corelib: - timerStateToDuration() - Calculate current duration from TimerState (already existed) - timerStateToZeroTime() - Calculate zero time from TimerState (new) - Both shared between backend and frontend for consistent calculations Expose timing information to blueprints: - Add timing field to IBlueprintSegmentRundown interface - Exposes RundownPlaylistTiming via context.rundown.timing - Removes need for accessing private _rundown property Implementation: - PlaylistTTimerImpl implements all new methods using shared utilities - Update convertRundownToBlueprintSegmentRundown() to include timing - All methods properly handle paused/running states and edge cases Related to BBC-SOFIE-454 --- .../src/context/tTimersContext.ts | 57 ++++++++++ .../src/documents/rundown.ts | 3 + .../corelib/src/dataModel/RundownPlaylist.ts | 23 ++++ .../job-worker/src/blueprints/context/lib.ts | 1 + .../context/services/TTimersService.ts | 104 ++++++++++++++++++ 5 files changed, 188 insertions(+) diff --git a/packages/blueprints-integration/src/context/tTimersContext.ts b/packages/blueprints-integration/src/context/tTimersContext.ts index a93b9ef786..aba8e72221 100644 --- a/packages/blueprints-integration/src/context/tTimersContext.ts +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -150,6 +150,29 @@ export interface IPlaylistTTimer { */ restart(): boolean + /** + * Set the duration of a countdown timer + * This resets both the original duration (what restart() resets to) and the current countdown value. + * @param duration New duration in milliseconds + * @throws If timer is not in countdown mode or not initialized + */ + setDuration(duration: number): void + + /** + * Update the original duration (reset-to value) and/or current duration of a countdown timer + * This allows you to independently update: + * - `original`: The duration the timer resets to when restart() is called + * - `current`: The current countdown value (what's displayed now) + * + * If only `original` is provided, the current duration is recalculated to preserve elapsed time. + * If only `current` is provided, just the current countdown is updated. + * If both are provided, both values are updated independently. + * + * @param options Object with optional `original` and/or `current` duration in milliseconds + * @throws If timer is not in countdown mode or not initialized + */ + setDuration(options: { original?: number; current?: number }): void + /** * Clear any projection (manual or anchor-based) for this timer * This removes both manual projections set via setProjectedTime/setProjectedDuration @@ -194,4 +217,38 @@ export interface IPlaylistTTimer { * If false (default), we're progressing normally (projection counts down in real-time). */ setProjectedDuration(duration: number, paused?: boolean): void + + /** + * Get the current duration of the timer in milliseconds + * For countdown timers, this returns how much time is remaining (can be negative if past zero) + * For timeOfDay timers, this returns time until/since the target time + * For freeRun timers, this returns how much time has elapsed + * @returns Current duration in milliseconds, or null if timer is not initialized + */ + getDuration(): number | null + + /** + * Get the zero time (reference point) for the timer + * - For countdown/timeOfDay timers: the absolute timestamp when the timer reaches zero + * - For freeRun timers: the absolute timestamp when the timer started (what it counts from) + * For paused timers, calculates when zero would be if resumed now. + * @returns Unix timestamp in milliseconds, or null if timer is not initialized + */ + getZeroTime(): number | null + + /** + * Get the projected duration in milliseconds + * This returns the projected timer value when we expect to reach the anchor part. + * Used to calculate over/under (how far ahead or behind schedule we are). + * @returns Projected duration in milliseconds, or null if no projection is set + */ + getProjectedDuration(): number | null + + /** + * Get the projected zero time (reference point) + * This returns when we project the timer will reach zero based on scheduled durations. + * For paused projections (when pushing/delayed), calculates when zero would be if resumed now. + * @returns Unix timestamp in milliseconds, or null if no projection is set + */ + getProjectedZeroTime(): number | null } diff --git a/packages/blueprints-integration/src/documents/rundown.ts b/packages/blueprints-integration/src/documents/rundown.ts index 9daa30383a..f8c0840573 100644 --- a/packages/blueprints-integration/src/documents/rundown.ts +++ b/packages/blueprints-integration/src/documents/rundown.ts @@ -55,6 +55,9 @@ export interface IBlueprintRundownDBData { export interface IBlueprintSegmentRundown { externalId: string + /** Rundown timing information */ + timing: RundownPlaylistTiming + /** Arbitraty data storage for internal use in the blueprints */ privateData?: TPrivateData /** Arbitraty data relevant for other systems, made available to them through APIs */ diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index 0641dd44b7..6522b9bdba 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -121,6 +121,29 @@ export function timerStateToDuration(state: TimerState, now: number): number { } } +/** + * Get the zero time (reference timestamp) for a timer state. + * - For countdown/timeOfDay timers: when the timer reaches zero + * - For freeRun timers: when the timer started (what it counts from) + * For paused timers, calculates when zero would be if resumed now. + * + * @param state The timer state + * @param now Current timestamp in milliseconds + * @returns The zero time timestamp in milliseconds + */ +export function timerStateToZeroTime(state: TimerState, now: number): number { + if (state.paused) { + // Calculate when zero would be if we resumed now + return now + state.duration + } else if (state.pauseTime && now >= state.pauseTime) { + // Auto-pause at overrun (current part ended) + return state.zeroTime - state.pauseTime + now + } else { + // Already have the zero time + return state.zeroTime + } +} + export type RundownTTimerIndex = 1 | 2 | 3 export interface RundownTTimer { diff --git a/packages/job-worker/src/blueprints/context/lib.ts b/packages/job-worker/src/blueprints/context/lib.ts index f16ee424c0..513ca6bd12 100644 --- a/packages/job-worker/src/blueprints/context/lib.ts +++ b/packages/job-worker/src/blueprints/context/lib.ts @@ -443,6 +443,7 @@ export function convertRundownToBlueprintSegmentRundown( ): IBlueprintSegmentRundown { const obj: Complete = { externalId: rundown.externalId, + timing: rundown.timing, privateData: skipClone ? rundown.privateData : clone(rundown.privateData), publicData: skipClone ? rundown.publicData : clone(rundown.publicData), } diff --git a/packages/job-worker/src/blueprints/context/services/TTimersService.ts b/packages/job-worker/src/blueprints/context/services/TTimersService.ts index 24d14ce865..94933cf5cd 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -4,6 +4,7 @@ import type { TimerState, } from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' import type { RundownTTimer, RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { timerStateToDuration, timerStateToZeroTime } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import type { PartId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { literal } from '@sofie-automation/corelib/dist/lib' import { protectString, unprotectString } from '@sofie-automation/corelib/dist/protectedString' @@ -162,6 +163,77 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { return true } + setDuration(durationOrOptions: number | { original?: number; current?: number }): void { + // Handle overloaded signatures + if (typeof durationOrOptions === 'number') { + // Simple case: reset timer to this duration + return this.setDuration({ original: durationOrOptions, current: durationOrOptions }) + } + + // Options case: independently update original and/or current + const options = durationOrOptions + + if (options.original !== undefined && options.original <= 0) { + throw new Error('Original duration must be greater than zero') + } + if (options.current !== undefined && options.current <= 0) { + throw new Error('Current duration must be greater than zero') + } + + if (!this.#timer.mode || this.#timer.mode.type !== 'countdown') { + throw new Error('Timer must be in countdown mode to update duration') + } + + if (!this.#timer.state) { + throw new Error('Timer is not initialized') + } + + if (!options.original && !options.current) { + throw new Error('At least one of original or current duration must be provided') + } + + const now = getCurrentTime() + const state = this.#timer.state + + // Calculate current elapsed time using built-in function (handles pauseTime correctly) + const remaining = timerStateToDuration(state, now) + const elapsed = this.#timer.mode.duration - remaining + + let newOriginalDuration: number + let newCurrentRemaining: number + + if (options.original !== undefined && options.current !== undefined) { + // Both specified: use both values independently + newOriginalDuration = options.original + newCurrentRemaining = options.current + } else if (options.original !== undefined) { + // Only original specified: preserve elapsed time + newOriginalDuration = options.original + newCurrentRemaining = Math.max(0, newOriginalDuration - elapsed) + } else if (options.current !== undefined) { + // Only current specified: keep original unchanged + newOriginalDuration = this.#timer.mode.duration + newCurrentRemaining = options.current + } else { + // This should be unreachable due to earlier check + throw new Error('Invalid duration update options') + } + + // Update both mode and state + this.#timer = { + ...this.#timer, + mode: { + ...this.#timer.mode, + duration: newOriginalDuration, + }, + state: state.paused + ? { paused: true, duration: newCurrentRemaining } + : { paused: false, zeroTime: now + newCurrentRemaining }, + } + + this.#emitChange(this.#timer) + } + clearProjected(): void { this.#timer = { ...this.#timer, @@ -215,4 +287,36 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { } this.#emitChange(this.#timer) } + + getDuration(): number | null { + if (!this.#timer.state) { + return null + } + + return timerStateToDuration(this.#timer.state, getCurrentTime()) + } + + getZeroTime(): number | null { + if (!this.#timer.state) { + return null + } + + return timerStateToZeroTime(this.#timer.state, getCurrentTime()) + } + + getProjectedDuration(): number | null { + if (!this.#timer.projectedState) { + return null + } + + return timerStateToDuration(this.#timer.projectedState, getCurrentTime()) + } + + getProjectedZeroTime(): number | null { + if (!this.#timer.projectedState) { + return null + } + + return timerStateToZeroTime(this.#timer.projectedState, getCurrentTime()) + } } From 93069ea8030d31d022c56138ab32b666c24c6a75 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Thu, 26 Mar 2026 20:15:19 +0000 Subject: [PATCH 4/4] feat: expose startedPlayback to blueprint contexts via getter properties Add startedPlayback property to IRundownContext interface and implement as getters in RundownActivationContext and SyncIngestUpdateToPartInstanceContext. This provides blueprints access to playlist.startedPlayback timing data. Part of BBC-SOFIE-454 --- .../blueprints-integration/src/context/rundownContext.ts | 4 ++++ .../src/blueprints/context/RundownActivationContext.ts | 5 +++++ .../context/SyncIngestUpdateToPartInstanceContext.ts | 5 +++++ 3 files changed, 14 insertions(+) diff --git a/packages/blueprints-integration/src/context/rundownContext.ts b/packages/blueprints-integration/src/context/rundownContext.ts index cb57cbd569..702d885bee 100644 --- a/packages/blueprints-integration/src/context/rundownContext.ts +++ b/packages/blueprints-integration/src/context/rundownContext.ts @@ -5,11 +5,15 @@ import type { IShowStyleContext } from './showStyleContext.js' import type { IExecuteTSRActionsContext } from './executeTsrActionContext.js' import type { IDataStoreMethods } from './adlibActionContext.js' import { ITTimersContext } from './tTimersContext.js' +import type { Time } from '../common.js' export interface IRundownContext extends IShowStyleContext { readonly rundownId: string readonly playlistId: string readonly rundown: Readonly + + /** Actual time of playback starting for the playlist (undefined if not started) */ + readonly startedPlayback?: Time } export interface IRundownUserContext extends IUserNotesContext, IRundownContext {} diff --git a/packages/job-worker/src/blueprints/context/RundownActivationContext.ts b/packages/job-worker/src/blueprints/context/RundownActivationContext.ts index 5335d041bc..71cd3bab1e 100644 --- a/packages/job-worker/src/blueprints/context/RundownActivationContext.ts +++ b/packages/job-worker/src/blueprints/context/RundownActivationContext.ts @@ -4,6 +4,7 @@ import { IRundownActivationContext, IRundownActivationContextState, TSR, + Time, } from '@sofie-automation/blueprints-integration' import { PeripheralDeviceId } from '@sofie-automation/shared-lib/dist/core/model/Ids' import { ReadonlyDeep } from 'type-fest' @@ -58,6 +59,10 @@ export class RundownActivationContext extends RundownEventContext implements IRu return this._currentState } + get startedPlayback(): Time | undefined { + return this._playoutModel.playlist.startedPlayback + } + async listPlayoutDevices(): Promise { return listPlayoutDevices(this._context, this._playoutModel) } diff --git a/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts b/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts index 61e2dcb486..6e44cb8771 100644 --- a/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts +++ b/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts @@ -17,6 +17,7 @@ import { IBlueprintPartInstance, SomeContent, WithTimeline, + Time, } from '@sofie-automation/blueprints-integration' import { postProcessPieces, postProcessTimelineObjects } from '../postProcess.js' import { @@ -61,6 +62,10 @@ export class SyncIngestUpdateToPartInstanceContext return Array.from(this.#changedTTimers.values()) } + public get startedPlayback(): Time | undefined { + return this.#playoutModel.playlist.startedPlayback + } + constructor( context: JobContext, playoutModel: PlayoutModel,