Skip to content

Commit 739abe8

Browse files
committed
fix(EAV-929): prevent a still playing part from disappearing from the timeline when more than two parts overlap
keeps track of more than one previous part, so that they are included in the timeline and ab-logic, until stopped playback; timing properties are used for pruning, but an arbitrary limit of 10 is in place, to avoid too much data piling up if blueprints did something very wrong
1 parent 589d677 commit 739abe8

60 files changed

Lines changed: 1453 additions & 373 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

meteor/__mocks__/defaultCollectionObjects.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI
4747
rehearsal: false,
4848
currentPartInfo: null,
4949
nextPartInfo: null,
50-
previousPartInfo: null,
50+
previousPartsInfo: [],
5151
timing: {
5252
type: 'none' as any,
5353
},

meteor/server/__tests__/cronjobs.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,7 @@ describe('cronjobs', () => {
615615
externalId: '',
616616
modified: Date.now(),
617617
name: 'Rundown',
618-
previousPartInfo: null,
618+
previousPartsInfo: [],
619619
rundownIdsInOrder: [],
620620
studioId,
621621
timing: {

meteor/server/api/__tests__/externalMessageQueue.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ describe('Test external message queue static methods', () => {
3535
manuallySelected: false,
3636
consumesQueuedSegmentId: false,
3737
},
38-
previousPartInfo: null,
38+
previousPartsInfo: [],
3939
activationId: protectString('active'),
4040
timing: {
4141
type: PlaylistTimingType.None,

meteor/server/api/__tests__/peripheralDevice.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ describe('test peripheralDevice general API methods', () => {
7272
modified: 0,
7373
currentPartInfo: null,
7474
nextPartInfo: null,
75-
previousPartInfo: null,
75+
previousPartsInfo: [],
7676
activationId: protectString('active'),
7777
timing: {
7878
type: PlaylistTimingType.None,

meteor/server/api/deviceTriggers/TagsService.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export class TagsService {
5656
return false
5757
}
5858

59-
const previousPartInstanceId = rundownPlaylist?.previousPartInfo?.partInstanceId
59+
const previousPartInstanceIds = (rundownPlaylist?.previousPartsInfo ?? []).map((info) => info.partInstanceId)
6060
const currentPartInstanceId = rundownPlaylist?.currentPartInfo?.partInstanceId
6161
const nextPartInstanceId = rundownPlaylist?.nextPartInfo?.partInstanceId
6262

@@ -66,13 +66,13 @@ export class TagsService {
6666

6767
const resolvedSourceLayers = applyAndValidateOverrides(showStyleBase.sourceLayersWithOverrides).obj
6868

69-
const inPreviousPartInstance = previousPartInstanceId
70-
? this.processAndPrunePieceInstanceTimings(
71-
cache.PartInstances.findOne(previousPartInstanceId)?.timings,
72-
cache.PieceInstances.find({ partInstanceId: previousPartInstanceId }).fetch(),
73-
resolvedSourceLayers
74-
)
75-
: []
69+
const inPreviousPartInstances = previousPartInstanceIds.flatMap((previousPartInstanceId) =>
70+
this.processAndPrunePieceInstanceTimings(
71+
cache.PartInstances.findOne(previousPartInstanceId)?.timings,
72+
cache.PieceInstances.find({ partInstanceId: previousPartInstanceId }).fetch(),
73+
resolvedSourceLayers
74+
)
75+
)
7676
const inCurrentPartInstance = currentPartInstanceId
7777
? this.processAndPrunePieceInstanceTimings(
7878
cache.PartInstances.findOne(currentPartInstanceId)?.timings,
@@ -88,8 +88,9 @@ export class TagsService {
8888
)
8989
: []
9090

91-
const activePieceInstances = [...inPreviousPartInstance, ...inCurrentPartInstance].filter((pieceInstance) =>
92-
this.isPieceInstanceActive(pieceInstance, previousPartInstanceId, currentPartInstanceId)
91+
const previousPartInstanceIdSet = new Set(previousPartInstanceIds)
92+
const activePieceInstances = [...inPreviousPartInstances, ...inCurrentPartInstance].filter((pieceInstance) =>
93+
this.isPieceInstanceActive(pieceInstance, previousPartInstanceIdSet, currentPartInstanceId)
9394
)
9495

9596
const activePieceInstancesTags = new Set<string>()
@@ -144,14 +145,14 @@ export class TagsService {
144145

145146
private isPieceInstanceActive(
146147
pieceInstance: PieceInstanceWithTimings,
147-
previousPartInstanceId: PartInstanceId | undefined,
148+
previousPartInstanceIds: Set<PartInstanceId>,
148149
currentPartInstanceId: PartInstanceId | undefined
149150
) {
150151
return (
151152
pieceInstance.reportedStoppedPlayback == null &&
152153
pieceInstance.piece.virtual !== true &&
153154
pieceInstance.disabled !== true &&
154-
(pieceInstance.partInstanceId === previousPartInstanceId || // a piece from previous part instance may be active during transition
155+
(previousPartInstanceIds.has(pieceInstance.partInstanceId) || // a piece from a previous part instance may be active during transition/overlap
155156
pieceInstance.partInstanceId === currentPartInstanceId) &&
156157
(pieceInstance.reportedStartedPlayback != null || // has been reported to have started by the Playout Gateway
157158
pieceInstance.plannedStartedPlayback != null || // a time to start playing has been set by Core

meteor/server/api/deviceTriggers/__tests__/TagsService.test.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ const tag2 = 'tag2'
4040
const tag3 = 'tag3'
4141

4242
const tag4 = 'tag4'
43+
const tag5 = 'tag5'
44+
const tag6 = 'tag6'
45+
46+
const partInstanceId3 = protectString<PartInstanceId>('partInstance3')
47+
const partInstanceId4 = protectString<PartInstanceId>('partInstance4')
48+
const pieceInstanceId4 = protectString<PieceInstanceId>('pieceInstance4')
49+
const pieceInstanceId5 = protectString<PieceInstanceId>('pieceInstance5')
4350

4451
function createAndPopulateMockCache(): ContentCache {
4552
const newCache: ContentCache = {
@@ -233,4 +240,164 @@ describe('TagsService', () => {
233240

234241
expect(result).toEqual(true)
235242
})
243+
244+
test('piece in previousPartsInfo[0] (most-recent previous) is treated as on-air', () => {
245+
// partInstanceId3 = previous (index 0), partInstanceId0 = current
246+
const testee = createTestee()
247+
const cache: ContentCache = {
248+
RundownPlaylists: new ReactiveCacheCollection('rundownPlaylists'),
249+
ShowStyleBases: new ReactiveCacheCollection('showStyleBases'),
250+
PieceInstances: new ReactiveCacheCollection('pieceInstances'),
251+
PartInstances: new ReactiveCacheCollection('partInstances'),
252+
}
253+
cache.RundownPlaylists.insert({
254+
_id: playlistId,
255+
activationId,
256+
previousPartsInfo: [{ partInstanceId: partInstanceId3 }],
257+
currentPartInfo: { partInstanceId: partInstanceId0 },
258+
nextPartInfo: { partInstanceId: partInstanceId1 },
259+
} as DBRundownPlaylist)
260+
cache.ShowStyleBases.insert({
261+
_id: showStyleBaseId,
262+
sourceLayersWithOverrides: wrapDefaultObject(
263+
normalizeArray(
264+
[
265+
literal<ISourceLayer>({
266+
_id: sourceLayerId0,
267+
_rank: 0,
268+
name: 'Camera',
269+
type: SourceLayerType.CAMERA,
270+
}),
271+
],
272+
'_id'
273+
)
274+
),
275+
} as DBShowStyleBase)
276+
// Piece in the previous part — started playback, not yet stopped
277+
cache.PieceInstances.insert({
278+
_id: pieceInstanceId4,
279+
piece: {
280+
tags: [tag5],
281+
sourceLayerId: sourceLayerId0,
282+
enable: { start: 0 },
283+
lifespan: PieceLifespan.WithinPart,
284+
},
285+
partInstanceId: partInstanceId3,
286+
plannedStartedPlayback: 1000,
287+
} as PieceInstance)
288+
// Piece in the current part
289+
cache.PieceInstances.insert({
290+
_id: pieceInstanceId0,
291+
piece: {
292+
tags: [tag0],
293+
sourceLayerId: sourceLayerId0,
294+
enable: { start: 0 },
295+
lifespan: PieceLifespan.WithinPart,
296+
},
297+
partInstanceId: partInstanceId0,
298+
} as PieceInstance)
299+
cache.PartInstances.insert({ _id: partInstanceId3 } as DBPartInstance)
300+
cache.PartInstances.insert({ _id: partInstanceId0 } as DBPartInstance)
301+
cache.PartInstances.insert({ _id: partInstanceId1 } as DBPartInstance)
302+
303+
testee.updatePieceInstances(cache, showStyleBaseId)
304+
305+
// tag5 is from previous part → on-air; tag0 is from current → on-air; neither is next
306+
expect(testee.getTallyStateFromTags({ currentPieceTags: [tag5] } as IWrappedAdLib)).toEqual({
307+
isActive: true,
308+
isNext: false,
309+
})
310+
expect(testee.getTallyStateFromTags({ currentPieceTags: [tag0] } as IWrappedAdLib)).toEqual({
311+
isActive: true,
312+
isNext: false,
313+
})
314+
})
315+
316+
test('pieces in all entries of previousPartsInfo are treated as on-air', () => {
317+
// partInstanceId4 = older previous (index 1), partInstanceId3 = recent previous (index 0), partInstanceId0 = current
318+
const testee = createTestee()
319+
const cache: ContentCache = {
320+
RundownPlaylists: new ReactiveCacheCollection('rundownPlaylists'),
321+
ShowStyleBases: new ReactiveCacheCollection('showStyleBases'),
322+
PieceInstances: new ReactiveCacheCollection('pieceInstances'),
323+
PartInstances: new ReactiveCacheCollection('partInstances'),
324+
}
325+
cache.RundownPlaylists.insert({
326+
_id: playlistId,
327+
activationId,
328+
// most-recent-first: index 0 = partInstanceId3, index 1 = partInstanceId4
329+
previousPartsInfo: [{ partInstanceId: partInstanceId3 }, { partInstanceId: partInstanceId4 }],
330+
currentPartInfo: { partInstanceId: partInstanceId0 },
331+
} as DBRundownPlaylist)
332+
cache.ShowStyleBases.insert({
333+
_id: showStyleBaseId,
334+
sourceLayersWithOverrides: wrapDefaultObject(
335+
normalizeArray(
336+
[
337+
literal<ISourceLayer>({
338+
_id: sourceLayerId0,
339+
_rank: 0,
340+
name: 'Camera',
341+
type: SourceLayerType.CAMERA,
342+
}),
343+
],
344+
'_id'
345+
)
346+
),
347+
} as DBShowStyleBase)
348+
// Piece in the most-recent previous part (index 0)
349+
cache.PieceInstances.insert({
350+
_id: pieceInstanceId4,
351+
piece: {
352+
tags: [tag5],
353+
sourceLayerId: sourceLayerId0,
354+
enable: { start: 0 },
355+
lifespan: PieceLifespan.WithinPart,
356+
},
357+
partInstanceId: partInstanceId3,
358+
plannedStartedPlayback: 1000,
359+
} as PieceInstance)
360+
// Piece in the older previous part (index 1) — still has started playback, not stopped
361+
cache.PieceInstances.insert({
362+
_id: pieceInstanceId5,
363+
piece: {
364+
tags: [tag6],
365+
sourceLayerId: sourceLayerId0,
366+
enable: { start: 0 },
367+
lifespan: PieceLifespan.WithinPart,
368+
},
369+
partInstanceId: partInstanceId4,
370+
plannedStartedPlayback: 500,
371+
} as PieceInstance)
372+
// Piece in the current part
373+
cache.PieceInstances.insert({
374+
_id: pieceInstanceId0,
375+
piece: {
376+
tags: [tag0],
377+
sourceLayerId: sourceLayerId0,
378+
enable: { start: 0 },
379+
lifespan: PieceLifespan.WithinPart,
380+
},
381+
partInstanceId: partInstanceId0,
382+
} as PieceInstance)
383+
cache.PartInstances.insert({ _id: partInstanceId4 } as DBPartInstance)
384+
cache.PartInstances.insert({ _id: partInstanceId3 } as DBPartInstance)
385+
cache.PartInstances.insert({ _id: partInstanceId0 } as DBPartInstance)
386+
387+
testee.updatePieceInstances(cache, showStyleBaseId)
388+
389+
// All three tags should be on-air
390+
expect(testee.getTallyStateFromTags({ currentPieceTags: [tag5] } as IWrappedAdLib)).toEqual({
391+
isActive: true,
392+
isNext: false,
393+
})
394+
expect(testee.getTallyStateFromTags({ currentPieceTags: [tag6] } as IWrappedAdLib)).toEqual({
395+
isActive: true,
396+
isNext: false,
397+
})
398+
expect(testee.getTallyStateFromTags({ currentPieceTags: [tag0] } as IWrappedAdLib)).toEqual({
399+
isActive: true,
400+
isNext: false,
401+
})
402+
})
236403
})

meteor/server/api/deviceTriggers/reactiveContentCacheForPieceInstances.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export type RundownPlaylistFields =
1414
| 'activationId'
1515
| 'currentPartInfo'
1616
| 'nextPartInfo'
17-
| 'previousPartInfo'
17+
| 'previousPartsInfo'
1818
export const rundownPlaylistFieldSpecifier = literal<
1919
MongoFieldSpecifierOnesStrict<Pick<DBRundownPlaylist, RundownPlaylistFields>>
2020
>({
@@ -23,7 +23,7 @@ export const rundownPlaylistFieldSpecifier = literal<
2323
activationId: 1,
2424
currentPartInfo: 1,
2525
nextPartInfo: 1,
26-
previousPartInfo: 1,
26+
previousPartsInfo: 1,
2727
})
2828

2929
export type PieceInstanceFields =

packages/corelib/src/dataModel/RundownPlaylist.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,8 +272,13 @@ export interface DBRundownPlaylist {
272272
nextPartInfo: SelectedPartInstance | null
273273
/** The time offset of the next line */
274274
nextTimeOffset?: number | null
275-
/** the id of the Previous Part */
276-
previousPartInfo: SelectedPartInstance | null
275+
/**
276+
* Previously played PartInstances, ordered most-recent-first (index 0 = the one taken from most recently).
277+
* There may be more than one entry when keepalive/postroll/preroll cause PartInstances to overlap:
278+
* e.g. if Part A is still audible due to postroll when Part C is taken, both A and B are retained here
279+
* until their timeline contribution has fully ended.
280+
*/
281+
previousPartsInfo: SelectedPartInstance[]
277282

278283
/**
279284
* The id of the Queued Segment. If set, the Next point will jump to that segment when reaching the end of the currently playing segment.

packages/job-worker/src/__mocks__/defaultCollectionObjects.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI
3838
rehearsal: false,
3939
currentPartInfo: null,
4040
nextPartInfo: null,
41-
previousPartInfo: null,
41+
previousPartsInfo: [],
4242

4343
timing: {
4444
type: PlaylistTimingType.None,

packages/job-worker/src/blueprints/context/OnTimelineGenerateContext.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,15 @@ export class OnTimelineGenerateContext extends RundownContext implements ITimeli
3838
showStyleBlueprintConfig: ProcessedShowStyleConfig,
3939
playlist: ReadonlyDeep<DBRundownPlaylist>,
4040
rundown: ReadonlyDeep<DBRundown>,
41-
previousPartInstance: ReadonlyDeep<DBPartInstance> | undefined,
41+
previousPartInstances: ReadonlyDeep<DBPartInstance>[],
4242
currentPartInstance: ReadonlyDeep<DBPartInstance> | undefined,
4343
nextPartInstance: ReadonlyDeep<DBPartInstance> | undefined,
4444
pieceInstances: ReadonlyDeep<ResolvedPieceInstance[]>
4545
) {
4646
super(
4747
{
4848
name: playlist.name,
49-
identifier: `playlistId=${playlist._id},previousPartInstance=${previousPartInstance?._id},currentPartInstance=${currentPartInstance?._id},nextPartInstance=${nextPartInstance?._id}`,
49+
identifier: `playlistId=${playlist._id},previousPartInstance=${previousPartInstances[0]?._id},currentPartInstance=${currentPartInstance?._id},nextPartInstance=${nextPartInstance?._id}`,
5050
},
5151
studio,
5252
studioBlueprintConfig,
@@ -57,11 +57,12 @@ export class OnTimelineGenerateContext extends RundownContext implements ITimeli
5757

5858
this.currentPartInstance = currentPartInstance && convertPartInstanceToBlueprints(currentPartInstance)
5959
this.nextPartInstance = nextPartInstance && convertPartInstanceToBlueprints(nextPartInstance)
60-
this.previousPartInstance = previousPartInstance && convertPartInstanceToBlueprints(previousPartInstance)
60+
this.previousPartInstance =
61+
previousPartInstances[0] && convertPartInstanceToBlueprints(previousPartInstances[0])
6162

6263
this.quickLoopInfo = createBlueprintQuickLoopInfo(playlist)
6364

64-
const partInstances = _.compact([previousPartInstance, currentPartInstance, nextPartInstance])
65+
const partInstances = _.compact([...previousPartInstances, currentPartInstance, nextPartInstance])
6566

6667
for (const pieceInstance of pieceInstances) {
6768
this.#pieceInstanceCache.set(pieceInstance.instance._id, pieceInstance.instance)

0 commit comments

Comments
 (0)