Skip to content

Commit 52af9c6

Browse files
committed
Add Take Previous and pause/resume for the on-air clock.
Operators can step back to the previous playable part and freeze the časovka at timings.pausedAt. Pause also stops rolling VT/VO clips.
1 parent 581076c commit 52af9c6

15 files changed

Lines changed: 404 additions & 10 deletions

File tree

meteor/server/api/userActions.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,51 @@ class ServerUserActionAPI
118118
}
119119
)
120120
}
121+
async pauseCurrentPart(userEvent: string, eventTime: Time, rundownPlaylistId: RundownPlaylistId) {
122+
return ServerClientAPI.runUserActionInLogForPlaylistOnWorker(
123+
this,
124+
userEvent,
125+
eventTime,
126+
rundownPlaylistId,
127+
() => {
128+
check(rundownPlaylistId, String)
129+
},
130+
StudioJobs.PauseCurrentPart,
131+
{
132+
playlistId: rundownPlaylistId,
133+
}
134+
)
135+
}
136+
async resumeCurrentPart(userEvent: string, eventTime: Time, rundownPlaylistId: RundownPlaylistId) {
137+
return ServerClientAPI.runUserActionInLogForPlaylistOnWorker(
138+
this,
139+
userEvent,
140+
eventTime,
141+
rundownPlaylistId,
142+
() => {
143+
check(rundownPlaylistId, String)
144+
},
145+
StudioJobs.ResumeCurrentPart,
146+
{
147+
playlistId: rundownPlaylistId,
148+
}
149+
)
150+
}
151+
async takePreviousPart(userEvent: string, eventTime: Time, rundownPlaylistId: RundownPlaylistId) {
152+
return ServerClientAPI.runUserActionInLogForPlaylistOnWorker(
153+
this,
154+
userEvent,
155+
eventTime,
156+
rundownPlaylistId,
157+
() => {
158+
check(rundownPlaylistId, String)
159+
},
160+
StudioJobs.TakePreviousPart,
161+
{
162+
playlistId: rundownPlaylistId,
163+
}
164+
)
165+
}
121166
async setNext(
122167
userEvent: string,
123168
eventTime: Time,

packages/corelib/src/dataModel/PartInstance.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,9 @@ export interface PartInstanceTimings {
8686
reportedStartedPlayback?: Time
8787
/** Point in time the Part stopped playing (ie the time of the playout) */
8888
reportedStoppedPlayback?: Time
89+
/**
90+
* When set, on-air countdown (časovka) is frozen at this timestamp.
91+
* Cleared on the next Take or Resume.
92+
*/
93+
pausedAt?: Time
8994
}

packages/corelib/src/error.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export enum UserErrorMessage {
6565
RateLimitExceeded = 49,
6666
SystemSingleStudio = 50,
6767
TakePartInstanceInvalid = 51,
68+
TakeNoPreviousPart = 52,
6869
}
6970

7071
const UserErrorMessagesTranslations: { [key in UserErrorMessage]: string } = {
@@ -128,6 +129,7 @@ const UserErrorMessagesTranslations: { [key in UserErrorMessage]: string } = {
128129
[UserErrorMessage.RateLimitExceeded]: t(`Rate limit exceeded`),
129130
[UserErrorMessage.SystemSingleStudio]: t(`System must have exactly one studio`),
130131
[UserErrorMessage.TakePartInstanceInvalid]: t(`Part has issues and cannot be taken`),
132+
[UserErrorMessage.TakeNoPreviousPart]: t(`No previous part to take`),
131133
}
132134

133135
export interface SerializedUserError {

packages/corelib/src/worker/studio.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,18 @@ export enum StudioJobs {
106106
* Take the currently Next:ed Part (start playing it)
107107
*/
108108
TakeNextPart = 'takeNextPart',
109+
/**
110+
* Freeze the current part clock (časovka) and stop rolling VT/VO clips
111+
*/
112+
PauseCurrentPart = 'pauseCurrentPart',
113+
/**
114+
* Resume a frozen current-part clock
115+
*/
116+
ResumeCurrentPart = 'resumeCurrentPart',
117+
/**
118+
* Take the previous playable Part and freeze its clock
119+
*/
120+
TakePreviousPart = 'takePreviousPart',
109121
/**
110122
* Disable the next Piece which allows being disabled
111123
*/
@@ -342,6 +354,9 @@ export interface ExecuteActionResult {
342354
export interface TakeNextPartProps extends RundownPlayoutPropsBase {
343355
fromPartInstanceId: PartInstanceId | null
344356
}
357+
export type PauseCurrentPartProps = RundownPlayoutPropsBase
358+
export type ResumeCurrentPartProps = RundownPlayoutPropsBase
359+
export type TakePreviousPartProps = RundownPlayoutPropsBase
345360
export interface DisableNextPieceProps extends RundownPlayoutPropsBase {
346361
undo: boolean
347362
}
@@ -514,6 +529,9 @@ export type StudioJobFunc = {
514529
[StudioJobs.ExecuteAction]: (data: ExecuteActionProps) => ExecuteActionResult
515530
[StudioJobs.ExecuteBucketAdLibOrAction]: (data: ExecuteBucketAdLibOrActionProps) => ExecuteActionResult
516531
[StudioJobs.TakeNextPart]: (data: TakeNextPartProps) => TakeNextPartResult
532+
[StudioJobs.PauseCurrentPart]: (data: PauseCurrentPartProps) => void
533+
[StudioJobs.ResumeCurrentPart]: (data: ResumeCurrentPartProps) => void
534+
[StudioJobs.TakePreviousPart]: (data: TakePreviousPartProps) => TakeNextPartResult
517535
[StudioJobs.DisableNextPiece]: (data: DisableNextPieceProps) => void
518536
[StudioJobs.RemovePlaylist]: (data: RemovePlaylistProps) => void
519537
[StudioJobs.RegeneratePlaylist]: (data: RegeneratePlaylistProps) => void
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { protectString } from '@sofie-automation/corelib/dist/protectedString'
2+
import { findPreviousPlayablePart } from '../pausePlayback.js'
3+
import type { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part'
4+
5+
function part(id: string, flags: { invalid?: boolean; floated?: boolean } = {}): Pick<DBPart, '_id' | 'invalid' | 'floated'> {
6+
return {
7+
_id: protectString(id),
8+
invalid: flags.invalid,
9+
floated: flags.floated,
10+
}
11+
}
12+
13+
describe('findPreviousPlayablePart', () => {
14+
it('returns the previous playable part', () => {
15+
const parts = [part('a'), part('b'), part('c')]
16+
expect(findPreviousPlayablePart(parts, protectString('c'))?._id).toEqual(protectString('b'))
17+
})
18+
19+
it('skips invalid and floated parts', () => {
20+
const parts = [part('a'), part('b', { floated: true }), part('c', { invalid: true }), part('d')]
21+
expect(findPreviousPlayablePart(parts, protectString('d'))?._id).toEqual(protectString('a'))
22+
})
23+
24+
it('returns undefined at the start of the rundown', () => {
25+
const parts = [part('a'), part('b')]
26+
expect(findPreviousPlayablePart(parts, protectString('a'))).toBeUndefined()
27+
})
28+
})

packages/job-worker/src/playout/model/PlayoutPartInstanceModel.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,12 @@ export interface PlayoutPartInstanceModel {
193193
*/
194194
setReportedStoppedPlaybackWithPieceInstances(time: Time): boolean
195195

196+
/**
197+
* Freeze or unfreeze the on-air clock for this PartInstance.
198+
* Pass a timestamp to pause; pass undefined to resume.
199+
*/
200+
setPausedPlayback(time: Time | undefined): void
201+
196202
/**
197203
* Set the rank of this PartInstance, to update it's position in the Segment
198204
* @param rank New rank

packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,16 @@ export class PlayoutPartInstanceModelImpl implements PlayoutPartInstanceModel {
505505
return setOnAll
506506
}
507507

508+
setPausedPlayback(time: Time | undefined): void {
509+
const timings = { ...this.partInstanceImpl.timings }
510+
if (time === undefined) {
511+
delete timings.pausedAt
512+
} else {
513+
timings.pausedAt = time
514+
}
515+
this.#compareAndSetPartInstanceValue('timings', timings, true)
516+
}
517+
508518
setRank(rank: number): void {
509519
this.#compareAndSetPartValue('_rank', rank)
510520
}
@@ -515,6 +525,7 @@ export class PlayoutPartInstanceModelImpl implements PlayoutPartInstanceModel {
515525
const timings = { ...this.partInstanceImpl.timings }
516526
timings.take = takeTime
517527
timings.playOffset = playOffset ?? 0
528+
delete timings.pausedAt
518529

519530
if (playOffset !== null) {
520531
// Shift the startedPlayback into the past, to cause playout to start a while into the Part:
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { SourceLayerType } from '@sofie-automation/blueprints-integration'
2+
import { DBPart, isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part'
3+
import { PartId } from '@sofie-automation/corelib/dist/dataModel/Ids'
4+
import { UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error'
5+
import {
6+
PauseCurrentPartProps,
7+
ResumeCurrentPartProps,
8+
TakeNextPartResult,
9+
TakePreviousPartProps,
10+
} from '@sofie-automation/corelib/dist/worker/studio'
11+
import { ReadonlyDeep } from 'type-fest'
12+
import { JobContext } from '../jobs/index.js'
13+
import { getCurrentTime } from '../lib/index.js'
14+
import { innerStopPieces } from './adlibUtils.js'
15+
import { runJobWithPlayoutModel } from './lock.js'
16+
import { PlayoutModel } from './model/PlayoutModel.js'
17+
import { setNextPartFromPart } from './setNext.js'
18+
import { performTakeToNextedPart } from './take.js'
19+
import { updateTimeline } from './timeline/generate.js'
20+
21+
export function findPreviousPlayablePart<
22+
T extends Pick<ReadonlyDeep<DBPart>, '_id' | 'invalid' | 'floated'>,
23+
>(orderedParts: readonly T[], currentPartId: PartId): T | undefined {
24+
const currentIndex = orderedParts.findIndex((part) => part._id === currentPartId)
25+
if (currentIndex <= 0) {
26+
return undefined
27+
}
28+
29+
for (let i = currentIndex - 1; i >= 0; i--) {
30+
const candidate = orderedParts[i]
31+
if (isPartPlayable(candidate)) {
32+
return candidate
33+
}
34+
}
35+
36+
return undefined
37+
}
38+
39+
function isVideoClipLayerType(type: SourceLayerType | undefined): boolean {
40+
return type === SourceLayerType.VT || type === SourceLayerType.LIVE_SPEAK
41+
}
42+
43+
async function pauseCurrentPartInner(context: JobContext, playoutModel: PlayoutModel, stopClips: boolean): Promise<void> {
44+
const playlist = playoutModel.playlist
45+
if (!playlist.activationId) throw UserError.create(UserErrorMessage.InactiveRundown, undefined, 412)
46+
47+
const currentPartInstance = playoutModel.currentPartInstance
48+
if (!currentPartInstance) throw UserError.create(UserErrorMessage.NoCurrentPart, undefined, 412)
49+
50+
const now = getCurrentTime()
51+
if (currentPartInstance.partInstance.timings?.pausedAt) {
52+
return
53+
}
54+
55+
currentPartInstance.setPausedPlayback(now)
56+
57+
if (stopClips && currentPartInstance.partInstance.timings?.plannedStartedPlayback) {
58+
const currentRundown = playoutModel.getRundown(currentPartInstance.partInstance.rundownId)
59+
if (currentRundown) {
60+
const showStyle = await context.getShowStyleCompound(
61+
currentRundown.rundown.showStyleVariantId,
62+
currentRundown.rundown.showStyleBaseId
63+
)
64+
innerStopPieces(
65+
context,
66+
playoutModel,
67+
showStyle.sourceLayers,
68+
currentPartInstance,
69+
(pieceInstance) => isVideoClipLayerType(showStyle.sourceLayers[pieceInstance.piece.sourceLayerId]?.type),
70+
undefined
71+
)
72+
}
73+
}
74+
75+
await updateTimeline(context, playoutModel)
76+
}
77+
78+
/**
79+
* Freeze the current part countdown and stop rolling VT/VO clips.
80+
*/
81+
export async function handlePauseCurrentPart(context: JobContext, data: PauseCurrentPartProps): Promise<void> {
82+
return runJobWithPlayoutModel(
83+
context,
84+
data,
85+
async (playoutModel) => {
86+
if (!playoutModel.playlist.activationId) throw UserError.create(UserErrorMessage.InactiveRundown, undefined, 412)
87+
if (!playoutModel.currentPartInstance) throw UserError.create(UserErrorMessage.NoCurrentPart, undefined, 412)
88+
},
89+
async (playoutModel) => {
90+
await pauseCurrentPartInner(context, playoutModel, true)
91+
}
92+
)
93+
}
94+
95+
/**
96+
* Resume a frozen current-part countdown.
97+
*/
98+
export async function handleResumeCurrentPart(context: JobContext, data: ResumeCurrentPartProps): Promise<void> {
99+
return runJobWithPlayoutModel(
100+
context,
101+
data,
102+
async (playoutModel) => {
103+
if (!playoutModel.playlist.activationId) throw UserError.create(UserErrorMessage.InactiveRundown, undefined, 412)
104+
if (!playoutModel.currentPartInstance) throw UserError.create(UserErrorMessage.NoCurrentPart, undefined, 412)
105+
},
106+
async (playoutModel) => {
107+
const currentPartInstance = playoutModel.currentPartInstance
108+
if (!currentPartInstance) throw UserError.create(UserErrorMessage.NoCurrentPart, undefined, 412)
109+
110+
if (!currentPartInstance.partInstance.timings?.pausedAt) {
111+
return
112+
}
113+
114+
currentPartInstance.setPausedPlayback(undefined)
115+
await updateTimeline(context, playoutModel)
116+
}
117+
)
118+
}
119+
120+
/**
121+
* Take the previous playable part, then freeze its clock so the operator can regroup.
122+
*/
123+
export async function handleTakePreviousPart(
124+
context: JobContext,
125+
data: TakePreviousPartProps
126+
): Promise<TakeNextPartResult> {
127+
const now = getCurrentTime()
128+
129+
return runJobWithPlayoutModel(
130+
context,
131+
data,
132+
async (playoutModel) => {
133+
if (!playoutModel.playlist.activationId) throw UserError.create(UserErrorMessage.InactiveRundown, undefined, 412)
134+
if (!playoutModel.currentPartInstance) throw UserError.create(UserErrorMessage.NoCurrentPart, undefined, 412)
135+
},
136+
async (playoutModel) => {
137+
const currentPartInstance = playoutModel.currentPartInstance
138+
if (!currentPartInstance) throw UserError.create(UserErrorMessage.NoCurrentPart, undefined, 412)
139+
140+
const previousPart = findPreviousPlayablePart(
141+
playoutModel.getAllOrderedParts(),
142+
currentPartInstance.partInstance.part._id
143+
)
144+
if (!previousPart) {
145+
throw UserError.create(UserErrorMessage.TakeNoPreviousPart, undefined, 412)
146+
}
147+
148+
await setNextPartFromPart(context, playoutModel, previousPart, true)
149+
await performTakeToNextedPart(context, playoutModel, now, undefined)
150+
151+
const takenPart = playoutModel.currentPartInstance
152+
takenPart?.setPausedPlayback(getCurrentTime())
153+
await updateTimeline(context, playoutModel)
154+
155+
return {
156+
nextTakeTime: now + context.studio.settings.minimumTakeSpan,
157+
}
158+
}
159+
)
160+
}

packages/job-worker/src/workers/studio/jobs.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ import { handleTimelineTriggerTime, handleOnPlayoutPlaybackChanged } from '../..
4646
import { handleOnExternalEvents } from '../../playout/externalEvents.js'
4747
import { handleExecuteAdlibAction } from '../../playout/adlibAction.js'
4848
import { handleTakeNextPart } from '../../playout/take.js'
49+
import {
50+
handlePauseCurrentPart,
51+
handleResumeCurrentPart,
52+
handleTakePreviousPart,
53+
} from '../../playout/pausePlayback.js'
4954
import { handleClearQuickLoopMarkers, handleSetQuickLoopMarker } from '../../playout/quickLoopMarkers.js'
5055
import { handleActivateAdlibTesting } from '../../playout/adlibTesting.js'
5156
import { handleExecuteBucketAdLibOrAction } from '../../playout/bucketAdlibJobs.js'
@@ -94,6 +99,9 @@ export const studioJobHandlers: StudioJobHandlers = {
9499
[StudioJobs.ExecuteAction]: handleExecuteAdlibAction,
95100
[StudioJobs.ExecuteBucketAdLibOrAction]: handleExecuteBucketAdLibOrAction,
96101
[StudioJobs.TakeNextPart]: handleTakeNextPart,
102+
[StudioJobs.PauseCurrentPart]: handlePauseCurrentPart,
103+
[StudioJobs.ResumeCurrentPart]: handleResumeCurrentPart,
104+
[StudioJobs.TakePreviousPart]: handleTakePreviousPart,
97105
[StudioJobs.DisableNextPiece]: handleDisableNextPiece,
98106
[StudioJobs.RemovePlaylist]: handleRemoveRundownPlaylist,
99107
[StudioJobs.RegeneratePlaylist]: handleRegenerateRundownPlaylist,

packages/meteor-lib/src/api/userActions.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,21 @@ export interface NewUserActionAPI {
3939
rundownPlaylistId: RundownPlaylistId,
4040
fromPartInstanceId: PartInstanceId | null
4141
): Promise<ClientAPI.ClientResponse<TakeNextPartResult>>
42+
pauseCurrentPart(
43+
userEvent: string,
44+
eventTime: Time,
45+
rundownPlaylistId: RundownPlaylistId
46+
): Promise<ClientAPI.ClientResponse<void>>
47+
resumeCurrentPart(
48+
userEvent: string,
49+
eventTime: Time,
50+
rundownPlaylistId: RundownPlaylistId
51+
): Promise<ClientAPI.ClientResponse<void>>
52+
takePreviousPart(
53+
userEvent: string,
54+
eventTime: Time,
55+
rundownPlaylistId: RundownPlaylistId
56+
): Promise<ClientAPI.ClientResponse<TakeNextPartResult>>
4257
setNext(
4358
userEvent: string,
4459
eventTime: Time,
@@ -358,6 +373,9 @@ export interface NewUserActionAPI {
358373

359374
export enum UserActionAPIMethods {
360375
'take' = 'userAction.take',
376+
'pauseCurrentPart' = 'userAction.pauseCurrentPart',
377+
'resumeCurrentPart' = 'userAction.resumeCurrentPart',
378+
'takePreviousPart' = 'userAction.takePreviousPart',
361379
'setNext' = 'userAction.setNext',
362380
'setNextSegment' = 'userAction.setNextSegment',
363381
'queueNextSegment' = 'userAction.queueNextSegment',

0 commit comments

Comments
 (0)