From e1076ed1230d426afd99167bc7209c8ef6e88793 Mon Sep 17 00:00:00 2001 From: ianshade Date: Tue, 21 Apr 2026 11:00:08 +0200 Subject: [PATCH 1/6] 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 --- meteor/__mocks__/defaultCollectionObjects.ts | 2 +- meteor/server/__tests__/cronjobs.test.ts | 2 +- .../__tests__/externalMessageQueue.test.ts | 2 +- .../api/__tests__/peripheralDevice.test.ts | 2 +- .../server/api/deviceTriggers/TagsService.ts | 25 +- .../__tests__/TagsService.test.ts | 167 +++++++ .../reactiveContentCacheForPieceInstances.ts | 4 +- .../RundownPlaylist/RundownPlaylist.ts | 9 +- .../src/__mocks__/defaultCollectionObjects.ts | 2 +- .../context/OnTimelineGenerateContext.ts | 9 +- .../PartAndPieceInstanceActionService.test.ts | 2 +- .../__tests__/externalMessageQueue.test.ts | 4 +- .../src/ingest/__tests__/ingest.test.ts | 4 +- .../syncChangesToPartInstance.test.ts | 2 +- .../src/ingest/__tests__/updateNext.test.ts | 4 +- packages/job-worker/src/ingest/commit.ts | 39 +- .../__snapshots__/mosIngest.test.ts.snap | 30 +- .../__snapshots__/playout.test.ts.snap | 2 +- .../job-worker/src/playout/__tests__/lib.ts | 6 +- .../playout/__tests__/resolvedPieces.test.ts | 121 ++++- .../src/playout/__tests__/tTimersJobs.test.ts | 8 +- .../src/playout/abPlayback/index.ts | 2 +- .../findForLayer/basicBehavior.test.ts | 12 +- .../__tests__/findForLayer/constants.ts | 14 +- .../findForLayer/orderedParts.test.ts | 33 +- .../playoutStatePropagation.test.ts | 6 +- .../findForLayer/searchDistance.test.ts | 21 +- .../__tests__/findForLayer/timing.test.ts | 14 +- .../lookahead/__tests__/lookahead.test.ts | 39 +- .../lookaheadOffset/lookaheadOffset.test.ts | 15 +- .../src/playout/lookahead/findForLayer.ts | 21 +- .../job-worker/src/playout/lookahead/index.ts | 40 +- .../src/playout/model/PlayoutModel.ts | 28 +- .../model/implementation/LoadPlayoutModel.ts | 2 +- .../model/implementation/PlayoutModelImpl.ts | 76 ++- .../__tests__/PlayoutModelImpl.spec.ts | 445 +++++++++++++++++- .../job-worker/src/playout/resolvedPieces.ts | 32 +- packages/job-worker/src/playout/setNext.ts | 19 +- packages/job-worker/src/playout/snapshot.ts | 40 +- .../timeline/__tests__/rundown.test.ts | 301 ++++++++---- .../src/playout/timeline/generate.ts | 26 +- .../src/playout/timeline/rundown.ts | 91 +++- .../src/playout/timings/partPlayback.ts | 3 + packages/job-worker/src/rundownPlaylists.ts | 4 +- .../src/collections/partInstancesHandler.ts | 18 +- .../src/collections/pieceInstancesHandler.ts | 32 +- .../topics/__tests__/activePlaylist.spec.ts | 2 +- .../src/topics/__tests__/utils.ts | 2 +- .../src/__mocks__/defaultCollectionObjects.ts | 2 +- .../lib/__tests__/rundownTiming.test.ts | 2 +- .../src/client/lib/rundownPlaylistUtil.ts | 8 +- .../DirectorScreen/DirectorScreen.tsx | 4 +- .../client/ui/ClockView/PresenterScreen.tsx | 4 +- .../src/client/ui/MediaStatus/MediaStatus.tsx | 2 +- packages/webui/src/client/ui/RundownView.tsx | 2 +- .../RundownTiming/RundownTimingProvider.tsx | 2 +- .../RundownView/RundownViewSubscriptions.ts | 6 +- .../Parts/SegmentTimelinePart.tsx | 2 +- .../ui/SegmentTimeline/SegmentContextMenu.tsx | 2 +- .../webui/src/client/ui/Shelf/AdLibPanel.tsx | 6 +- 60 files changed, 1453 insertions(+), 373 deletions(-) diff --git a/meteor/__mocks__/defaultCollectionObjects.ts b/meteor/__mocks__/defaultCollectionObjects.ts index 8ad4d7857f0..a72f2e70f19 100644 --- a/meteor/__mocks__/defaultCollectionObjects.ts +++ b/meteor/__mocks__/defaultCollectionObjects.ts @@ -47,7 +47,7 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI rehearsal: false, currentPartInfo: null, nextPartInfo: null, - previousPartInfo: null, + previousPartsInfo: [], timing: { type: 'none' as any, }, diff --git a/meteor/server/__tests__/cronjobs.test.ts b/meteor/server/__tests__/cronjobs.test.ts index 131960500fd..9cfe6bfee6a 100644 --- a/meteor/server/__tests__/cronjobs.test.ts +++ b/meteor/server/__tests__/cronjobs.test.ts @@ -615,7 +615,7 @@ describe('cronjobs', () => { externalId: '', modified: Date.now(), name: 'Rundown', - previousPartInfo: null, + previousPartsInfo: [], rundownIdsInOrder: [], studioId, timing: { diff --git a/meteor/server/api/__tests__/externalMessageQueue.test.ts b/meteor/server/api/__tests__/externalMessageQueue.test.ts index 1b5fb53f938..94cde8fb720 100644 --- a/meteor/server/api/__tests__/externalMessageQueue.test.ts +++ b/meteor/server/api/__tests__/externalMessageQueue.test.ts @@ -35,7 +35,7 @@ describe('Test external message queue static methods', () => { manuallySelected: false, consumesQueuedSegmentId: false, }, - previousPartInfo: null, + previousPartsInfo: [], activationId: protectString('active'), timing: { type: PlaylistTimingType.None, diff --git a/meteor/server/api/__tests__/peripheralDevice.test.ts b/meteor/server/api/__tests__/peripheralDevice.test.ts index 594c44049ca..b73129a66bd 100644 --- a/meteor/server/api/__tests__/peripheralDevice.test.ts +++ b/meteor/server/api/__tests__/peripheralDevice.test.ts @@ -72,7 +72,7 @@ describe('test peripheralDevice general API methods', () => { modified: 0, currentPartInfo: null, nextPartInfo: null, - previousPartInfo: null, + previousPartsInfo: [], activationId: protectString('active'), timing: { type: PlaylistTimingType.None, diff --git a/meteor/server/api/deviceTriggers/TagsService.ts b/meteor/server/api/deviceTriggers/TagsService.ts index 0ec3e53df72..44ea777a58a 100644 --- a/meteor/server/api/deviceTriggers/TagsService.ts +++ b/meteor/server/api/deviceTriggers/TagsService.ts @@ -56,7 +56,7 @@ export class TagsService { return false } - const previousPartInstanceId = rundownPlaylist?.previousPartInfo?.partInstanceId + const previousPartInstanceIds = (rundownPlaylist?.previousPartsInfo ?? []).map((info) => info.partInstanceId) const currentPartInstanceId = rundownPlaylist?.currentPartInfo?.partInstanceId const nextPartInstanceId = rundownPlaylist?.nextPartInfo?.partInstanceId @@ -66,13 +66,13 @@ export class TagsService { const resolvedSourceLayers = applyAndValidateOverrides(showStyleBase.sourceLayersWithOverrides).obj - const inPreviousPartInstance = previousPartInstanceId - ? this.processAndPrunePieceInstanceTimings( - cache.PartInstances.findOne(previousPartInstanceId)?.timings, - cache.PieceInstances.find({ partInstanceId: previousPartInstanceId }).fetch(), - resolvedSourceLayers - ) - : [] + const inPreviousPartInstances = previousPartInstanceIds.flatMap((previousPartInstanceId) => + this.processAndPrunePieceInstanceTimings( + cache.PartInstances.findOne(previousPartInstanceId)?.timings, + cache.PieceInstances.find({ partInstanceId: previousPartInstanceId }).fetch(), + resolvedSourceLayers + ) + ) const inCurrentPartInstance = currentPartInstanceId ? this.processAndPrunePieceInstanceTimings( cache.PartInstances.findOne(currentPartInstanceId)?.timings, @@ -88,8 +88,9 @@ export class TagsService { ) : [] - const activePieceInstances = [...inPreviousPartInstance, ...inCurrentPartInstance].filter((pieceInstance) => - this.isPieceInstanceActive(pieceInstance, previousPartInstanceId, currentPartInstanceId) + const previousPartInstanceIdSet = new Set(previousPartInstanceIds) + const activePieceInstances = [...inPreviousPartInstances, ...inCurrentPartInstance].filter((pieceInstance) => + this.isPieceInstanceActive(pieceInstance, previousPartInstanceIdSet, currentPartInstanceId) ) const activePieceInstancesTags = new Set() @@ -144,14 +145,14 @@ export class TagsService { private isPieceInstanceActive( pieceInstance: PieceInstanceWithTimings, - previousPartInstanceId: PartInstanceId | undefined, + previousPartInstanceIds: Set, currentPartInstanceId: PartInstanceId | undefined ) { return ( pieceInstance.reportedStoppedPlayback == null && pieceInstance.piece.virtual !== true && pieceInstance.disabled !== true && - (pieceInstance.partInstanceId === previousPartInstanceId || // a piece from previous part instance may be active during transition + (previousPartInstanceIds.has(pieceInstance.partInstanceId) || // a piece from a previous part instance may be active during transition/overlap pieceInstance.partInstanceId === currentPartInstanceId) && (pieceInstance.reportedStartedPlayback != null || // has been reported to have started by the Playout Gateway pieceInstance.plannedStartedPlayback != null || // a time to start playing has been set by Core diff --git a/meteor/server/api/deviceTriggers/__tests__/TagsService.test.ts b/meteor/server/api/deviceTriggers/__tests__/TagsService.test.ts index bb42dce479e..845f73ef555 100644 --- a/meteor/server/api/deviceTriggers/__tests__/TagsService.test.ts +++ b/meteor/server/api/deviceTriggers/__tests__/TagsService.test.ts @@ -40,6 +40,13 @@ const tag2 = 'tag2' const tag3 = 'tag3' const tag4 = 'tag4' +const tag5 = 'tag5' +const tag6 = 'tag6' + +const partInstanceId3 = protectString('partInstance3') +const partInstanceId4 = protectString('partInstance4') +const pieceInstanceId4 = protectString('pieceInstance4') +const pieceInstanceId5 = protectString('pieceInstance5') function createAndPopulateMockCache(): ContentCache { const newCache: ContentCache = { @@ -233,4 +240,164 @@ describe('TagsService', () => { expect(result).toEqual(true) }) + + test('piece in previousPartsInfo[0] (most-recent previous) is treated as on-air', () => { + // partInstanceId3 = previous (index 0), partInstanceId0 = current + const testee = createTestee() + const cache: ContentCache = { + RundownPlaylists: new ReactiveCacheCollection('rundownPlaylists'), + ShowStyleBases: new ReactiveCacheCollection('showStyleBases'), + PieceInstances: new ReactiveCacheCollection('pieceInstances'), + PartInstances: new ReactiveCacheCollection('partInstances'), + } + cache.RundownPlaylists.insert({ + _id: playlistId, + activationId, + previousPartsInfo: [{ partInstanceId: partInstanceId3 }], + currentPartInfo: { partInstanceId: partInstanceId0 }, + nextPartInfo: { partInstanceId: partInstanceId1 }, + } as DBRundownPlaylist) + cache.ShowStyleBases.insert({ + _id: showStyleBaseId, + sourceLayersWithOverrides: wrapDefaultObject( + normalizeArray( + [ + literal({ + _id: sourceLayerId0, + _rank: 0, + name: 'Camera', + type: SourceLayerType.CAMERA, + }), + ], + '_id' + ) + ), + } as DBShowStyleBase) + // Piece in the previous part — started playback, not yet stopped + cache.PieceInstances.insert({ + _id: pieceInstanceId4, + piece: { + tags: [tag5], + sourceLayerId: sourceLayerId0, + enable: { start: 0 }, + lifespan: PieceLifespan.WithinPart, + }, + partInstanceId: partInstanceId3, + plannedStartedPlayback: 1000, + } as PieceInstance) + // Piece in the current part + cache.PieceInstances.insert({ + _id: pieceInstanceId0, + piece: { + tags: [tag0], + sourceLayerId: sourceLayerId0, + enable: { start: 0 }, + lifespan: PieceLifespan.WithinPart, + }, + partInstanceId: partInstanceId0, + } as PieceInstance) + cache.PartInstances.insert({ _id: partInstanceId3 } as DBPartInstance) + cache.PartInstances.insert({ _id: partInstanceId0 } as DBPartInstance) + cache.PartInstances.insert({ _id: partInstanceId1 } as DBPartInstance) + + testee.updatePieceInstances(cache, showStyleBaseId) + + // tag5 is from previous part → on-air; tag0 is from current → on-air; neither is next + expect(testee.getTallyStateFromTags({ currentPieceTags: [tag5] } as IWrappedAdLib)).toEqual({ + isActive: true, + isNext: false, + }) + expect(testee.getTallyStateFromTags({ currentPieceTags: [tag0] } as IWrappedAdLib)).toEqual({ + isActive: true, + isNext: false, + }) + }) + + test('pieces in all entries of previousPartsInfo are treated as on-air', () => { + // partInstanceId4 = older previous (index 1), partInstanceId3 = recent previous (index 0), partInstanceId0 = current + const testee = createTestee() + const cache: ContentCache = { + RundownPlaylists: new ReactiveCacheCollection('rundownPlaylists'), + ShowStyleBases: new ReactiveCacheCollection('showStyleBases'), + PieceInstances: new ReactiveCacheCollection('pieceInstances'), + PartInstances: new ReactiveCacheCollection('partInstances'), + } + cache.RundownPlaylists.insert({ + _id: playlistId, + activationId, + // most-recent-first: index 0 = partInstanceId3, index 1 = partInstanceId4 + previousPartsInfo: [{ partInstanceId: partInstanceId3 }, { partInstanceId: partInstanceId4 }], + currentPartInfo: { partInstanceId: partInstanceId0 }, + } as DBRundownPlaylist) + cache.ShowStyleBases.insert({ + _id: showStyleBaseId, + sourceLayersWithOverrides: wrapDefaultObject( + normalizeArray( + [ + literal({ + _id: sourceLayerId0, + _rank: 0, + name: 'Camera', + type: SourceLayerType.CAMERA, + }), + ], + '_id' + ) + ), + } as DBShowStyleBase) + // Piece in the most-recent previous part (index 0) + cache.PieceInstances.insert({ + _id: pieceInstanceId4, + piece: { + tags: [tag5], + sourceLayerId: sourceLayerId0, + enable: { start: 0 }, + lifespan: PieceLifespan.WithinPart, + }, + partInstanceId: partInstanceId3, + plannedStartedPlayback: 1000, + } as PieceInstance) + // Piece in the older previous part (index 1) — still has started playback, not stopped + cache.PieceInstances.insert({ + _id: pieceInstanceId5, + piece: { + tags: [tag6], + sourceLayerId: sourceLayerId0, + enable: { start: 0 }, + lifespan: PieceLifespan.WithinPart, + }, + partInstanceId: partInstanceId4, + plannedStartedPlayback: 500, + } as PieceInstance) + // Piece in the current part + cache.PieceInstances.insert({ + _id: pieceInstanceId0, + piece: { + tags: [tag0], + sourceLayerId: sourceLayerId0, + enable: { start: 0 }, + lifespan: PieceLifespan.WithinPart, + }, + partInstanceId: partInstanceId0, + } as PieceInstance) + cache.PartInstances.insert({ _id: partInstanceId4 } as DBPartInstance) + cache.PartInstances.insert({ _id: partInstanceId3 } as DBPartInstance) + cache.PartInstances.insert({ _id: partInstanceId0 } as DBPartInstance) + + testee.updatePieceInstances(cache, showStyleBaseId) + + // All three tags should be on-air + expect(testee.getTallyStateFromTags({ currentPieceTags: [tag5] } as IWrappedAdLib)).toEqual({ + isActive: true, + isNext: false, + }) + expect(testee.getTallyStateFromTags({ currentPieceTags: [tag6] } as IWrappedAdLib)).toEqual({ + isActive: true, + isNext: false, + }) + expect(testee.getTallyStateFromTags({ currentPieceTags: [tag0] } as IWrappedAdLib)).toEqual({ + isActive: true, + isNext: false, + }) + }) }) diff --git a/meteor/server/api/deviceTriggers/reactiveContentCacheForPieceInstances.ts b/meteor/server/api/deviceTriggers/reactiveContentCacheForPieceInstances.ts index 2035e70c21e..69ed0ed3ff1 100644 --- a/meteor/server/api/deviceTriggers/reactiveContentCacheForPieceInstances.ts +++ b/meteor/server/api/deviceTriggers/reactiveContentCacheForPieceInstances.ts @@ -14,7 +14,7 @@ export type RundownPlaylistFields = | 'activationId' | 'currentPartInfo' | 'nextPartInfo' - | 'previousPartInfo' + | 'previousPartsInfo' export const rundownPlaylistFieldSpecifier = literal< MongoFieldSpecifierOnesStrict> >({ @@ -23,7 +23,7 @@ export const rundownPlaylistFieldSpecifier = literal< activationId: 1, currentPartInfo: 1, nextPartInfo: 1, - previousPartInfo: 1, + previousPartsInfo: 1, }) export type PieceInstanceFields = diff --git a/packages/corelib/src/dataModel/RundownPlaylist/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist/RundownPlaylist.ts index ae5924f79f1..8565e8bdbc5 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist/RundownPlaylist.ts @@ -135,8 +135,13 @@ export interface DBRundownPlaylist { nextPartInfo: SelectedPartInstance | null /** The time offset of the next line */ nextTimeOffset?: number | null - /** the id of the Previous Part */ - previousPartInfo: SelectedPartInstance | null + /** + * Previously played PartInstances, ordered most-recent-first (index 0 = the one taken from most recently). + * There may be more than one entry when keepalive/postroll/preroll cause PartInstances to overlap: + * e.g. if Part A is still audible due to postroll when Part C is taken, both A and B are retained here + * until their timeline contribution has fully ended. + */ + previousPartsInfo: SelectedPartInstance[] /** * 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. diff --git a/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts b/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts index 439b90bba2c..1c0c1ab0561 100644 --- a/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts +++ b/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts @@ -38,7 +38,7 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI rehearsal: false, currentPartInfo: null, nextPartInfo: null, - previousPartInfo: null, + previousPartsInfo: [], timing: { type: PlaylistTimingType.None, diff --git a/packages/job-worker/src/blueprints/context/OnTimelineGenerateContext.ts b/packages/job-worker/src/blueprints/context/OnTimelineGenerateContext.ts index 3ea1f818b07..702b41cac0d 100644 --- a/packages/job-worker/src/blueprints/context/OnTimelineGenerateContext.ts +++ b/packages/job-worker/src/blueprints/context/OnTimelineGenerateContext.ts @@ -43,7 +43,7 @@ export class OnTimelineGenerateContext extends RundownContext implements ITimeli showStyleBlueprintConfig: ProcessedShowStyleConfig, playlist: ReadonlyDeep, rundown: ReadonlyDeep, - previousPartInstance: ReadonlyDeep | undefined, + previousPartInstances: ReadonlyDeep[], currentPartInstance: ReadonlyDeep | undefined, nextPartInstance: ReadonlyDeep | undefined, pieceInstances: ReadonlyDeep @@ -51,7 +51,7 @@ export class OnTimelineGenerateContext extends RundownContext implements ITimeli super( { name: playlist.name, - identifier: `playlistId=${playlist._id},previousPartInstance=${previousPartInstance?._id},currentPartInstance=${currentPartInstance?._id},nextPartInstance=${nextPartInstance?._id}`, + identifier: `playlistId=${playlist._id},previousPartInstance=${previousPartInstances[0]?._id},currentPartInstance=${currentPartInstance?._id},nextPartInstance=${nextPartInstance?._id}`, }, studio, studioBlueprintConfig, @@ -62,11 +62,12 @@ export class OnTimelineGenerateContext extends RundownContext implements ITimeli this.currentPartInstance = currentPartInstance && convertPartInstanceToBlueprints(currentPartInstance) this.nextPartInstance = nextPartInstance && convertPartInstanceToBlueprints(nextPartInstance) - this.previousPartInstance = previousPartInstance && convertPartInstanceToBlueprints(previousPartInstance) + this.previousPartInstance = + previousPartInstances[0] && convertPartInstanceToBlueprints(previousPartInstances[0]) this.quickLoopInfo = createBlueprintQuickLoopInfo(playlist) - const partInstances = _.compact([previousPartInstance, currentPartInstance, nextPartInstance]) + const partInstances = _.compact([...previousPartInstances, currentPartInstance, nextPartInstance]) for (const pieceInstance of pieceInstances) { this.#pieceInstanceCache.set(pieceInstance.instance._id, pieceInstance.instance) diff --git a/packages/job-worker/src/blueprints/context/services/__tests__/PartAndPieceInstanceActionService.test.ts b/packages/job-worker/src/blueprints/context/services/__tests__/PartAndPieceInstanceActionService.test.ts index acf6550f9eb..884119f7cdc 100644 --- a/packages/job-worker/src/blueprints/context/services/__tests__/PartAndPieceInstanceActionService.test.ts +++ b/packages/job-worker/src/blueprints/context/services/__tests__/PartAndPieceInstanceActionService.test.ts @@ -287,7 +287,7 @@ describe('Test blueprint api context', () => { if (previousPartInstance !== undefined) { await jobContext.mockCollections.RundownPlaylists.update(playlistId, { $set: { - previousPartInfo: convertInfo(previousPartInstance), + previousPartsInfo: previousPartInstance ? [convertInfo(previousPartInstance)!] : [], }, }) } diff --git a/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts b/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts index 272ea655cf2..f09482fee9e 100644 --- a/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts +++ b/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts @@ -50,7 +50,7 @@ describe('Test external message queue static methods', () => { manuallySelected: false, consumesQueuedSegmentId: false, }, - previousPartInfo: null, + previousPartsInfo: [], activationId: protectString('active'), timing: { type: PlaylistTimingType.None, @@ -200,7 +200,7 @@ describe('Test sending messages to mocked endpoints', () => { manuallySelected: false, consumesQueuedSegmentId: false, }, - previousPartInfo: null, + previousPartsInfo: [], activationId: protectString('active'), timing: { type: PlaylistTimingType.None, diff --git a/packages/job-worker/src/ingest/__tests__/ingest.test.ts b/packages/job-worker/src/ingest/__tests__/ingest.test.ts index 1c0abc828a2..3d37bc81d68 100644 --- a/packages/job-worker/src/ingest/__tests__/ingest.test.ts +++ b/packages/job-worker/src/ingest/__tests__/ingest.test.ts @@ -1853,7 +1853,7 @@ describe('Test ingest actions for rundowns and segments', () => { )) as DBRundownPlaylist expect(playlist).toBeTruthy() expect(playlist.currentPartInfo?.partInstanceId).toBe(partInstanceId1) - expect(playlist.previousPartInfo?.partInstanceId).toBe(partInstanceId0) + expect(playlist.previousPartsInfo?.[0]?.partInstanceId).toBe(partInstanceId0) const currentPartInstance = (await getSelectedPartInstances(context, playlist)) .currentPartInstance as DBPartInstance @@ -1902,7 +1902,7 @@ describe('Test ingest actions for rundowns and segments', () => { )) as DBRundownPlaylist expect(playlist).toBeTruthy() expect(playlist.currentPartInfo?.partInstanceId).toBe(partInstanceId1) - expect(playlist.previousPartInfo?.partInstanceId).toBe(partInstanceId0) + expect(playlist.previousPartsInfo?.[0]?.partInstanceId).toBe(partInstanceId0) const currentPartInstance = (await getSelectedPartInstances(context, playlist)) .currentPartInstance as DBPartInstance diff --git a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts index ce581611713..0e59246233e 100644 --- a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts +++ b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts @@ -327,7 +327,7 @@ describe('SyncChangesToPartInstancesWorker', () => { manuallySelected: false, consumesQueuedSegmentId: false, }, - previousPartInfo: null, + previousPartsInfo: [], studioId: context.studioId, name: 'mockName', created: 0, diff --git a/packages/job-worker/src/ingest/__tests__/updateNext.test.ts b/packages/job-worker/src/ingest/__tests__/updateNext.test.ts index cc40fff7157..ef7ac5b574a 100644 --- a/packages/job-worker/src/ingest/__tests__/updateNext.test.ts +++ b/packages/job-worker/src/ingest/__tests__/updateNext.test.ts @@ -27,7 +27,7 @@ async function createMockRO(context: MockJobContext): Promise { modified: 0, currentPartInfo: null, nextPartInfo: null, - previousPartInfo: null, + previousPartsInfo: [], activationId: protectString('active'), timing: { type: 'none' as any, @@ -340,7 +340,7 @@ describe('ensureNextPartIsValid', () => { consumesQueuedSegmentId: false, } : null, - previousPartInfo: null, + previousPartsInfo: [], }, }) } diff --git a/packages/job-worker/src/ingest/commit.ts b/packages/job-worker/src/ingest/commit.ts index 245eac2d80a..f7beaf93473 100644 --- a/packages/job-worker/src/ingest/commit.ts +++ b/packages/job-worker/src/ingest/commit.ts @@ -275,12 +275,12 @@ export async function CommitIngestOperation( } function canRemoveSegment( - prevPartInstance: ReadonlyDeep | undefined, + previousPartInstances: ReadonlyDeep[], currentPartInstance: ReadonlyDeep | undefined, nextPartInstance: ReadonlyDeep | undefined, segmentId: SegmentId ): boolean { - if (prevPartInstance?.segmentId === segmentId) { + if (previousPartInstances.some((p) => p.segmentId === segmentId)) { // Don't allow removing an active rundown logger.warn(`Not allowing removal of previous playing segment "${segmentId}", making segment unsynced instead`) return false @@ -602,7 +602,7 @@ export async function updatePlayoutAfterChangingRundownInPlaylist( playoutModel.previousPartInstance && playoutModel.previousPartInstance.partInstance.rundownId === rundownIdToForget ) { - playoutModel.clearPreviousPartInstance() + playoutModel.clearPreviousPartInstances() } // Ensure playout is in sync @@ -661,9 +661,10 @@ async function getSelectedPartInstances( playlist: DBRundownPlaylist, rundownIds: Array ) { + const previousInfos = playlist.previousPartsInfo ?? [] const ids = _.compact([ playlist.currentPartInfo?.partInstanceId, - playlist.previousPartInfo?.partInstanceId, + ...previousInfos.map((p) => p.partInstanceId), playlist.nextPartInfo?.partInstanceId, ]) @@ -678,7 +679,9 @@ async function getSelectedPartInstances( const currentPartInstance = instances.find((inst) => inst._id === playlist.currentPartInfo?.partInstanceId) const nextPartInstance = instances.find((inst) => inst._id === playlist.nextPartInfo?.partInstanceId) - const previousPartInstance = instances.find((inst) => inst._id === playlist.previousPartInfo?.partInstanceId) + const previousPartInstances = previousInfos + .map((info) => instances.find((inst) => inst._id === info.partInstanceId)) + .filter((inst): inst is DBPartInstance => inst !== undefined) if (playlist.currentPartInfo?.partInstanceId && !currentPartInstance) logger.error( @@ -688,15 +691,17 @@ async function getSelectedPartInstances( logger.error( `playlist.nextPartInfo is set, but PartInstance "${playlist.nextPartInfo?.partInstanceId}" was not found!` ) - if (playlist.previousPartInfo?.partInstanceId && !previousPartInstance) - logger.error( - `playlist.previousPartInfo is set, but PartInstance "${playlist.previousPartInfo?.partInstanceId}" was not found!` - ) + for (const info of previousInfos) { + if (!previousPartInstances.find((inst) => inst._id === info.partInstanceId)) + logger.error( + `playlist.previousPartsInfo contains partInstanceId "${info.partInstanceId}" but PartInstance was not found!` + ) + } return { currentPartInstance, nextPartInstance, - previousPartInstance, + previousPartInstances, } } @@ -776,7 +781,7 @@ async function removeSegments( _changedSegmentIds: ReadonlyDeep, removedSegmentIds: ReadonlyDeep ) { - const { previousPartInstance, currentPartInstance, nextPartInstance } = await getSelectedPartInstances( + const { previousPartInstances, currentPartInstance, nextPartInstance } = await getSelectedPartInstances( context, newPlaylist, rundownsInPlaylist.map((r) => r._id) @@ -786,7 +791,7 @@ async function removeSegments( const orphanDeletedSegmentIds = new Set() const orphanHiddenSegmentIds = new Set() for (const segmentId of removedSegmentIds) { - if (canRemoveSegment(previousPartInstance, currentPartInstance, nextPartInstance, segmentId)) { + if (canRemoveSegment(previousPartInstances, currentPartInstance, nextPartInstance, segmentId)) { purgeSegmentIds.add(segmentId) } else { logger.warn( @@ -801,7 +806,7 @@ async function removeSegments( if (segment.segment.isHidden) { // Blueprints want to hide the Segment - if (!canRemoveSegment(previousPartInstance, currentPartInstance, nextPartInstance, segmentId)) { + if (!canRemoveSegment(previousPartInstances, currentPartInstance, nextPartInstance, segmentId)) { // The Segment is live, so we need to protect it from being hidden logger.warn(`Cannot hide live segment ${segmentId}, it will be orphaned`) switch (segment.segment.orphaned) { @@ -822,7 +827,7 @@ async function removeSegments( } else if (!orphanDeletedSegmentIds.has(segmentId) && segment.parts.length === 0) { // No parts in segment - if (!canRemoveSegment(previousPartInstance, currentPartInstance, nextPartInstance, segmentId)) { + if (!canRemoveSegment(previousPartInstances, currentPartInstance, nextPartInstance, segmentId)) { // Protect live segment from being hidden logger.warn(`Cannot hide live segment ${segmentId}, it will be orphaned`) orphanHiddenSegmentIds.add(segmentId) @@ -862,10 +867,12 @@ async function removeSegments( for (const segmentId of purgeSegmentIds) { logger.debug( `IngestModel: Removing segment "${segmentId}" (` + - `previousPartInfo?.partInstanceId: ${newPlaylist.previousPartInfo?.partInstanceId},` + + `previousPartsInfo ids: ${JSON.stringify( + newPlaylist.previousPartsInfo?.map((p) => p.partInstanceId) + )},` + `currentPartInfo?.partInstanceId: ${newPlaylist.currentPartInfo?.partInstanceId},` + `nextPartInfo?.partInstanceId: ${newPlaylist.nextPartInfo?.partInstanceId},` + - `previousPartInstance.segmentId: ${!previousPartInstance ? 'N/A' : previousPartInstance.segmentId},` + + `previousPartInstances segmentIds: ${JSON.stringify(previousPartInstances.map((p) => p.segmentId))},` + `currentPartInstance.segmentId: ${!currentPartInstance ? 'N/A' : currentPartInstance.segmentId},` + `nextPartInstance.segmentId: ${!nextPartInstance ? 'N/A' : nextPartInstance.segmentId}` + `)` diff --git a/packages/job-worker/src/ingest/mosDevice/__tests__/__snapshots__/mosIngest.test.ts.snap b/packages/job-worker/src/ingest/mosDevice/__tests__/__snapshots__/mosIngest.test.ts.snap index a6acb97b01a..576e055772d 100644 --- a/packages/job-worker/src/ingest/mosDevice/__tests__/__snapshots__/mosIngest.test.ts.snap +++ b/packages/job-worker/src/ingest/mosDevice/__tests__/__snapshots__/mosIngest.test.ts.snap @@ -10,7 +10,7 @@ exports[`Test recieved mos ingest payloads mosRoCreate 1`] = ` "name": "All effect1 into clip combinations", "nextPartInfo": null, "notes": [], - "previousPartInfo": null, + "previousPartsInfo": [], "rundownIdsInOrder": [ "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], @@ -322,7 +322,7 @@ exports[`Test recieved mos ingest payloads mosRoCreate: replace existing 1`] = ` "name": "All effect1 into clip combinations", "nextPartInfo": null, "notes": [], - "previousPartInfo": null, + "previousPartsInfo": [], "rundownIdsInOrder": [ "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], @@ -626,7 +626,7 @@ exports[`Test recieved mos ingest payloads mosRoFullStory: Valid data 1`] = ` "name": "All effect1 into clip combinations", "nextPartInfo": null, "notes": [], - "previousPartInfo": null, + "previousPartsInfo": [], "rundownIdsInOrder": [ "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], @@ -951,7 +951,7 @@ exports[`Test recieved mos ingest payloads mosRoReadyToAir: Update ro 1`] = ` "name": "All effect1 into clip combinations", "nextPartInfo": null, "notes": [], - "previousPartInfo": null, + "previousPartsInfo": [], "rundownIdsInOrder": [ "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], @@ -1266,7 +1266,7 @@ exports[`Test recieved mos ingest payloads mosRoStatus: Update ro 1`] = ` "name": "All effect1 into clip combinations", "nextPartInfo": null, "notes": [], - "previousPartInfo": null, + "previousPartsInfo": [], "rundownIdsInOrder": [ "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], @@ -1579,7 +1579,7 @@ exports[`Test recieved mos ingest payloads mosRoStoryDelete: Remove segment 1`] "name": "All effect1 into clip combinations", "nextPartInfo": null, "notes": [], - "previousPartInfo": null, + "previousPartsInfo": [], "rundownIdsInOrder": [ "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], @@ -1860,7 +1860,7 @@ exports[`Test recieved mos ingest payloads mosRoStoryInsert: Into segment 1`] = "name": "All effect1 into clip combinations", "nextPartInfo": null, "notes": [], - "previousPartInfo": null, + "previousPartsInfo": [], "rundownIdsInOrder": [ "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], @@ -2186,7 +2186,7 @@ exports[`Test recieved mos ingest payloads mosRoStoryInsert: New segment 1`] = ` "name": "All effect1 into clip combinations", "nextPartInfo": null, "notes": [], - "previousPartInfo": null, + "previousPartsInfo": [], "rundownIdsInOrder": [ "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], @@ -2520,7 +2520,7 @@ exports[`Test recieved mos ingest payloads mosRoStoryMove: Move whole segment to "name": "All effect1 into clip combinations", "nextPartInfo": null, "notes": [], - "previousPartInfo": null, + "previousPartsInfo": [], "rundownIdsInOrder": [ "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], @@ -2837,7 +2837,7 @@ exports[`Test recieved mos ingest payloads mosRoStoryMove: Within segment 1`] = "name": "All effect1 into clip combinations", "nextPartInfo": null, "notes": [], - "previousPartInfo": null, + "previousPartsInfo": [], "rundownIdsInOrder": [ "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], @@ -3154,7 +3154,7 @@ exports[`Test recieved mos ingest payloads mosRoStoryReplace: Same segment 1`] = "name": "All effect1 into clip combinations", "nextPartInfo": null, "notes": [], - "previousPartInfo": null, + "previousPartsInfo": [], "rundownIdsInOrder": [ "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], @@ -3470,7 +3470,7 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: Swap across segments "name": "All effect1 into clip combinations", "nextPartInfo": null, "notes": [], - "previousPartInfo": null, + "previousPartsInfo": [], "rundownIdsInOrder": [ "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], @@ -3779,7 +3779,7 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: Swap across segments2 "name": "All effect1 into clip combinations", "nextPartInfo": null, "notes": [], - "previousPartInfo": null, + "previousPartsInfo": [], "rundownIdsInOrder": [ "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], @@ -4120,7 +4120,7 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: With first in same se "name": "All effect1 into clip combinations", "nextPartInfo": null, "notes": [], - "previousPartInfo": null, + "previousPartsInfo": [], "rundownIdsInOrder": [ "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], @@ -4437,7 +4437,7 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: Within same segment 1 "name": "All effect1 into clip combinations", "nextPartInfo": null, "notes": [], - "previousPartInfo": null, + "previousPartsInfo": [], "rundownIdsInOrder": [ "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], diff --git a/packages/job-worker/src/playout/__tests__/__snapshots__/playout.test.ts.snap b/packages/job-worker/src/playout/__tests__/__snapshots__/playout.test.ts.snap index 964548f28ef..020b5727766 100644 --- a/packages/job-worker/src/playout/__tests__/__snapshots__/playout.test.ts.snap +++ b/packages/job-worker/src/playout/__tests__/__snapshots__/playout.test.ts.snap @@ -72,7 +72,7 @@ exports[`Playout API Basic rundown control 4`] = ` "name": "Default RundownPlaylist", "nextPartInfo": null, "nextTimeOffset": null, - "previousPartInfo": null, + "previousPartsInfo": [], "rehearsal": false, "resetTime": 0, "rundownIdsInOrder": [], diff --git a/packages/job-worker/src/playout/__tests__/lib.ts b/packages/job-worker/src/playout/__tests__/lib.ts index d64a134e554..db60ce8d2e7 100644 --- a/packages/job-worker/src/playout/__tests__/lib.ts +++ b/packages/job-worker/src/playout/__tests__/lib.ts @@ -21,8 +21,8 @@ export async function getSelectedPartInstances( playlist.nextPartInfo ? context.directCollections.PartInstances.findOne(playlist.nextPartInfo.partInstanceId) : null, - playlist.previousPartInfo - ? context.directCollections.PartInstances.findOne(playlist.previousPartInfo.partInstanceId) + playlist.previousPartsInfo?.[0] + ? context.directCollections.PartInstances.findOne(playlist.previousPartsInfo[0].partInstanceId) : null, ]) @@ -31,7 +31,7 @@ export async function getSelectedPartInstances( if (nextPartInstance === undefined) throw new Error(`Missing currentPartInstance "${playlist.nextPartInfo?.partInstanceId}"`) if (previousPartInstance === undefined) - throw new Error(`Missing currentPartInstance "${playlist.previousPartInfo?.partInstanceId}"`) + throw new Error(`Missing currentPartInstance "${playlist.previousPartsInfo?.[0]?.partInstanceId}"`) return { currentPartInstance, nextPartInstance, previousPartInstance } } diff --git a/packages/job-worker/src/playout/__tests__/resolvedPieces.test.ts b/packages/job-worker/src/playout/__tests__/resolvedPieces.test.ts index bb83480a061..e13492d1cf8 100644 --- a/packages/job-worker/src/playout/__tests__/resolvedPieces.test.ts +++ b/packages/job-worker/src/playout/__tests__/resolvedPieces.test.ts @@ -405,7 +405,7 @@ describe('Resolved Pieces', () => { const resolvedPieces = getResolvedPiecesForPartInstancesOnTimeline( context, - { current: currentPartInfo }, + { previous: [], current: currentPartInfo }, now ) @@ -441,7 +441,7 @@ describe('Resolved Pieces', () => { // Check the result const simpleResolvedPieces = getResolvedPiecesForPartInstancesOnTimeline( context, - { current: currentPartInfo }, + { previous: [], current: currentPartInfo }, now ) expect(stripResult(simpleResolvedPieces)).toEqual([ @@ -481,7 +481,7 @@ describe('Resolved Pieces', () => { const simpleResolvedPieces = getResolvedPiecesForPartInstancesOnTimeline( context, - { current: currentPartInfo }, + { previous: [], current: currentPartInfo }, now ) expect(stripResult(simpleResolvedPieces)).toEqual([ @@ -512,7 +512,7 @@ describe('Resolved Pieces', () => { const simpleResolvedPieces = getResolvedPiecesForPartInstancesOnTimeline( context, - { current: currentPartInfo }, + { previous: [], current: currentPartInfo }, now ) expect(stripResult(simpleResolvedPieces)).toEqual([ @@ -556,7 +556,7 @@ describe('Resolved Pieces', () => { const simpleResolvedPieces = getResolvedPiecesForPartInstancesOnTimeline( context, - { current: currentPartInfo }, + { previous: [], current: currentPartInfo }, now ) expect(stripResult(simpleResolvedPieces)).toEqual([ @@ -600,7 +600,7 @@ describe('Resolved Pieces', () => { const simpleResolvedPieces = getResolvedPiecesForPartInstancesOnTimeline( context, - { current: currentPartInfo }, + { previous: [], current: currentPartInfo }, now ) expect(stripResult(simpleResolvedPieces)).toEqual([ @@ -632,7 +632,7 @@ describe('Resolved Pieces', () => { context, { current: currentPartInfo, - previous: previousPartInfo, + previous: [previousPartInfo], }, now ) @@ -681,7 +681,7 @@ describe('Resolved Pieces', () => { context, { current: currentPartInfo, - previous: previousPartInfo, + previous: [previousPartInfo], }, now ) @@ -756,7 +756,7 @@ describe('Resolved Pieces', () => { context, { current: currentPartInfo, - previous: previousPartInfo, + previous: [previousPartInfo], }, now ) @@ -806,6 +806,7 @@ describe('Resolved Pieces', () => { const simpleResolvedPieces = getResolvedPiecesForPartInstancesOnTimeline( context, { + previous: [], current: currentPartInfo, next: nextPartInfo, }, @@ -860,6 +861,7 @@ describe('Resolved Pieces', () => { const simpleResolvedPieces = getResolvedPiecesForPartInstancesOnTimeline( context, { + previous: [], current: currentPartInfo, next: nextPartInfo, }, @@ -941,6 +943,7 @@ describe('Resolved Pieces', () => { const simpleResolvedPieces = getResolvedPiecesForPartInstancesOnTimeline( context, { + previous: [], current: currentPartInfo, next: nextPartInfo, }, @@ -965,5 +968,105 @@ describe('Resolved Pieces', () => { }, ] satisfies StrippedResult) }) + + test('two previous parts: each is capped at the start of the part that followed it', async () => { + const sourceLayerId = Object.keys(sourceLayers)[0] + expect(sourceLayerId).toBeTruthy() + + // Timeline: prev1 starts | prev0 starts | current starts | now + // t=1000 t=5000 t=8000 t=10000 + const now = 10000 + const currentStarted = 8000 + const prev0Started = 5000 + const prev1Started = 1000 + + const piecePrev1 = createPieceInstance(sourceLayerId, { start: 0 }) + const piecePrev0 = createPieceInstance(sourceLayerId, { start: 0 }) + const pieceCurrent = createPieceInstance(sourceLayerId, { start: 0 }) + + const prev1Times = createPartCurrentTimes(now, prev1Started) + const prev0Times = createPartCurrentTimes(now, prev0Started) + const currentTimes = createPartCurrentTimes(now, currentStarted) + + const prev1Info = createPartInstanceInfo(prev1Times, createPartInstance(), [piecePrev1]) + const prev0Info = createPartInstanceInfo(prev0Times, createPartInstance(), [piecePrev0]) + const currentInfo = createPartInstanceInfo(currentTimes, createPartInstance(), [pieceCurrent]) + + const resolvedPieces = getResolvedPiecesForPartInstancesOnTimeline( + context, + // most-recent previous first + { previous: [prev0Info, prev1Info], current: currentInfo }, + now + ) + + expect(stripResult(resolvedPieces)).toEqual([ + { + _id: piecePrev1._id, + // prev1 is capped at prev0.partStarted + resolvedStart: prev1Times.partStartTime!, + resolvedDuration: prev0Started - prev1Started, // 4000 + }, + { + _id: piecePrev0._id, + // prev0 is capped at currentStarted + resolvedStart: prev0Times.partStartTime!, + resolvedDuration: currentStarted - prev0Started, // 3000 + }, + { + _id: pieceCurrent._id, + resolvedStart: currentTimes.partStartTime!, + resolvedDuration: undefined, + }, + ] satisfies StrippedResult) + }) + + test('two previous parts: piece in older previous ending before cap is not extended', async () => { + const sourceLayerId = Object.keys(sourceLayers)[0] + expect(sourceLayerId).toBeTruthy() + + // Timeline: prev1 starts | prev0 starts | current starts | now + // t=1000 t=5000 t=8000 t=10000 + const now = 10000 + const currentStarted = 8000 + const prev0Started = 5000 + const prev1Started = 1000 + + // Short piece: ends at t=2000, well before prev0 starts at t=5000 + const shortPiece = createPieceInstance(sourceLayerId, { start: 0, duration: 2000 }) + const piecePrev0 = createPieceInstance(sourceLayerId, { start: 0 }) + const pieceCurrent = createPieceInstance(sourceLayerId, { start: 0 }) + + const prev1Times = createPartCurrentTimes(now, prev1Started) + const prev0Times = createPartCurrentTimes(now, prev0Started) + const currentTimes = createPartCurrentTimes(now, currentStarted) + + const prev1Info = createPartInstanceInfo(prev1Times, createPartInstance(), [shortPiece]) + const prev0Info = createPartInstanceInfo(prev0Times, createPartInstance(), [piecePrev0]) + const currentInfo = createPartInstanceInfo(currentTimes, createPartInstance(), [pieceCurrent]) + + const resolvedPieces = getResolvedPiecesForPartInstancesOnTimeline( + context, + { previous: [prev0Info, prev1Info], current: currentInfo }, + now + ) + + expect(stripResult(resolvedPieces)).toEqual([ + { + _id: shortPiece._id, + resolvedStart: prev1Times.partStartTime!, + resolvedDuration: 2000, // not extended to cap (cap=4000) — piece ends naturally before cap + }, + { + _id: piecePrev0._id, + resolvedStart: prev0Times.partStartTime!, + resolvedDuration: currentStarted - prev0Started, // 3000 + }, + { + _id: pieceCurrent._id, + resolvedStart: currentTimes.partStartTime!, + resolvedDuration: undefined, + }, + ] satisfies StrippedResult) + }) }) }) diff --git a/packages/job-worker/src/playout/__tests__/tTimersJobs.test.ts b/packages/job-worker/src/playout/__tests__/tTimersJobs.test.ts index 836bd5fb576..fae8dd6b850 100644 --- a/packages/job-worker/src/playout/__tests__/tTimersJobs.test.ts +++ b/packages/job-worker/src/playout/__tests__/tTimersJobs.test.ts @@ -39,7 +39,7 @@ describe('tTimersJobs', () => { modified: 0, currentPartInfo: null, nextPartInfo: null, - previousPartInfo: null, + previousPartsInfo: [], rundownIdsInOrder: [], timing: { type: 'none' as any, @@ -88,7 +88,7 @@ describe('tTimersJobs', () => { modified: 0, currentPartInfo: null, nextPartInfo: null, - previousPartInfo: null, + previousPartsInfo: [], rundownIdsInOrder: [], timing: { type: 'none' as any, @@ -138,7 +138,7 @@ describe('tTimersJobs', () => { modified: 0, currentPartInfo: null, nextPartInfo: null, - previousPartInfo: null, + previousPartsInfo: [], rundownIdsInOrder: [], timing: { type: 'none' as any, @@ -179,7 +179,7 @@ describe('tTimersJobs', () => { modified: 0, currentPartInfo: null, nextPartInfo: null, - previousPartInfo: null, + previousPartsInfo: [], rundownIdsInOrder: [], timing: { type: 'none' as any, diff --git a/packages/job-worker/src/playout/abPlayback/index.ts b/packages/job-worker/src/playout/abPlayback/index.ts index eab0754bd47..75d9c7c4dea 100644 --- a/packages/job-worker/src/playout/abPlayback/index.ts +++ b/packages/job-worker/src/playout/abPlayback/index.ts @@ -55,7 +55,7 @@ export function applyAbPlaybackForTimeline( const blueprintContext = new ShowStyleContext( { name: playlist.name, - identifier: `playlistId=${playlist._id},previousPartInstance=${playlist.previousPartInfo?.partInstanceId},currentPartInstance=${playlist.currentPartInfo?.partInstanceId},nextPartInstance=${playlist.nextPartInfo?.partInstanceId}`, + identifier: `playlistId=${playlist._id},previousPartInstance=${playlist.previousPartsInfo?.[0]?.partInstanceId},currentPartInstance=${playlist.currentPartInfo?.partInstanceId},nextPartInstance=${playlist.nextPartInfo?.partInstanceId}`, }, context.studio, context.getStudioBlueprintConfig(), diff --git a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/basicBehavior.test.ts b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/basicBehavior.test.ts index 7a47a4bca87..70cc30d10a7 100644 --- a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/basicBehavior.test.ts +++ b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/basicBehavior.test.ts @@ -19,7 +19,7 @@ const onAirPlayoutState = findForLayerTestConstants.playoutState.onAir describe('findLookaheadForLayer – basic behavior', () => { test('no parts', () => { - const res = findLookaheadForLayer(context, {}, [], 'abc', 1, 1, onAirPlayoutState) + const res = findLookaheadForLayer(context, { previous: [] }, [], 'abc', 1, 1, onAirPlayoutState) expect(res.timed).toHaveLength(0) expect(res.future).toHaveLength(0) @@ -27,15 +27,7 @@ describe('findLookaheadForLayer – basic behavior', () => { test('if the previous part is unset', () => { findLookaheadObjectsForPartMock.mockReturnValue([]) - findLookaheadForLayer( - context, - { previous: undefined, current, next: nextFuture }, - [], - layer, - 1, - 1, - onAirPlayoutState - ) + findLookaheadForLayer(context, { previous: [], current, next: nextFuture }, [], layer, 1, 1, onAirPlayoutState) expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(2) expectInstancesToMatch(findLookaheadObjectsForPartMock, 1, layer, current, undefined, onAirPlayoutState) diff --git a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/constants.ts b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/constants.ts index e7dce409b77..eaad0d20442 100644 --- a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/constants.ts +++ b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/constants.ts @@ -22,12 +22,14 @@ export const findForLayerTestConstants = { includeWhenNotInHoldObjects: true, } as TimelinePlayoutState, }, - previous: { - part: { _id: 'pPrev', part: 'prev' }, - allPieces: [createFakePiece('1'), createFakePiece('2'), createFakePiece('3')], - onTimeline: true, - nowInPart: 2000, - } as any as PartInstanceAndPieceInstances, + previous: [ + { + part: { _id: 'pPrev', part: 'prev' }, + allPieces: [createFakePiece('1'), createFakePiece('2'), createFakePiece('3')], + onTimeline: true, + nowInPart: 2000, + }, + ] as any as PartInstanceAndPieceInstances[], current: { part: { _id: 'pCur', part: 'cur' }, allPieces: [createFakePiece('4'), createFakePiece('5'), createFakePiece('6')], diff --git a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/orderedParts.test.ts b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/orderedParts.test.ts index 5353697dc8c..9cab1584acc 100644 --- a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/orderedParts.test.ts +++ b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/orderedParts.test.ts @@ -38,7 +38,16 @@ describe('findLookaheadForLayer - orderedParts', () => { .mockReturnValueOnce(['t6', 't7'] as any) .mockReturnValueOnce(['t8', 't9'] as any) - const res2 = findLookaheadForLayer(context, {}, orderedParts, layer, 1, 4, onAirPlayoutState, null) + const res2 = findLookaheadForLayer( + context, + { previous: [] }, + orderedParts, + layer, + 1, + 4, + onAirPlayoutState, + null + ) expect(res2.timed).toHaveLength(0) expect(res2.future).toEqual(['t0', 't1']) @@ -58,7 +67,16 @@ describe('findLookaheadForLayer - orderedParts', () => { test('returns nothing when target index is 0', () => { findLookaheadObjectsForPartMock.mockReturnValue([]) - const res3 = findLookaheadForLayer(context, {}, orderedParts, layer, 0, 4, onAirPlayoutState, null) + const res3 = findLookaheadForLayer( + context, + { previous: [] }, + orderedParts, + layer, + 0, + 4, + onAirPlayoutState, + null + ) expect(res3.timed).toHaveLength(0) expect(res3.future).toHaveLength(0) @@ -74,7 +92,16 @@ describe('findLookaheadForLayer - orderedParts', () => { .mockReturnValueOnce(['t6', 't7'] as any) // 4th part .mockReturnValueOnce(['t8', 't9'] as any) // 5th part - we shouldn't see objects from this one due to the maximum search distance - const res4 = findLookaheadForLayer(context, {}, orderedParts, layer, 100, 5, onAirPlayoutState, null) + const res4 = findLookaheadForLayer( + context, + { previous: [] }, + orderedParts, + layer, + 100, + 5, + onAirPlayoutState, + null + ) expect(res4.timed).toHaveLength(0) expect(res4.future).toEqual(['t0', 't1', 't2', 't3', 't4', 't5', 't6', 't7']) diff --git a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/playoutStatePropagation.test.ts b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/playoutStatePropagation.test.ts index 2de1a7c9cd9..8d0b58e11c9 100644 --- a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/playoutStatePropagation.test.ts +++ b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/playoutStatePropagation.test.ts @@ -20,6 +20,7 @@ const playoutState = findForLayerTestConstants.playoutState describe('playoutState propagates to findLookaheadObjectsForPart', () => { test('onAir inHold propagation for partInstances (current and next)', () => { const partInstancesInfo: PartInstanceAndPieceInstancesInfos = { + previous: [], current, next: nextFuture, } @@ -53,6 +54,7 @@ describe('playoutState propagates to findLookaheadObjectsForPart', () => { }) test('Rehearsal propagation for partInstances (current and next)', () => { const partInstancesInfo: PartInstanceAndPieceInstancesInfos = { + previous: [], current, next: nextFuture, } @@ -95,7 +97,9 @@ describe('playoutState propagates to findLookaheadObjectsForPart', () => { findLookaheadForLayer( context, - {}, + { + previous: [], + }, rehearsalInHoldOrderedParts, layer, 100, diff --git a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/searchDistance.test.ts b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/searchDistance.test.ts index f8352d2317d..aecb94c4467 100644 --- a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/searchDistance.test.ts +++ b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/searchDistance.test.ts @@ -21,7 +21,7 @@ const onAirPlayoutState = findForLayerTestConstants.playoutState.onAir describe('findLookaheadForLayer – search distance', () => { test('searchDistance = 0 ignores future parts', () => { - findLookaheadObjectsForPartMock.mockReturnValueOnce(['cur0', 'cur1'] as any) + findLookaheadObjectsForPartMock.mockReturnValueOnce([] as any).mockReturnValueOnce(['cur0', 'cur1'] as any) const res = findLookaheadForLayer( context, @@ -37,9 +37,9 @@ describe('findLookaheadForLayer – search distance', () => { expect(res.timed).toEqual(['cur0', 'cur1']) expect(res.future).toHaveLength(0) - expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(2) - expectInstancesToMatch(findLookaheadObjectsForPartMock, 1, layer, current, previous, onAirPlayoutState) - expectInstancesToMatch(findLookaheadObjectsForPartMock, 2, layer, nextFuture, current, onAirPlayoutState) + expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(3) + expectInstancesToMatch(findLookaheadObjectsForPartMock, 2, layer, current, previous[0], onAirPlayoutState) + expectInstancesToMatch(findLookaheadObjectsForPartMock, 3, layer, nextFuture, current, onAirPlayoutState) }) test('returns nothing when maxSearchDistance is too small', () => { @@ -51,7 +51,18 @@ describe('findLookaheadForLayer – search distance', () => { .mockReturnValueOnce(['t6', 't7'] as any) .mockReturnValueOnce(['t8', 't9'] as any) - const res = findLookaheadForLayer(context, {}, orderedParts, layer, 1, 1, onAirPlayoutState, null) + const res = findLookaheadForLayer( + context, + { + previous: [], + }, + orderedParts, + layer, + 1, + 1, + onAirPlayoutState, + null + ) expect(res.timed).toHaveLength(0) expect(res.future).toHaveLength(0) diff --git a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/timing.test.ts b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/timing.test.ts index 77e40ffb877..4b6b68114ea 100644 --- a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/timing.test.ts +++ b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/timing.test.ts @@ -22,6 +22,7 @@ const layer = findForLayerTestConstants.layer describe('findLookaheadForLayer – timing', () => { test('current part with timed next part (all goes into timed)', () => { findLookaheadObjectsForPartMock + .mockReturnValueOnce([] as any) .mockReturnValueOnce(['cur0', 'cur1'] as any) .mockReturnValueOnce(['nT0', 'nT1'] as any) @@ -39,13 +40,14 @@ describe('findLookaheadForLayer – timing', () => { expect(res.timed).toEqual(['cur0', 'cur1', 'nT0', 'nT1']) // should have all pieces expect(res.future).toHaveLength(0) // should be empty - expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(2) - expectInstancesToMatch(findLookaheadObjectsForPartMock, 1, layer, current, previous, onAirPlayoutState) - expectInstancesToMatch(findLookaheadObjectsForPartMock, 2, layer, nextTimed, current, onAirPlayoutState) + expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(3) + expectInstancesToMatch(findLookaheadObjectsForPartMock, 2, layer, current, previous[0], onAirPlayoutState) + expectInstancesToMatch(findLookaheadObjectsForPartMock, 3, layer, nextTimed, current, onAirPlayoutState) }) test('current part with un-timed next part (next goes into future)', () => { findLookaheadObjectsForPartMock + .mockReturnValueOnce([] as any) .mockReturnValueOnce(['cur0', 'cur1'] as any) .mockReturnValueOnce(['nF0', 'nF1'] as any) @@ -63,8 +65,8 @@ describe('findLookaheadForLayer – timing', () => { expect(res.timed).toEqual(['cur0', 'cur1']) // Should only contain the current part's pieces expect(res.future).toEqual(['nF0', 'nF1']) // Should only contain the future pieces - expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(2) - expectInstancesToMatch(findLookaheadObjectsForPartMock, 1, layer, current, previous, onAirPlayoutState) - expectInstancesToMatch(findLookaheadObjectsForPartMock, 2, layer, nextFuture, current, onAirPlayoutState) + expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(3) + expectInstancesToMatch(findLookaheadObjectsForPartMock, 2, layer, current, previous[0], onAirPlayoutState) + expectInstancesToMatch(findLookaheadObjectsForPartMock, 3, layer, nextFuture, current, onAirPlayoutState) }) }) diff --git a/packages/job-worker/src/playout/lookahead/__tests__/lookahead.test.ts b/packages/job-worker/src/playout/lookahead/__tests__/lookahead.test.ts index de4b4430fc4..a13a4555fee 100644 --- a/packages/job-worker/src/playout/lookahead/__tests__/lookahead.test.ts +++ b/packages/job-worker/src/playout/lookahead/__tests__/lookahead.test.ts @@ -163,7 +163,7 @@ describe('Lookahead', () => { } test('No pieces', async () => { - const partInstancesInfo: SelectedPartInstancesTimelineInfo = {} + const partInstancesInfo: SelectedPartInstancesTimelineInfo = { previous: [] } const fakeParts = partIds.map((p) => ({ part: { _id: p } as any, usesInTransition: true, pieces: [] })) getOrderedPartsAfterPlayheadMock.mockReturnValueOnce(fakeParts.map((p) => p.part)) @@ -175,7 +175,7 @@ describe('Lookahead', () => { expect(getOrderedPartsAfterPlayheadMock).toHaveBeenCalledTimes(1) expect(getOrderedPartsAfterPlayheadMock).toHaveBeenCalledWith(context, expect.anything(), 10) // default distance - await expectLookaheadForLayerMock(playlistId, {}, fakeParts) + await expectLookaheadForLayerMock(playlistId, { previous: [] }, fakeParts) }) function fakeResultObj(id: string, pieceId: string, layer: string): LookaheadTimelineObject { @@ -188,7 +188,7 @@ describe('Lookahead', () => { } test('got some objects', async () => { - const partInstancesInfo: SelectedPartInstancesTimelineInfo = {} + const partInstancesInfo: SelectedPartInstancesTimelineInfo = { previous: [] } const fakeParts = partIds.map((p) => ({ part: { _id: p } as any, usesInTransition: true, pieces: [] })) getOrderedPartsAfterPlayheadMock.mockReturnValueOnce(fakeParts.map((p) => p.part)) @@ -238,11 +238,11 @@ describe('Lookahead', () => { expect(getOrderedPartsAfterPlayheadMock).toHaveBeenCalledTimes(1) expect(getOrderedPartsAfterPlayheadMock).toHaveBeenCalledWith(context, expect.anything(), 10) // default distance - await expectLookaheadForLayerMock(playlistId, {}, fakeParts) + await expectLookaheadForLayerMock(playlistId, { previous: [] }, fakeParts) }) test('Different max distances', async () => { - const partInstancesInfo: SelectedPartInstancesTimelineInfo = {} + const partInstancesInfo: SelectedPartInstancesTimelineInfo = { previous: [] } // Set really low { @@ -293,28 +293,29 @@ describe('Lookahead', () => { // It does have assertions, but hidden inside helper methods expect(true).toBeTruthy() - const partInstancesInfo: SelectedPartInstancesTimelineInfo = {} - partInstancesInfo.previous = { + const partInstancesInfo: SelectedPartInstancesTimelineInfo = { previous: [] } + const previousEntry = { partInstance: { _id: 'abc2', part: { _id: 'abc' } } as any, partTimes: createPartCurrentTimes(getCurrentTime(), getCurrentTime() + 546), pieceInstances: ['1', '2'] as any, calculatedTimings: { inTransitionStart: null } as any, regenerateTimelineAt: undefined, } + partInstancesInfo.previous = [previousEntry] const expectedPrevious = { - part: partInstancesInfo.previous.partInstance, + part: previousEntry.partInstance, onTimeline: true, - nowInPart: partInstancesInfo.previous.partTimes.nowInPart, - allPieces: partInstancesInfo.previous.pieceInstances, - calculatedTimings: partInstancesInfo.previous.calculatedTimings, + nowInPart: previousEntry.partTimes.nowInPart, + allPieces: previousEntry.pieceInstances, + calculatedTimings: previousEntry.calculatedTimings, } // With a previous await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => getLookeaheadObjects(context, playoutModel, partInstancesInfo) ) - await expectLookaheadForLayerMock(playlistId, { previous: expectedPrevious }, fakeParts) + await expectLookaheadForLayerMock(playlistId, { previous: [expectedPrevious] }, fakeParts) // Add a current partInstancesInfo.current = { @@ -336,7 +337,7 @@ describe('Lookahead', () => { ) await expectLookaheadForLayerMock( playlistId, - { current: expectedCurrent, previous: expectedPrevious }, + { previous: [expectedPrevious], current: expectedCurrent }, fakeParts ) @@ -360,7 +361,7 @@ describe('Lookahead', () => { ) await expectLookaheadForLayerMock( playlistId, - { current: expectedCurrent, next: expectedNext, previous: expectedPrevious }, + { previous: [expectedPrevious], current: expectedCurrent, next: expectedNext }, fakeParts ) @@ -372,13 +373,13 @@ describe('Lookahead', () => { ) await expectLookaheadForLayerMock( playlistId, - { current: expectedCurrent, next: expectedNext, previous: expectedPrevious }, + { previous: [expectedPrevious], current: expectedCurrent, next: expectedNext }, fakeParts ) }) test('Playlist state influences playoutState parameter', async () => { - const partInstancesInfo: SelectedPartInstancesTimelineInfo = {} + const partInstancesInfo: SelectedPartInstancesTimelineInfo = { previous: [] } const fakeParts = partIds.map((p) => ({ part: { _id: p } as any, usesInTransition: true, pieces: [] })) getOrderedPartsAfterPlayheadMock.mockReturnValue(fakeParts.map((p) => p.part)) @@ -390,7 +391,7 @@ describe('Lookahead', () => { expect(findLookaheadForLayerMock).toHaveBeenCalledWith( context, - {}, + { previous: [] }, fakeParts, 'PRELOAD', 1, @@ -411,7 +412,7 @@ describe('Lookahead', () => { expect(findLookaheadForLayerMock).toHaveBeenCalledWith( context, - {}, + { previous: [] }, fakeParts, 'PRELOAD', 1, @@ -432,7 +433,7 @@ describe('Lookahead', () => { expect(findLookaheadForLayerMock).toHaveBeenCalledWith( context, - {}, + { previous: [] }, fakeParts, 'PRELOAD', 1, diff --git a/packages/job-worker/src/playout/lookahead/__tests__/lookaheadOffset/lookaheadOffset.test.ts b/packages/job-worker/src/playout/lookahead/__tests__/lookaheadOffset/lookaheadOffset.test.ts index 7c969d1d619..c0a08c9c729 100644 --- a/packages/job-worker/src/playout/lookahead/__tests__/lookaheadOffset/lookaheadOffset.test.ts +++ b/packages/job-worker/src/playout/lookahead/__tests__/lookaheadOffset/lookaheadOffset.test.ts @@ -54,7 +54,9 @@ describe('lookahead offset integration', () => { }, } as JobContext - const res = await getLookeaheadObjects(context, playoutModel, {} as SelectedPartInstancesTimelineInfo) + const res = await getLookeaheadObjects(context, playoutModel, { + previous: [], + } as SelectedPartInstancesTimelineInfo) expect(res).toEqual([]) }) @@ -99,7 +101,7 @@ describe('lookahead offset integration', () => { const res = await getLookeaheadObjects(context, playoutModel, { current: undefined, next: undefined, - previous: undefined, + previous: [], } as SelectedPartInstancesTimelineInfo) expect(res).toHaveLength(2) @@ -146,7 +148,9 @@ describe('lookahead offset integration', () => { makePiece({ partId: 'p2', layer: 'layer1', start: 2000 }), ]) - const res = await getLookeaheadObjects(context, playoutModel, {} as SelectedPartInstancesTimelineInfo) + const res = await getLookeaheadObjects(context, playoutModel, { + previous: [], + } as SelectedPartInstancesTimelineInfo) expect(res).toHaveLength(2) expect(res[0].lookaheadOffset).toBe(5000) @@ -170,6 +174,7 @@ describe('lookahead offset integration', () => { ]) const res = await getLookeaheadObjects(context, playoutModel, { + previous: [], next: { partTimes: { nowInPart: 0 }, partInstance: { @@ -213,6 +218,7 @@ describe('lookahead offset integration', () => { .mockResolvedValue(lookaheadOffsetTestConstants.multiLayerPart.pieces) const res = await getLookeaheadObjects(context, playoutModel, { + previous: [], next: { ...lookaheadOffsetTestConstants.multiLayerPart, pieceInstances: lookaheadOffsetTestConstants.multiLayerPart.pieces.map((piece) => @@ -246,6 +252,7 @@ describe('lookahead offset integration', () => { .mockResolvedValue(lookaheadOffsetTestConstants.multiLayerPartWhile.pieces) const res = await getLookeaheadObjects(context, playoutModel, { + previous: [], next: { ...lookaheadOffsetTestConstants.multiLayerPartWhile, pieceInstances: lookaheadOffsetTestConstants.multiLayerPartWhile.pieces.map((piece) => @@ -279,6 +286,7 @@ describe('lookahead offset integration', () => { .mockResolvedValue(lookaheadOffsetTestConstants.singleLayerPart.pieces) const res = await getLookeaheadObjects(context, playoutModel, { + previous: [], next: { ...lookaheadOffsetTestConstants.singleLayerPart, pieceInstances: lookaheadOffsetTestConstants.singleLayerPart.pieces.map((piece) => @@ -311,6 +319,7 @@ describe('lookahead offset integration', () => { .mockResolvedValue(lookaheadOffsetTestConstants.singleLayerPartWhile.pieces) const res = await getLookeaheadObjects(context, playoutModel, { + previous: [], next: { ...lookaheadOffsetTestConstants.singleLayerPartWhile, pieceInstances: lookaheadOffsetTestConstants.singleLayerPartWhile.pieces.map((piece) => diff --git a/packages/job-worker/src/playout/lookahead/findForLayer.ts b/packages/job-worker/src/playout/lookahead/findForLayer.ts index 9d69cb67616..3a5d4ebd8ee 100644 --- a/packages/job-worker/src/playout/lookahead/findForLayer.ts +++ b/packages/job-worker/src/playout/lookahead/findForLayer.ts @@ -13,7 +13,8 @@ export interface LookaheadResult { } export interface PartInstanceAndPieceInstancesInfos { - previous?: PartInstanceAndPieceInstances + /** Oldest-first. Each entry is "on timeline" and contributes timed lookahead data. */ + previous: PartInstanceAndPieceInstances[] current?: PartInstanceAndPieceInstances next?: PartInstanceAndPieceInstances } @@ -35,10 +36,22 @@ export function findLookaheadForLayer( future: [], } - // Track the previous info for checking how the timeline will be built let previousPart: ReadonlyDeep | undefined - if (partInstancesInfo.previous?.part.part) { - previousPart = partInstancesInfo.previous.part.part + for (const prevInfo of partInstancesInfo.previous) { + const { objs: prevObjs, partInfo: prevPartInfo } = generatePartInstanceLookaheads( + context, + prevInfo, + currentPartId, + layer, + previousPart, + playoutState + ) + if (prevInfo.onTimeline) { + res.timed.push(...prevObjs) + } else { + res.future.push(...prevObjs) + } + previousPart = prevPartInfo.part } // Generate timed/future objects for the partInstances diff --git a/packages/job-worker/src/playout/lookahead/index.ts b/packages/job-worker/src/playout/lookahead/index.ts index 447bb3352ce..1e830b92afa 100644 --- a/packages/job-worker/src/playout/lookahead/index.ts +++ b/packages/job-worker/src/playout/lookahead/index.ts @@ -106,23 +106,31 @@ export async function getLookeaheadObjects( }, }) - // Track the previous info for checking how the timeline will be built - let previousPartInfo: PartInstanceAndPieceInstances | undefined - if (partInstancesInfo0.previous) { - previousPartInfo = removeInfiniteContinuations( - { - part: partInstancesInfo0.previous.partInstance, - onTimeline: true, - nowInPart: partInstancesInfo0.previous.partTimes.nowInPart, - allPieces: getPrunedEndedPieceInstances(partInstancesInfo0.previous), - calculatedTimings: partInstancesInfo0.previous.calculatedTimings, - }, - false - ) - } - + // Previous parts are included oldest-first so that the timed-lookahead chain is ordered + // chronologically. This is necessary for WHEN_CLEAR correctness: a previous part may still + // have pieces that have not yet started (e.g. a delayed piece during a heavy overlap). Without + // including it here those pieces would not produce a timed lookahead entry, and a future part's + // lookahead (which runs at low priority with `while: '1'`) would incorrectly fill the gap on + // that layer until the real piece becomes active. + // `getPrunedEndedPieceInstances` already drops definitively-ended pieces, so only still-relevant + // pieces are included. The `classesForNext` chain threads naturally through the ordered list. const partInstancesInfo: PartInstanceAndPieceInstancesInfos = { - previous: previousPartInfo, + // partInstancesInfo0.previous is most-recent-first; reverse to oldest-first for lookahead chain ordering + previous: partInstancesInfo0.previous + .slice() + .reverse() + .map((prevInfo) => + removeInfiniteContinuations( + { + part: prevInfo.partInstance, + onTimeline: true, + nowInPart: prevInfo.partTimes.nowInPart, + allPieces: getPrunedEndedPieceInstances(prevInfo), + calculatedTimings: prevInfo.calculatedTimings, + }, + false + ) + ), current: partInstancesInfo0.current ? removeInfiniteContinuations( { diff --git a/packages/job-worker/src/playout/model/PlayoutModel.ts b/packages/job-worker/src/playout/model/PlayoutModel.ts index 86946144222..bddaacb0651 100644 --- a/packages/job-worker/src/playout/model/PlayoutModel.ts +++ b/packages/job-worker/src/playout/model/PlayoutModel.ts @@ -105,9 +105,18 @@ export interface PlayoutModelReadonly extends StudioPlayoutModelBaseReadonly { */ get olderPartInstances(): PlayoutPartInstanceModel[] /** - * The PartInstance previously played, if any + * The most recently played PartInstance (index 0 of previousPartsInfo), if any. + * Convenience accessor; use `previousPartInstances` when you need the full chain. */ get previousPartInstance(): PlayoutPartInstanceModel | null + /** + * All previously-played PartInstances that are still contributing to the timeline due to + * keepalive / postroll / preroll overlap. + * Ordered most-recent-first, mirroring `playlist.previousPartsInfo`: + * index 0 is the part most recently taken from (same as `previousPartInstance`), + * index 1 the one before that, and so on. + */ + get previousPartInstances(): PlayoutPartInstanceModel[] /** * The PartInstance currently being played, if any */ @@ -117,11 +126,11 @@ export interface PlayoutModelReadonly extends StudioPlayoutModelBaseReadonly { */ get nextPartInstance(): PlayoutPartInstanceModel | null /** - * Ids of the previous, current and next PartInstances + * Ids of all previous, current and next PartInstances (includes all entries of previousPartsInfo) */ get selectedPartInstanceIds(): PartInstanceId[] /** - * The previous, current and next PartInstances + * All previous, current and next PartInstances */ get selectedPartInstances(): PlayoutPartInstanceModel[] /** @@ -232,7 +241,7 @@ export interface PlayoutModel extends PlayoutModelReadonly, StudioPlayoutModelBa * Clear the currently selected previousPartInstance. * This can be useful if it references a Rundown that has been removed from the Playlist */ - clearPreviousPartInstance(): void + clearPreviousPartInstances(): void /** * Insert an adlibbed PartInstance into the RundownPlaylist @@ -296,6 +305,17 @@ export interface PlayoutModel extends PlayoutModelReadonly, StudioPlayoutModelBa */ queuePartInstanceTimingEvent(partInstanceId: PartInstanceId): void + /** + * Drop stale entries from `previousPartsInfo` that are no longer contributing to the timeline. + * An entry is dropped when its timeline group end time — derived from the reference part's + * `partPlayoutTimings.fromPartRemaining` and `plannedStartedPlayback` — is already before `now`. + * If timing data is unavailable for a reference part, the entry is kept (safe default). + * At least one entry is always retained so consumers can still read `previousPartInstance[0]`. + * The list is also capped at a maximum length to prevent unbounded growth. + * @param now The current wall-clock time; provided by the caller so this method remains pure. + */ + prunePreviousPartInstances(now: Time): void + /** * Remove all loaded PartInstances marked as `rehearsal` from this RundownPlaylist */ diff --git a/packages/job-worker/src/playout/model/implementation/LoadPlayoutModel.ts b/packages/job-worker/src/playout/model/implementation/LoadPlayoutModel.ts index 2bac8edbedf..e735fecef3d 100644 --- a/packages/job-worker/src/playout/model/implementation/LoadPlayoutModel.ts +++ b/packages/job-worker/src/playout/model/implementation/LoadPlayoutModel.ts @@ -264,7 +264,7 @@ async function loadPartInstances( const selectedPartInstanceIds = _.compact([ playlist.currentPartInfo?.partInstanceId, playlist.nextPartInfo?.partInstanceId, - playlist.previousPartInfo?.partInstanceId, + ...(playlist.previousPartsInfo ?? []).map((info) => info.partInstanceId), ]) const partInstancesCollection = Promise.resolve().then(async () => { diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts index 92c3d74cbbc..cc9cf80e3d2 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts @@ -164,11 +164,19 @@ export class PlayoutModelReadonlyImpl implements PlayoutModelReadonly { return allPartInstances.filter((partInstance) => !ignoreIds.has(partInstance.partInstance._id)) } public get previousPartInstance(): PlayoutPartInstanceModel | null { - if (!this.playlist.previousPartInfo?.partInstanceId) return null - const partInstance = this.allPartInstances.get(this.playlist.previousPartInfo.partInstanceId) - if (!partInstance) return null // throw new Error('PreviousPartInstance is missing') + // Most-recent-first: index 0 is the part just taken from + const firstInfo = this.playlist.previousPartsInfo?.[0] + if (!firstInfo?.partInstanceId) return null + const partInstance = this.allPartInstances.get(firstInfo.partInstanceId) + if (!partInstance) return null return partInstance } + public get previousPartInstances(): PlayoutPartInstanceModel[] { + return (this.playlist.previousPartsInfo ?? []).flatMap((info) => { + const partInstance = this.allPartInstances.get(info.partInstanceId) + return partInstance ? [partInstance] : [] + }) + } public get currentPartInstance(): PlayoutPartInstanceModel | null { if (!this.playlist.currentPartInfo?.partInstanceId) return null const partInstance = this.allPartInstances.get(this.playlist.currentPartInfo.partInstanceId) @@ -184,14 +192,14 @@ export class PlayoutModelReadonlyImpl implements PlayoutModelReadonly { public get selectedPartInstanceIds(): PartInstanceId[] { return _.compact([ - this.playlist.previousPartInfo?.partInstanceId, + ...(this.playlist.previousPartsInfo ?? []).map((info) => info.partInstanceId), this.playlist.currentPartInfo?.partInstanceId, this.playlist.nextPartInfo?.partInstanceId, ]) } public get selectedPartInstances(): PlayoutPartInstanceModel[] { - return _.compact([this.currentPartInstance, this.previousPartInstance, this.nextPartInstance]) + return _.compact([...this.previousPartInstances, this.currentPartInstance, this.nextPartInstance]) } public get loadedPartInstances(): PlayoutPartInstanceModel[] { @@ -382,7 +390,7 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou clearSelectedPartInstances(): void { this.playlistImpl.currentPartInfo = null this.playlistImpl.nextPartInfo = null - this.playlistImpl.previousPartInfo = null + this.playlistImpl.previousPartsInfo = [] this.playlistImpl.holdState = RundownHoldState.NONE delete this.playlistImpl.lastTakeTime @@ -391,8 +399,8 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou this.#playlistHasChanged = true } - clearPreviousPartInstance(): void { - this.playlistImpl.previousPartInfo = null + clearPreviousPartInstances(): void { + this.playlistImpl.previousPartsInfo = [] // Make sure that a hold isn't running. We can't block it here, so abort it immediately instead this.playlistImpl.holdState = RundownHoldState.NONE @@ -551,11 +559,53 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou return this.context.setRouteSetActive(routeSetId, isActive) } + prunePreviousPartInstances(now: number): void { + const current = this.playlistImpl.previousPartsInfo ?? [] + const before = current.length + + // Each previous[i] is active while: now <= reference.plannedStartedPlayback + reference.fromPartRemaining + // where reference is currentPartInstance for i=0, or previous[i-1] for i>0. + // We keep the contiguous prefix [0..lastActiveIndex]: dropping an intermediate entry would + // shift later entries to wrong indices and break their timeline group references. + let lastActiveIndex = -1 + for (let i = 0; i < current.length; i++) { + const referencePI = + i === 0 + ? this.currentPartInstance?.partInstance + : this.allPartInstances.get(current[i - 1].partInstanceId)?.partInstance + + const referenceStarted = referencePI?.timings?.plannedStartedPlayback + const referenceTimings = referencePI?.partPlayoutTimings + const isActive = + referenceStarted === undefined || + referenceTimings === undefined || + now <= referenceStarted + referenceTimings.fromPartRemaining + + if (isActive) lastActiveIndex = i + } + + const filtered = lastActiveIndex >= 0 ? current.slice(0, lastActiveIndex + 1) : [] + // Always keep at least one entry, and never more than the cap. + // We keep a hard cap to prevent unbounded growth if blueprints were doing something really bad with part/piece timings. + const MAX_PREVIOUS_PARTS = 10 + const pruned = filtered.length > 0 ? filtered.slice(0, MAX_PREVIOUS_PARTS) : current.slice(0, 1) + this.playlistImpl.previousPartsInfo = pruned + if (this.playlistImpl.previousPartsInfo.length !== before) { + this.#playlistHasChanged = true + } + } + cycleSelectedPartInstances(): void { - this.playlistImpl.previousPartInfo = this.playlistImpl.currentPartInfo + const currentInfo = this.playlistImpl.currentPartInfo + if (currentInfo) { + this.playlistImpl.previousPartsInfo = [currentInfo, ...(this.playlistImpl.previousPartsInfo ?? [])] + } this.playlistImpl.currentPartInfo = this.playlistImpl.nextPartInfo this.playlistImpl.nextPartInfo = null - this.playlistImpl.lastTakeTime = getCurrentTime() + const now = getCurrentTime() + this.playlistImpl.lastTakeTime = now + + this.prunePreviousPartInstances(now) this.#playlistHasChanged = true } @@ -663,7 +713,7 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou * Reset the playlist for playout */ resetPlaylist(regenerateActivationId: boolean): void { - this.playlistImpl.previousPartInfo = null + this.playlistImpl.previousPartsInfo = [] this.playlistImpl.currentPartInfo = null this.playlistImpl.nextPartInfo = null this.playlistImpl.holdState = RundownHoldState.NONE @@ -853,8 +903,8 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou setSegmentStartedPlayback(segmentPlayoutId: SegmentPlayoutId, timestamp: number): void { const segmentPlayoutIdsToKeep: string[] = [] - if (this.previousPartInstance) { - segmentPlayoutIdsToKeep.push(unprotectString(this.previousPartInstance.partInstance.segmentPlayoutId)) + for (const prev of this.previousPartInstances) { + segmentPlayoutIdsToKeep.push(unprotectString(prev.partInstance.segmentPlayoutId)) } if (this.currentPartInstance) { segmentPlayoutIdsToKeep.push(unprotectString(this.currentPartInstance.partInstance.segmentPlayoutId)) diff --git a/packages/job-worker/src/playout/model/implementation/__tests__/PlayoutModelImpl.spec.ts b/packages/job-worker/src/playout/model/implementation/__tests__/PlayoutModelImpl.spec.ts index 32e5f6dbf79..fb897ee9938 100644 --- a/packages/job-worker/src/playout/model/implementation/__tests__/PlayoutModelImpl.spec.ts +++ b/packages/job-worker/src/playout/model/implementation/__tests__/PlayoutModelImpl.spec.ts @@ -4,6 +4,7 @@ import { PieceLifespan, StatusCode, } from '@sofie-automation/blueprints-integration' +import { mock } from 'jest-mock-extended' import { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' import { PartInstanceId, @@ -12,6 +13,7 @@ import { RundownPlaylistId, } from '@sofie-automation/corelib/dist/dataModel/Ids' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { PeripheralDevice, PeripheralDeviceCategory, @@ -19,10 +21,14 @@ import { } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' import { EmptyPieceTimelineObjectsBlob, Piece } from '@sofie-automation/corelib/dist/dataModel/Piece' import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' -import { QuickLoopMarkerType } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist/RundownPlaylist' +import { + QuickLoopMarkerType, + SelectedPartInstance, +} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist/RundownPlaylist' import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { RundownBaselineAdLibItem } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineAdLibPiece' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { PartCalculatedTimings } from '@sofie-automation/corelib/dist/playout/timings' import { protectString, unprotectString } from '@sofie-automation/corelib/dist/protectedString' import { ReadonlyDeep } from 'type-fest' import { MockJobContext, setupDefaultJobEnvironment } from '../../../../__mocks__/context.js' @@ -36,7 +42,8 @@ import { } from '../../../../__mocks__/defaultCollectionObjects.js' import { setupMockShowStyleCompound } from '../../../../__mocks__/presetCollections.js' import { ProcessedShowStyleCompound } from '../../../../jobs/index.js' -import { runWithPlaylistLock } from '../../../../playout/lock.js' +import { PlaylistLock } from '../../../../jobs/lock.js' +import { runWithPlaylistLock } from '../../../lock.js' import { PlayoutModelImpl } from '../PlayoutModelImpl.js' import { PlayoutRundownModelImpl } from '../PlayoutRundownModelImpl.js' import { PlayoutSegmentModelImpl } from '../PlayoutSegmentModelImpl.js' @@ -47,6 +54,7 @@ const TIME_CONNECTED = 2000 const TIME_PING = 3000 describe('PlayoutModelImpl', () => { + // --- Shared setup for nowInPlayout tests --- let context: MockJobContext let showStyleCompound: ReadonlyDeep @@ -55,6 +63,87 @@ describe('PlayoutModelImpl', () => { showStyleCompound = await setupMockShowStyleCompound(context) }) + // --- Helpers for cycleSelectedPartInstances / prunePreviousPartInstances tests --- + const playlistId = protectString('playlist0') + const studioId = protectString('studio0') + + const DEFAULT_PART_TIMINGS: PartCalculatedTimings = { + inTransitionStart: null, + toPartDelay: 0, + toPartPostroll: 0, + fromPartRemaining: 500, + fromPartPostroll: 0, + fromPartKeepalive: 0, + } + + function makePartInstance( + id: string, + opts?: { + plannedStartedPlayback?: number + partPlayoutTimings?: PartCalculatedTimings + } + ): DBPartInstance { + const { plannedStartedPlayback, partPlayoutTimings } = opts ?? {} + return { + _id: protectString(id), + rundownId: protectString('rd0'), + segmentId: protectString('seg0'), + playlistActivationId: protectString('act0'), + segmentPlayoutId: protectString('segpayout0'), + rehearsal: false, + takeCount: 0, + part: { + _id: protectString(id + '_part'), + _rank: 0, + rundownId: protectString('rd0'), + segmentId: protectString('seg0'), + externalId: id, + title: id, + expectedDurationWithTransition: undefined, + }, + ...(plannedStartedPlayback !== undefined || partPlayoutTimings !== undefined + ? { timings: { setAsNext: 0, plannedStartedPlayback }, partPlayoutTimings } + : {}), + } + } + + function makeSelectedPartInfo(partInstanceId: string): SelectedPartInstance { + return { + partInstanceId: protectString(partInstanceId), + rundownId: protectString('rd0'), + manuallySelected: false, + consumesQueuedSegmentId: false, + } + } + + function createModel( + partInstances: DBPartInstance[], + playlistOverrides?: { + currentPartInfo?: SelectedPartInstance | null + nextPartInfo?: SelectedPartInstance | null + previousPartsInfo?: SelectedPartInstance[] + } + ): PlayoutModelImpl { + const modelContext = setupDefaultJobEnvironment() + const playlist = { + ...defaultRundownPlaylist(playlistId, studioId), + ...(playlistOverrides ?? {}), + } + return new PlayoutModelImpl( + modelContext, + mock(), + playlistId, + [], + playlist, + partInstances, + new Map(), + [], + undefined + ) + } + + // --- nowInPlayout --- + describe('nowInPlayout', () => { beforeEach(async () => { jest.useFakeTimers() @@ -254,6 +343,358 @@ describe('PlayoutModelImpl', () => { }) }) }) + + // --- cycleSelectedPartInstances --- + + describe('cycleSelectedPartInstances', () => { + it('moves currentPartInfo to front of previousPartsInfo and advances current/next', () => { + const pi0Info = makeSelectedPartInfo('pi0') + const pi1Info = makeSelectedPartInfo('pi1') + const model = createModel([], { + currentPartInfo: pi0Info, + nextPartInfo: pi1Info, + previousPartsInfo: [], + }) + + model.cycleSelectedPartInstances() + + expect(model.playlist.previousPartsInfo).toEqual([pi0Info]) + expect(model.playlist.currentPartInfo).toEqual(pi1Info) + expect(model.playlist.nextPartInfo).toBeNull() + }) + + it('accumulates multiple previous parts in most-recent-first order', () => { + // Start with one entry already in previousPartsInfo and cycle in another + const pi0Info = makeSelectedPartInfo('pi0') + const pi1Info = makeSelectedPartInfo('pi1') + const pi2Info = makeSelectedPartInfo('pi2') + + // pi0 is already previous, pi1 is current, pi2 is next + const model = createModel([], { + currentPartInfo: pi1Info, + nextPartInfo: pi2Info, + previousPartsInfo: [pi0Info], + }) + + model.cycleSelectedPartInstances() + + // pi1 is now most-recent previous, pi0 is older + expect(model.playlist.previousPartsInfo).toEqual([pi1Info, pi0Info]) + expect(model.playlist.currentPartInfo).toEqual(pi2Info) + expect(model.playlist.nextPartInfo).toBeNull() + }) + + it('does nothing to previousPartsInfo when there is no currentPartInfo', () => { + const pi1Info = makeSelectedPartInfo('pi1') + const model = createModel([], { + currentPartInfo: null, + nextPartInfo: pi1Info, + previousPartsInfo: [], + }) + + model.cycleSelectedPartInstances() + + expect(model.playlist.previousPartsInfo).toEqual([]) + }) + + it('caps previousPartsInfo at 10 entries', () => { + // Seed with 9 existing previous entries; cycle in a 10th via current + const existing = Array.from({ length: 9 }, (_, i) => makeSelectedPartInfo(`pi_old_${i}`)) + const current = makeSelectedPartInfo('pi_current') + const next = makeSelectedPartInfo('pi_next') + + const model = createModel([], { + currentPartInfo: current, + nextPartInfo: next, + previousPartsInfo: existing, + }) + + model.cycleSelectedPartInstances() + + expect(model.playlist.previousPartsInfo).toHaveLength(10) + // Most recent is first + expect(model.playlist.previousPartsInfo[0]).toEqual(current) + + // Now cycle one more in — should still be capped at 10, oldest dropped + ;(model as any).playlistImpl.currentPartInfo = next + ;(model as any).playlistImpl.nextPartInfo = makeSelectedPartInfo('pi_newer') + model.cycleSelectedPartInstances() + + expect(model.playlist.previousPartsInfo).toHaveLength(10) + expect(model.playlist.previousPartsInfo[0]).toEqual(next) + }) + }) + + // --- prunePreviousPartInstances --- + + describe('prunePreviousPartInstances', () => { + // Pruning logic overview: + // + // previous[i] is dropped once its own timeline group has stopped being needed. + // That window is defined by the *reference* part — the part that started after previous[i] did: + // - reference for previous[0] = current part + // - reference for previous[i>0] = previous[i-1] + // + // previous[i]'s group lingers for `fromPartRemaining` ms after the reference started. + // Drop condition: now > reference.plannedStartedPlayback + reference.partPlayoutTimings.fromPartRemaining + // + // If the reference has no timing data, the entry is kept (safe default). + // The list is also capped at MAX_PREVIOUS_PARTS regardless. + // At least one entry is always retained. + + const NOW = 100_000 + const OVERLAP = 500 // fromPartRemaining used in tests + + it('keeps previous[0] while its lingering window (reference: current) has not yet elapsed', () => { + // prev0's group lingers until current.plannedStartedPlayback + fromPartRemaining = NOW-100+500 = NOW+400 (future) → kept + // prev1's reference is prev0 which has no timing data → safe default keeps it too + const current = makePartInstance('current', { + plannedStartedPlayback: NOW - 100, + partPlayoutTimings: { ...DEFAULT_PART_TIMINGS, fromPartRemaining: OVERLAP }, + }) + const prev0 = makePartInstance('prev0') + const prev1 = makePartInstance('prev1') + const model = createModel([current, prev0, prev1], { + currentPartInfo: makeSelectedPartInfo('current'), + previousPartsInfo: [makeSelectedPartInfo('prev0'), makeSelectedPartInfo('prev1')], + }) + + model.prunePreviousPartInstances(NOW) + + expect(model.playlist.previousPartsInfo.map((p) => p.partInstanceId)).toEqual([ + protectString('prev0'), + protectString('prev1'), + ]) + }) + + it('retains previous[0] as the mandatory minimum even when its lingering window has elapsed', () => { + // prev0 lingers until NOW-1000+500 = NOW-500 (past) → would be pruned, + // but the minimum-1 rule keeps it + const current = makePartInstance('current', { + plannedStartedPlayback: NOW - 1000, + partPlayoutTimings: { ...DEFAULT_PART_TIMINGS, fromPartRemaining: OVERLAP }, + }) + const prev0 = makePartInstance('prev0') + const model = createModel([current, prev0], { + currentPartInfo: makeSelectedPartInfo('current'), + previousPartsInfo: [makeSelectedPartInfo('prev0')], + }) + + model.prunePreviousPartInstances(NOW) + + expect(model.playlist.previousPartsInfo).toHaveLength(1) + expect(model.playlist.previousPartsInfo[0].partInstanceId).toEqual(protectString('prev0')) + }) + + it('drops previous[1] once its lingering window (reference: previous[0]) has elapsed', () => { + // prev0 lingers until NOW-100+500 = NOW+400 (future) → previous[0] kept + // prev1 lingers until NOW-2000+500 = NOW-1500 (past) → previous[1] dropped + const current = makePartInstance('current', { + plannedStartedPlayback: NOW - 100, + partPlayoutTimings: { ...DEFAULT_PART_TIMINGS, fromPartRemaining: OVERLAP }, + }) + const prev0 = makePartInstance('prev0', { + plannedStartedPlayback: NOW - 2000, + partPlayoutTimings: { ...DEFAULT_PART_TIMINGS, fromPartRemaining: OVERLAP }, + }) + const prev1 = makePartInstance('prev1') + const model = createModel([current, prev0, prev1], { + currentPartInfo: makeSelectedPartInfo('current'), + previousPartsInfo: [makeSelectedPartInfo('prev0'), makeSelectedPartInfo('prev1')], + }) + + model.prunePreviousPartInstances(NOW) + + expect(model.playlist.previousPartsInfo).toHaveLength(1) + expect(model.playlist.previousPartsInfo[0].partInstanceId).toEqual(protectString('prev0')) + }) + + it('always retains previous[0] even when all lingering windows have elapsed', () => { + // prev0 lingers until NOW-1000+500 = NOW-500 (past) → would be pruned + // prev1 lingers until NOW-2000+500 = NOW-1500 (past) → would be pruned + // minimum-1 rule saves previous[0] + const current = makePartInstance('current', { + plannedStartedPlayback: NOW - 1000, + partPlayoutTimings: { ...DEFAULT_PART_TIMINGS, fromPartRemaining: OVERLAP }, + }) + const prev0 = makePartInstance('prev0', { + plannedStartedPlayback: NOW - 2000, + partPlayoutTimings: { ...DEFAULT_PART_TIMINGS, fromPartRemaining: OVERLAP }, + }) + const prev1 = makePartInstance('prev1') + const model = createModel([current, prev0, prev1], { + currentPartInfo: makeSelectedPartInfo('current'), + previousPartsInfo: [makeSelectedPartInfo('prev0'), makeSelectedPartInfo('prev1')], + }) + + model.prunePreviousPartInstances(NOW) + + expect(model.playlist.previousPartsInfo).toHaveLength(1) + expect(model.playlist.previousPartsInfo[0].partInstanceId).toEqual(protectString('prev0')) + }) + + it('keeps all entries when the reference part has no timing data (safe default)', () => { + // current has no partPlayoutTimings → group end unknown → keep previous[0] + const current = makePartInstance('current') + const prev0 = makePartInstance('prev0') + const model = createModel([current, prev0], { + currentPartInfo: makeSelectedPartInfo('current'), + previousPartsInfo: [makeSelectedPartInfo('prev0')], + }) + + model.prunePreviousPartInstances(NOW) + + expect(model.playlist.previousPartsInfo).toHaveLength(1) + }) + + it('keeps all entries when there is no current part (safe default)', () => { + // Without a current part, previous[0] has no reference → keep it + const prev0 = makePartInstance('prev0') + const model = createModel([prev0], { + previousPartsInfo: [makeSelectedPartInfo('prev0')], + }) + + model.prunePreviousPartInstances(NOW) + + expect(model.playlist.previousPartsInfo).toHaveLength(1) + }) + + it('does not mutate previousPartsInfo when nothing is pruned', () => { + const current = makePartInstance('current', { + plannedStartedPlayback: NOW - 100, + partPlayoutTimings: { ...DEFAULT_PART_TIMINGS, fromPartRemaining: OVERLAP }, + }) + const prev0 = makePartInstance('prev0') + const prev1 = makePartInstance('prev1') + const model = createModel([current, prev0, prev1], { + currentPartInfo: makeSelectedPartInfo('current'), + previousPartsInfo: [makeSelectedPartInfo('prev0'), makeSelectedPartInfo('prev1')], + }) + + const before = model.playlist.previousPartsInfo + + model.prunePreviousPartInstances(NOW) + + expect(model.playlist.previousPartsInfo).toEqual(before) + expect(model.playlist.previousPartsInfo).toHaveLength(2) + }) + + it('handles an empty previousPartsInfo without crashing', () => { + const model = createModel([], { previousPartsInfo: [] }) + + expect(() => model.prunePreviousPartInstances(NOW)).not.toThrow() + expect(model.playlist.previousPartsInfo).toHaveLength(0) + }) + + it('caps the list at MAX_PREVIOUS_PARTS even when no group ends are in the past', () => { + // 11 previous entries, all with no timing data (safe default keeps them all), + // but the cap should trim to 10 + const current = makePartInstance('current') + const prevInstances = Array.from({ length: 11 }, (_, i) => makePartInstance(`prev${i}`)) + const model = createModel([current, ...prevInstances], { + currentPartInfo: makeSelectedPartInfo('current'), + previousPartsInfo: prevInstances.map((p) => makeSelectedPartInfo(unprotectString(p._id))), + }) + + model.prunePreviousPartInstances(NOW) + + expect(model.playlist.previousPartsInfo).toHaveLength(10) + expect(model.playlist.previousPartsInfo[0].partInstanceId).toEqual(protectString('prev0')) + }) + + it('keeps previous[0] when its own window has elapsed but previous[1] is still active', () => { + // prev0's window (via current): NOW-10+100 = NOW+90 → expires at NOW+200 + // prev1's window (via prev0): NOW-5000+6000 = NOW+1000 → still active at NOW+200 + // prev0 must be kept to preserve the reference chain for prev1 + const tNow = NOW + 200 + + const current = makePartInstance('current', { + plannedStartedPlayback: NOW - 10, + partPlayoutTimings: { ...DEFAULT_PART_TIMINGS, fromPartRemaining: 100 }, + }) + const prev0 = makePartInstance('prev0', { + plannedStartedPlayback: NOW - 5000, + partPlayoutTimings: { ...DEFAULT_PART_TIMINGS, fromPartRemaining: 6000 }, + }) + const prev1 = makePartInstance('prev1') + + const model = createModel([current, prev0, prev1], { + currentPartInfo: makeSelectedPartInfo('current'), + previousPartsInfo: [makeSelectedPartInfo('prev0'), makeSelectedPartInfo('prev1')], + }) + + model.prunePreviousPartInstances(tNow) + + expect(model.playlist.previousPartsInfo.map((p) => p.partInstanceId)).toEqual([ + protectString('prev0'), + protectString('prev1'), + ]) + }) + + it('keeps the full chain when prev[0] and prev[1] have both elapsed but prev[2] is still active', () => { + // prev0's window (via current): NOW-10+50 = NOW+40 < NOW+200 → stale + // prev1's window (via prev0): NOW-500+100 = NOW-400 < NOW+200 → stale + // prev2's window (via prev1): NOW-2000+5000=NOW+3000 > NOW+200 → active + // All three must be retained to preserve the chain + const tNow = NOW + 200 + + const current = makePartInstance('current', { + plannedStartedPlayback: NOW - 10, + partPlayoutTimings: { ...DEFAULT_PART_TIMINGS, fromPartRemaining: 50 }, + }) + const prev0 = makePartInstance('prev0', { + plannedStartedPlayback: NOW - 500, + partPlayoutTimings: { ...DEFAULT_PART_TIMINGS, fromPartRemaining: 100 }, + }) + const prev1 = makePartInstance('prev1', { + plannedStartedPlayback: NOW - 2000, + partPlayoutTimings: { ...DEFAULT_PART_TIMINGS, fromPartRemaining: 5000 }, + }) + const prev2 = makePartInstance('prev2') + + const model = createModel([current, prev0, prev1, prev2], { + currentPartInfo: makeSelectedPartInfo('current'), + previousPartsInfo: [ + makeSelectedPartInfo('prev0'), + makeSelectedPartInfo('prev1'), + makeSelectedPartInfo('prev2'), + ], + }) + + model.prunePreviousPartInstances(tNow) + + expect(model.playlist.previousPartsInfo.map((p) => p.partInstanceId)).toEqual([ + protectString('prev0'), + protectString('prev1'), + protectString('prev2'), + ]) + }) + + it('prunes stale tail entries when every window in the chain has elapsed', () => { + // prev0's window: NOW-2000+500 = NOW-1500 → stale + // prev1's window: NOW-5000+500 = NOW-4500 → stale + // No active entry anywhere; minimum-1 rule keeps prev0 + const current = makePartInstance('current', { + plannedStartedPlayback: NOW - 2000, + partPlayoutTimings: { ...DEFAULT_PART_TIMINGS, fromPartRemaining: OVERLAP }, + }) + const prev0 = makePartInstance('prev0', { + plannedStartedPlayback: NOW - 5000, + partPlayoutTimings: { ...DEFAULT_PART_TIMINGS, fromPartRemaining: OVERLAP }, + }) + const prev1 = makePartInstance('prev1') + + const model = createModel([current, prev0, prev1], { + currentPartInfo: makeSelectedPartInfo('current'), + previousPartsInfo: [makeSelectedPartInfo('prev0'), makeSelectedPartInfo('prev1')], + }) + + model.prunePreviousPartInstances(NOW) + + expect(model.playlist.previousPartsInfo).toHaveLength(1) + expect(model.playlist.previousPartsInfo[0].partInstanceId).toEqual(protectString('prev0')) + }) + }) }) async function getPlayoutModelImplArugments( diff --git a/packages/job-worker/src/playout/resolvedPieces.ts b/packages/job-worker/src/playout/resolvedPieces.ts index 4bbc96a8c67..de13a591aa9 100644 --- a/packages/job-worker/src/playout/resolvedPieces.ts +++ b/packages/job-worker/src/playout/resolvedPieces.ts @@ -75,23 +75,27 @@ export function getResolvedPiecesForPartInstancesOnTimeline( // Translate start to absolute times offsetResolvedStartAndCapDuration(currentResolvedPieces, currentPartStarted, nextPartStarted) - // Calculate the previous part - let previousResolvedPieces: ResolvedPieceInstance[] = [] - if (partInstancesInfo.previous?.partTimes.partStartTime) { - const partTimes = partInstancesInfo.previous.partTimes - previousResolvedPieces = partInstancesInfo.previous.pieceInstances.map((instance) => - resolvePrunedPieceInstance(partTimes, instance) - ) - - // Translate start to absolute times - offsetResolvedStartAndCapDuration( - previousResolvedPieces, - partInstancesInfo.previous.partTimes.partStartTime, - currentPartStarted + // Calculate all previous parts still contributing to the timeline (keepalive/postroll). + // Each entry is capped at the start of the part that followed it (most-recent previous is + // capped at currentPartStarted; older entries are capped at the part after them). + let allPreviousResolvedPieces: ResolvedPieceInstance[] = [] + for (let i = 0; i < partInstancesInfo.previous.length; i++) { + const prevInfo = partInstancesInfo.previous[i] + if (!prevInfo.partTimes.partStartTime) continue + + const capEnd = + i === 0 + ? currentPartStarted + : (partInstancesInfo.previous[i - 1].partTimes.partStartTime ?? currentPartStarted) + + const resolved = prevInfo.pieceInstances.map((instance) => + resolvePrunedPieceInstance(prevInfo.partTimes, instance) ) + offsetResolvedStartAndCapDuration(resolved, prevInfo.partTimes.partStartTime, capEnd) + allPreviousResolvedPieces = allPreviousResolvedPieces.concat(resolved) } - return mergeInfinitesIntoCurrentPart(previousResolvedPieces, currentResolvedPieces, nextResolvedPieces) + return mergeInfinitesIntoCurrentPart(allPreviousResolvedPieces, currentResolvedPieces, nextResolvedPieces) } function offsetResolvedStartAndCapDuration( diff --git a/packages/job-worker/src/playout/setNext.ts b/packages/job-worker/src/playout/setNext.ts index 39e39fec630..82221e4dc0c 100644 --- a/packages/job-worker/src/playout/setNext.ts +++ b/packages/job-worker/src/playout/setNext.ts @@ -173,7 +173,7 @@ async function setNextPartAndCheckForPendingMoveNextPart( const selectedPartInstanceIds = _.compact([ newPartInstance.partInstance._id, playoutModel.playlist.currentPartInfo?.partInstanceId, - playoutModel.playlist.previousPartInfo?.partInstanceId, + ...(playoutModel.playlist.previousPartsInfo ?? []).map((p) => p.partInstanceId), ]) // reset any previous instances of this part @@ -402,11 +402,12 @@ async function cleanupOrphanedItems(context: JobContext, playoutModel: PlayoutMo const selectedPartInstancesSegmentIds = new Set() - const previousPartInstance = playoutModel.previousPartInstance?.partInstance + for (const prev of playoutModel.previousPartInstances) { + selectedPartInstancesSegmentIds.add(prev.partInstance.segmentId) + } const currentPartInstance = playoutModel.currentPartInstance?.partInstance const nextPartInstance = playoutModel.nextPartInstance?.partInstance - if (previousPartInstance) selectedPartInstancesSegmentIds.add(previousPartInstance.segmentId) if (currentPartInstance) selectedPartInstancesSegmentIds.add(currentPartInstance.segmentId) if (nextPartInstance) selectedPartInstancesSegmentIds.add(nextPartInstance.segmentId) @@ -467,16 +468,20 @@ async function cleanupOrphanedItems(context: JobContext, playoutModel: PlayoutMo const orphanedInstances = playoutModel.loadedPartInstances.filter( (p) => p.partInstance.orphaned === 'deleted' && !p.partInstance.reset ) + const protectedPartInstanceIds = new Set( + _.compact([ + playlist.currentPartInfo?.partInstanceId, + playlist.nextPartInfo?.partInstanceId, + ...(playlist.previousPartsInfo ?? []).map((p) => p.partInstanceId), + ]) + ) for (const partInstance of orphanedInstances) { if (PRESERVE_UNSYNCED_PLAYING_SEGMENT_CONTENTS && orphanedSegmentIds.has(partInstance.partInstance.segmentId)) { // If the segment is also orphaned, then don't delete it until it is clear continue } - if ( - partInstance.partInstance._id !== playlist.currentPartInfo?.partInstanceId && - partInstance.partInstance._id !== playlist.nextPartInfo?.partInstanceId - ) { + if (!protectedPartInstanceIds.has(partInstance.partInstance._id)) { removePartInstanceIds.push(partInstance.partInstance._id) } } diff --git a/packages/job-worker/src/playout/snapshot.ts b/packages/job-worker/src/playout/snapshot.ts index 8c8a71dee81..eddfea4a5ba 100644 --- a/packages/job-worker/src/playout/snapshot.ts +++ b/packages/job-worker/src/playout/snapshot.ts @@ -33,7 +33,6 @@ import { getPartId, getSegmentId } from '../ingest/lib.js' import { assertNever, getHash, getRandomId, literal, omit } from '@sofie-automation/corelib/dist/lib' import { logger } from '../logging.js' import { JSONBlobParse, JSONBlobStringify } from '@sofie-automation/shared-lib/dist/lib/JSONBlob' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist/RundownPlaylist' import { RundownOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { SofieIngestDataCacheObj } from '@sofie-automation/corelib/dist/dataModel/SofieIngestDataCache' import * as PackagesPreR53 from '@sofie-automation/corelib/dist/dataModel/Old/ExpectedPackagesR52' @@ -720,23 +719,42 @@ function fixupImportedSelectedPartInstanceIds( const fullOldKey = `${property}PartInstanceId` if (fullOldKey in snapshot.playlist) { const oldId = (snapshot.playlist as any)[fullOldKey] as PartInstanceId - snapshot.playlist.currentPartInfo = { + const migratedInfo = { partInstanceId: oldId, rundownId: partInstanceOldRundownIdMap.get(oldId) || protectString(''), manuallySelected: false, consumesQueuedSegmentId: false, } + if (property === 'previous') { + snapshot.playlist.previousPartsInfo = [migratedInfo] + } else if (property === 'next') { + snapshot.playlist.nextPartInfo = migratedInfo + } else { + snapshot.playlist.currentPartInfo = migratedInfo + } } - const fullNewKey: keyof DBRundownPlaylist = `${property}PartInfo` - - const snapshotInfo = snapshot.playlist[fullNewKey] - if (snapshotInfo) { - snapshot.playlist[fullNewKey] = { - partInstanceId: partInstanceIdMap.get(snapshotInfo.partInstanceId) || snapshotInfo.partInstanceId, - rundownId: rundownIdMap.get(snapshotInfo.rundownId) || snapshotInfo.rundownId, - manuallySelected: snapshotInfo.manuallySelected, - consumesQueuedSegmentId: snapshotInfo.consumesQueuedSegmentId, + if (property === 'previous') { + // previousPartsInfo is an array — remap each entry + const snapshotInfos = snapshot.playlist.previousPartsInfo + if (snapshotInfos?.length) { + snapshot.playlist.previousPartsInfo = snapshotInfos.map((snapshotInfo) => ({ + partInstanceId: partInstanceIdMap.get(snapshotInfo.partInstanceId) || snapshotInfo.partInstanceId, + rundownId: rundownIdMap.get(snapshotInfo.rundownId) || snapshotInfo.rundownId, + manuallySelected: snapshotInfo.manuallySelected, + consumesQueuedSegmentId: snapshotInfo.consumesQueuedSegmentId, + })) + } + } else { + const fullNewKey = `${property}PartInfo` as const + const snapshotInfo = snapshot.playlist[fullNewKey] + if (snapshotInfo) { + snapshot.playlist[fullNewKey] = { + partInstanceId: partInstanceIdMap.get(snapshotInfo.partInstanceId) || snapshotInfo.partInstanceId, + rundownId: rundownIdMap.get(snapshotInfo.rundownId) || snapshotInfo.rundownId, + manuallySelected: snapshotInfo.manuallySelected, + consumesQueuedSegmentId: snapshotInfo.consumesQueuedSegmentId, + } } } } diff --git a/packages/job-worker/src/playout/timeline/__tests__/rundown.test.ts b/packages/job-worker/src/playout/timeline/__tests__/rundown.test.ts index d8ceb5dd71b..8670bc84cd9 100644 --- a/packages/job-worker/src/playout/timeline/__tests__/rundown.test.ts +++ b/packages/job-worker/src/playout/timeline/__tests__/rundown.test.ts @@ -94,7 +94,9 @@ describe('buildTimelineObjsForRundown', () => { _id: protectString('mockPlaylist'), nextPartInfo: convertSelectedPartInstance(selectedPartInfos.next), currentPartInfo: convertSelectedPartInstance(selectedPartInfos.current), - previousPartInfo: convertSelectedPartInstance(selectedPartInfos.previous), + previousPartsInfo: selectedPartInfos.previous + .map((info) => convertSelectedPartInstance(info)) + .filter((info): info is SelectedPartInstance => info !== null), activationId: protectString('mockActivationId'), rehearsal: false, } as Partial as any @@ -173,7 +175,7 @@ describe('buildTimelineObjsForRundown', () => { it('playlist with no parts', () => { const context = setupDefaultJobEnvironment() - const selectedPartInfos: SelectedPartInstancesTimelineInfo = {} + const selectedPartInfos: SelectedPartInstancesTimelineInfo = { previous: [] } const playlist = createMockPlaylist(selectedPartInfos) const objs = buildTimelineObjsForRundown(context, playlist, selectedPartInfos, true) @@ -203,13 +205,15 @@ describe('buildTimelineObjsForRundown', () => { const context = setupDefaultJobEnvironment() const selectedPartInfos: SelectedPartInstancesTimelineInfo = { - previous: { - partTimes: createPartCurrentTimes(currentTime, 5678), - partInstance: createMockPartInstance('part0'), - pieceInstances: [], - calculatedTimings: DEFAULT_PART_TIMINGS, - regenerateTimelineAt: undefined, - }, + previous: [ + { + partTimes: createPartCurrentTimes(currentTime, 5678), + partInstance: createMockPartInstance('part0'), + pieceInstances: [], + calculatedTimings: DEFAULT_PART_TIMINGS, + regenerateTimelineAt: undefined, + }, + ], } const playlist = createMockPlaylist(selectedPartInfos) @@ -223,6 +227,7 @@ describe('buildTimelineObjsForRundown', () => { const context = setupDefaultJobEnvironment() const selectedPartInfos: SelectedPartInstancesTimelineInfo = { + previous: [], current: { partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance('part0'), @@ -248,6 +253,7 @@ describe('buildTimelineObjsForRundown', () => { const context = setupDefaultJobEnvironment() const selectedPartInfos: SelectedPartInstancesTimelineInfo = { + previous: [], current: { partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance( @@ -281,6 +287,7 @@ describe('buildTimelineObjsForRundown', () => { const context = setupDefaultJobEnvironment() const selectedPartInfos: SelectedPartInstancesTimelineInfo = { + previous: [], current: { partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance('part0'), @@ -314,6 +321,7 @@ describe('buildTimelineObjsForRundown', () => { const context = setupDefaultJobEnvironment() const selectedPartInfos: SelectedPartInstancesTimelineInfo = { + previous: [], current: { partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance('part0', { autoNext: true, expectedDuration: 5000 }), @@ -347,21 +355,23 @@ describe('buildTimelineObjsForRundown', () => { const context = setupDefaultJobEnvironment() const selectedPartInfos: SelectedPartInstancesTimelineInfo = { - previous: { - partTimes: createPartCurrentTimes(currentTime, 1234), - partInstance: createMockPartInstance( - 'part9', - { autoNext: true, expectedDuration: 5000 }, - { - timings: { - plannedStartedPlayback: 1235, - }, - } - ), - pieceInstances: [createMockPieceInstance('piece9')], - calculatedTimings: DEFAULT_PART_TIMINGS, - regenerateTimelineAt: undefined, - }, + previous: [ + { + partTimes: createPartCurrentTimes(currentTime, 1234), + partInstance: createMockPartInstance( + 'part9', + { autoNext: true, expectedDuration: 5000 }, + { + timings: { + plannedStartedPlayback: 1235, + }, + } + ), + pieceInstances: [createMockPieceInstance('piece9')], + calculatedTimings: DEFAULT_PART_TIMINGS, + regenerateTimelineAt: undefined, + }, + ], current: { partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance('part0'), @@ -379,7 +389,7 @@ describe('buildTimelineObjsForRundown', () => { expect(transformTimelineIntoSimplifiedForm(objs)).toMatchSnapshot() // make sure the previous part was generated - const previousPartGroupId = getPartGroupId(selectedPartInfos.previous!.partInstance) + const previousPartGroupId = getPartGroupId(selectedPartInfos.previous[0].partInstance) expect(objs.timeline.find((obj) => obj.id === previousPartGroupId)).toBeTruthy() expect(objs.timingContext?.previousPartOverlap).not.toBeUndefined() }) @@ -389,21 +399,23 @@ describe('buildTimelineObjsForRundown', () => { const context = setupDefaultJobEnvironment() const selectedPartInfos: SelectedPartInstancesTimelineInfo = { - previous: { - partTimes: createPartCurrentTimes(currentTime, 1234), - partInstance: createMockPartInstance( - 'part9', - { autoNext: true, expectedDuration: 5000 }, - { - timings: { - plannedStartedPlayback: 1235, - }, - } - ), - pieceInstances: [createMockPieceInstance('piece9'), createMockPieceInstance('piece8')], - calculatedTimings: DEFAULT_PART_TIMINGS, - regenerateTimelineAt: undefined, - }, + previous: [ + { + partTimes: createPartCurrentTimes(currentTime, 1234), + partInstance: createMockPartInstance( + 'part9', + { autoNext: true, expectedDuration: 5000 }, + { + timings: { + plannedStartedPlayback: 1235, + }, + } + ), + pieceInstances: [createMockPieceInstance('piece9'), createMockPieceInstance('piece8')], + calculatedTimings: DEFAULT_PART_TIMINGS, + regenerateTimelineAt: undefined, + }, + ], current: { partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance('part0'), @@ -428,7 +440,7 @@ describe('buildTimelineObjsForRundown', () => { expect(transformTimelineIntoSimplifiedForm(objs)).toMatchSnapshot() // make sure the previous part was generated - const previousPartGroupId = getPartGroupId(selectedPartInfos.previous!.partInstance) + const previousPartGroupId = getPartGroupId(selectedPartInfos.previous[0].partInstance) expect(objs.timeline.find((obj) => obj.id === previousPartGroupId)).toBeTruthy() expect(objs.timingContext?.previousPartOverlap).not.toBeUndefined() }) @@ -437,26 +449,28 @@ describe('buildTimelineObjsForRundown', () => { const context = setupDefaultJobEnvironment() const selectedPartInfos: SelectedPartInstancesTimelineInfo = { - previous: { - partTimes: createPartCurrentTimes(currentTime, 1234), - partInstance: createMockPartInstance( - 'part9', - { autoNext: true, expectedDuration: 5000 }, - { - timings: { - plannedStartedPlayback: 1235, - }, - } - ), - pieceInstances: [ - createMockPieceInstance('piece9'), - createMockPieceInstance('piece8', { - excludeDuringPartKeepalive: true, - }), - ], - calculatedTimings: DEFAULT_PART_TIMINGS, - regenerateTimelineAt: undefined, - }, + previous: [ + { + partTimes: createPartCurrentTimes(currentTime, 1234), + partInstance: createMockPartInstance( + 'part9', + { autoNext: true, expectedDuration: 5000 }, + { + timings: { + plannedStartedPlayback: 1235, + }, + } + ), + pieceInstances: [ + createMockPieceInstance('piece9'), + createMockPieceInstance('piece8', { + excludeDuringPartKeepalive: true, + }), + ], + calculatedTimings: DEFAULT_PART_TIMINGS, + regenerateTimelineAt: undefined, + }, + ], current: { partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance('part0'), @@ -481,7 +495,7 @@ describe('buildTimelineObjsForRundown', () => { expect(transformTimelineIntoSimplifiedForm(objs)).toMatchSnapshot() // make sure the previous part was generated - const previousPartGroupId = getPartGroupId(selectedPartInfos.previous!.partInstance) + const previousPartGroupId = getPartGroupId(selectedPartInfos.previous[0].partInstance) expect(objs.timeline.find((obj) => obj.id === previousPartGroupId)).toBeTruthy() expect(objs.timingContext?.previousPartOverlap).not.toBeUndefined() }) @@ -490,6 +504,7 @@ describe('buildTimelineObjsForRundown', () => { const context = setupDefaultJobEnvironment() const selectedPartInfos: SelectedPartInstancesTimelineInfo = { + previous: [], current: { partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance('part0', { autoNext: true, expectedDuration: 5000 }), @@ -532,6 +547,7 @@ describe('buildTimelineObjsForRundown', () => { jest.spyOn(global.Date, 'now').mockImplementation(() => 3000) const selectedPartInfos: SelectedPartInstancesTimelineInfo = { + previous: [], current: { partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance( @@ -611,7 +627,7 @@ describe('buildTimelineObjsForRundown', () => { const context = setupDefaultJobEnvironment() const selectedPartInfos: SelectedPartInstancesTimelineInfo = { - previous: PREVIOUS_PART_INSTANCE, + previous: [PREVIOUS_PART_INSTANCE], current: { partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance('part0'), @@ -636,13 +652,15 @@ describe('buildTimelineObjsForRundown', () => { const context = setupDefaultJobEnvironment() const selectedPartInfos: SelectedPartInstancesTimelineInfo = { - previous: { - ...PREVIOUS_PART_INSTANCE, - pieceInstances: [ - ...PREVIOUS_PART_INSTANCE.pieceInstances, - createMockInfinitePieceInstance('piece6', {}, {}, 1), - ], - }, + previous: [ + { + ...PREVIOUS_PART_INSTANCE, + pieceInstances: [ + ...PREVIOUS_PART_INSTANCE.pieceInstances, + createMockInfinitePieceInstance('piece6', {}, {}, 1), + ], + }, + ], current: { partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance('part0'), @@ -664,13 +682,15 @@ describe('buildTimelineObjsForRundown', () => { const context = setupDefaultJobEnvironment() const selectedPartInfos: SelectedPartInstancesTimelineInfo = { - previous: { - ...PREVIOUS_PART_INSTANCE, - pieceInstances: [ - ...PREVIOUS_PART_INSTANCE.pieceInstances, - createMockInfinitePieceInstance('piece6', { excludeDuringPartKeepalive: true }, {}, 1), - ], - }, + previous: [ + { + ...PREVIOUS_PART_INSTANCE, + pieceInstances: [ + ...PREVIOUS_PART_INSTANCE.pieceInstances, + createMockInfinitePieceInstance('piece6', { excludeDuringPartKeepalive: true }, {}, 1), + ], + }, + ], current: { partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance('part0'), @@ -694,10 +714,12 @@ describe('buildTimelineObjsForRundown', () => { const infinitePiece = createMockInfinitePieceInstance('piece6') const selectedPartInfos: SelectedPartInstancesTimelineInfo = { - previous: { - ...PREVIOUS_PART_INSTANCE, - pieceInstances: [...PREVIOUS_PART_INSTANCE.pieceInstances, infinitePiece], - }, + previous: [ + { + ...PREVIOUS_PART_INSTANCE, + pieceInstances: [...PREVIOUS_PART_INSTANCE.pieceInstances, infinitePiece], + }, + ], current: { partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance('part0'), @@ -721,6 +743,7 @@ describe('buildTimelineObjsForRundown', () => { const infinitePiece = createMockInfinitePieceInstance('piece6') const selectedPartInfos: SelectedPartInstancesTimelineInfo = { + previous: [], current: { partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance( @@ -765,6 +788,7 @@ describe('buildTimelineObjsForRundown', () => { const context = setupDefaultJobEnvironment() const selectedPartInfos: SelectedPartInstancesTimelineInfo = { + previous: [], current: { partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance( @@ -812,6 +836,7 @@ describe('buildTimelineObjsForRundown', () => { const context = setupDefaultJobEnvironment() const selectedPartInfos: SelectedPartInstancesTimelineInfo = { + previous: [], current: { partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance( @@ -858,4 +883,118 @@ describe('buildTimelineObjsForRundown', () => { expect(transformTimelineIntoSimplifiedForm(objs)).toMatchSnapshot() }) }) + + describe('multiple previous parts', () => { + function makeActivePrevInfo( + id: string, + plannedStartedPlayback: number, + partStarted: number, + fromPartRemaining: number, + pieces: PieceInstanceWithTimings[] = [] + ): SelectedPartInstanceTimelineInfo { + return { + partTimes: createPartCurrentTimes(currentTime, partStarted), + partInstance: createMockPartInstance(id, {}, { timings: { plannedStartedPlayback } }), + pieceInstances: pieces, + calculatedTimings: { ...DEFAULT_PART_TIMINGS, fromPartRemaining }, + regenerateTimelineAt: undefined, + } + } + + it('generates timeline objects for both previous parts', () => { + const context = setupDefaultJobEnvironment() + + const prev0Info = makeActivePrevInfo('prev0', 7999, 8000, 5000) + const prev1Info = makeActivePrevInfo('prev1', 2999, 3000, 2000) + + const selectedPartInfos: SelectedPartInstancesTimelineInfo = { + previous: [prev0Info, prev1Info], + current: { + partTimes: createPartCurrentTimes(currentTime, 9500), + partInstance: createMockPartInstance('current', {}, { timings: { plannedStartedPlayback: 9499 } }), + pieceInstances: [createMockPieceInstance('piece_current')], + calculatedTimings: { ...DEFAULT_PART_TIMINGS, fromPartRemaining: 5000 }, + regenerateTimelineAt: undefined, + }, + } + + const playlist = createMockPlaylist(selectedPartInfos) + const objs = buildTimelineObjsForRundown(context, playlist, selectedPartInfos, true) + + const prev0GroupId = getPartGroupId(prev0Info.partInstance) + const prev1GroupId = getPartGroupId(prev1Info.partInstance) + + // Both part groups must appear + expect(objs.timeline.find((obj) => obj.id === prev0GroupId)).toBeTruthy() + expect(objs.timeline.find((obj) => obj.id === prev1GroupId)).toBeTruthy() + + // Only the most-recent previous overlap goes into timingContext + expect(objs.timingContext?.previousPartOverlap).toBe(5000) + }) + + it('chains previous[1] group end relative to previous[0] group start', () => { + const context = setupDefaultJobEnvironment() + + // prev0's group ends at currentGroup.start + 3000 (from current.calculatedTimings.fromPartRemaining) + // prev1's group ends at prev0Group.start + 4000 (from prev0.calculatedTimings.fromPartRemaining) + const prev0Info = makeActivePrevInfo('prev0', 7999, 8000, 4000) + const prev1Info = makeActivePrevInfo('prev1', 2999, 3000, 2000) + + const selectedPartInfos: SelectedPartInstancesTimelineInfo = { + previous: [prev0Info, prev1Info], + current: { + partTimes: createPartCurrentTimes(currentTime, 9500), + partInstance: createMockPartInstance('current', {}, { timings: { plannedStartedPlayback: 9499 } }), + pieceInstances: [], + calculatedTimings: { ...DEFAULT_PART_TIMINGS, fromPartRemaining: 3000 }, + regenerateTimelineAt: undefined, + }, + } + + const playlist = createMockPlaylist(selectedPartInfos) + const objs = buildTimelineObjsForRundown(context, playlist, selectedPartInfos, true) + + const prev0GroupId = getPartGroupId(prev0Info.partInstance) + const prev1GroupId = getPartGroupId(prev1Info.partInstance) + const currentGroupId = objs.timingContext!.currentPartGroup.id + + const prev0Group = objs.timeline.find((obj) => obj.id === prev0GroupId) + expect(prev0Group).toBeTruthy() + expect(prev0Group!.enable).toMatchObject({ end: `#${currentGroupId}.start + 3000` }) + + const prev1Group = objs.timeline.find((obj) => obj.id === prev1GroupId) + expect(prev1Group).toBeTruthy() + expect(prev1Group!.enable).toMatchObject({ end: `#${prev0GroupId}.start + 4000` }) + }) + + it('skips a previous part that never had plannedStartedPlayback', () => { + const context = setupDefaultJobEnvironment() + + const prev0NoPlayback: SelectedPartInstanceTimelineInfo = { + partTimes: createPartCurrentTimes(currentTime, 8000), + partInstance: createMockPartInstance('prev0noPB'), + pieceInstances: [createMockPieceInstance('piece_prev0noPB')], + calculatedTimings: { ...DEFAULT_PART_TIMINGS, fromPartRemaining: 5000 }, + regenerateTimelineAt: undefined, + } + + const selectedPartInfos: SelectedPartInstancesTimelineInfo = { + previous: [prev0NoPlayback], + current: { + partTimes: createPartCurrentTimes(currentTime, 9500), + partInstance: createMockPartInstance('current', {}, { timings: { plannedStartedPlayback: 9499 } }), + pieceInstances: [], + calculatedTimings: DEFAULT_PART_TIMINGS, + regenerateTimelineAt: undefined, + }, + } + + const playlist = createMockPlaylist(selectedPartInfos) + const objs = buildTimelineObjsForRundown(context, playlist, selectedPartInfos, true) + + const prev0GroupId = getPartGroupId(prev0NoPlayback.partInstance) + expect(objs.timeline.find((obj) => obj.id === prev0GroupId)).toBeFalsy() + expect(objs.timingContext?.previousPartOverlap).toBeUndefined() + }) + }) }) diff --git a/packages/job-worker/src/playout/timeline/generate.ts b/packages/job-worker/src/playout/timeline/generate.ts index aa95c879c62..2772735989e 100644 --- a/packages/job-worker/src/playout/timeline/generate.ts +++ b/packages/job-worker/src/playout/timeline/generate.ts @@ -221,7 +221,12 @@ function hasNow(obj: TimelineEnableExt | TimelineEnableExt[]) { } export interface SelectedPartInstancesTimelineInfo { - previous?: SelectedPartInstanceTimelineInfo + /** + * All previously-played PartInstances whose timeline contribution may still be active, + * ordered most-recent-first (index 0 = the part taken from immediately before current). + * Most callers only need `previous[0]`; timeline generation iterates the whole array. + */ + previous: SelectedPartInstanceTimelineInfo[] current?: SelectedPartInstanceTimelineInfo next?: SelectedPartInstanceTimelineInfo } @@ -293,7 +298,7 @@ export async function getTimelineRundown( const currentPartInstance = playoutModel.currentPartInstance const nextPartInstance = playoutModel.nextPartInstance - const previousPartInstance = playoutModel.previousPartInstance + const previousPartInstances = playoutModel.previousPartInstances const partForRundown = currentPartInstance || nextPartInstance const activeRundown = partForRundown && playoutModel.getRundown(partForRundown.partInstance.rundownId) @@ -327,12 +332,15 @@ export async function getTimelineRundown( showStyle.sourceLayers, nextPartInstance ), - previous: getPartInstanceTimelineInfo( - absolutePiecePrepareTime, - targetNowTime, - showStyle.sourceLayers, - previousPartInstance - ), + previous: previousPartInstances.flatMap((pi) => { + const info = getPartInstanceTimelineInfo( + absolutePiecePrepareTime, + targetNowTime, + showStyle.sourceLayers, + pi + ) + return info ? [info] : [] + }), } if (partInstancesInfo.next && nextPartInstance) { @@ -386,7 +394,7 @@ export async function getTimelineRundown( context.getShowStyleBlueprintConfig(showStyle), playoutModel.playlist, activeRundown.rundown, - previousPartInstance?.partInstance, + previousPartInstances.map((pi) => pi.partInstance), currentPartInstance?.partInstance, nextPartInstance?.partInstance, resolvedPieces diff --git a/packages/job-worker/src/playout/timeline/rundown.ts b/packages/job-worker/src/playout/timeline/rundown.ts index 5c4bac75627..0427867b38e 100644 --- a/packages/job-worker/src/playout/timeline/rundown.ts +++ b/packages/job-worker/src/playout/timeline/rundown.ts @@ -25,7 +25,7 @@ import { JobContext } from '../../jobs/index.js' import { ReadonlyDeep } from 'type-fest' import { SelectedPartInstancesTimelineInfo, SelectedPartInstanceTimelineInfo } from './generate.js' import { createPartGroup, createPartGroupFirstObject, PartEnable, transformPartIntoTimeline } from './part.js' -import { literal, normalizeArrayToMapFunc } from '@sofie-automation/corelib/dist/lib' +import { literal } from '@sofie-automation/corelib/dist/lib' import { getCurrentTime } from '../../lib/index.js' import _ from 'underscore' import { getPieceEnableInsidePart, transformPieceGroupAndObjects } from './piece.js' @@ -95,10 +95,10 @@ export function buildTimelineObjsForRundown( if (!partInstancesInfo.current) throw new Error(`PartInstance "${activePlaylist.currentPartInfo?.partInstanceId}" not found!`) } - if (activePlaylist.previousPartInfo) { - // We may be at the beginning of a show, where there is no previous part - if (!partInstancesInfo.previous) - logger.warn(`Previous PartInstance "${activePlaylist.previousPartInfo?.partInstanceId}" not found!`) + if (activePlaylist.previousPartsInfo?.length) { + // Warn only if loaded info says 'previous' but the model didn't populate it + if (!partInstancesInfo.previous.length) + logger.warn(`Previous PartInstances "${JSON.stringify(activePlaylist.previousPartsInfo)}" not found!`) } if (!partInstancesInfo.next && !partInstancesInfo.current) { @@ -132,9 +132,13 @@ export function buildTimelineObjsForRundown( } const previousPartInfinites: Map = - partInstancesInfo.previous - ? normalizeArrayToMapFunc(partInstancesInfo.previous.pieceInstances, (inst) => - inst.infinite ? inst.infinite.infiniteInstanceId : undefined + partInstancesInfo.previous.length > 0 + ? new Map( + partInstancesInfo.previous.flatMap((prev) => + prev.pieceInstances.flatMap((inst) => + inst.infinite ? [[inst.infinite.infiniteInstanceId, inst] as const] : [] + ) + ) ) : new Map() @@ -149,9 +153,9 @@ export function buildTimelineObjsForRundown( } // Start generating objects - if (partInstancesInfo.previous) { + if (partInstancesInfo.previous.length > 0) { timelineObjs.push( - ...generatePreviousPartInstanceObjects( + ...generatePreviousPartInstancesObjects( context, activePlaylist, partInstancesInfo.previous, @@ -186,7 +190,7 @@ export function buildTimelineObjsForRundown( activePlaylist._id, partInstancesInfo.current.partInstance, currentPartGroup, - partInstancesInfo.previous?.partInstance + partInstancesInfo.previous[0]?.partInstance ), ...transformPartIntoTimeline( context, @@ -480,34 +484,66 @@ function applyInfinitePieceGroupEndCap( return { pieceInstanceWithUpdatedEndCap, cappedInfiniteGroupEnable } } -function generatePreviousPartInstanceObjects( +/** + * Generate timeline objects for all previous PartInstances whose keepalive/postroll still overlaps with the + * current (or a newer previous) PartInstance. + * + * `previousPartsInfo` is ordered most-recent-first (index 0 = the part taken from immediately before current). + * Groups are chained: previous[0] ends relative to currentPartGroup; previous[1] ends relative to previous[0]'s + * group start; and so on. + */ +function generatePreviousPartInstancesObjects( context: JobContext, activePlaylist: ReadonlyDeep, - previousPartInfo: SelectedPartInstanceTimelineInfo, + previousPartsInfo: SelectedPartInstanceTimelineInfo[], currentInfinitePieceIds: Set, timingContext: RundownTimelineTimingContext, currentPartInstanceTimings: PartCalculatedTimings ): Array { - const partStartedPlayback = previousPartInfo.partInstance.timings?.plannedStartedPlayback - if (partStartedPlayback) { - // The previous part should continue for a while into the following part - const prevPartOverlapDuration = currentPartInstanceTimings.fromPartRemaining - timingContext.previousPartOverlap = prevPartOverlapDuration + const result: Array = [] + + for (let i = 0; i < previousPartsInfo.length; i++) { + const previousPartInfo = previousPartsInfo[i] + const partStartedPlayback = previousPartInfo.partInstance.timings?.plannedStartedPlayback + if (!partStartedPlayback) continue // Part was never actually on air – skip + + /** + * The overlap duration for this part: + * - For previous[0]: how long it continues past the START of the current part group + * (comes from currentPartInstanceTimings.fromPartRemaining, same as the original single-previous logic) + * - For previous[i>0]: how long it continues past the START of previous[i-1]'s group + * (comes from previous[i-1].calculatedTimings.fromPartRemaining, which is the "fromPartRemaining" + * stored on the part that was taken TO previous[i-1] FROM previous[i]) + */ + const prevPartOverlapDuration = + i === 0 + ? currentPartInstanceTimings.fromPartRemaining + : previousPartsInfo[i - 1].calculatedTimings.fromPartRemaining + + // The "next" group in the chain: previous[0] ends relative to currentPartGroup; older ones end + // relative to the immediately-newer previous group. + const nextGroupId = + i === 0 ? timingContext.currentPartGroup.id : getPartGroupId(previousPartsInfo[i - 1].partInstance) const previousPartGroup = createPartGroup(previousPartInfo.partInstance, { start: partStartedPlayback, - end: `#${timingContext.currentPartGroup.id}.start + ${prevPartOverlapDuration}`, + end: `#${nextGroupId}.start + ${prevPartOverlapDuration}`, }) previousPartGroup.priority = -1 - // If a Piece is infinite, and continued in the new Part, then we want to add the Piece only there to avoid id collisions + // Only set the most-recent overlap in the timing context (used downstream by AB-playback etc.) + if (i === 0) { + timingContext.previousPartOverlap = prevPartOverlapDuration + } + + // If a Piece is infinite and continued in the new Part, add it only there to avoid id collisions const previousContinuedPieces = previousPartInfo.pieceInstances.filter( (pi) => !pi.infinite || !currentInfinitePieceIds.has(pi.infinite.infiniteInstanceId) ) const groupClasses: string[] = ['previous_part'] - return [ + result.push( previousPartGroup, ...transformPartIntoTimeline( context, @@ -516,16 +552,19 @@ function generatePreviousPartInstanceObjects( groupClasses, previousPartGroup, previousPartInfo, - currentPartInstanceTimings, + // Pass the relevant "next" timings for context-sensitive piece rendering. + // For the immediately-previous part this is the current part's timings; + // for older parts it is the immediately-newer previous part's timings. + i === 0 ? currentPartInstanceTimings : previousPartsInfo[i - 1].calculatedTimings, { isRehearsal: !!activePlaylist.rehearsal, isInHold: activePlaylist.holdState === RundownHoldState.ACTIVE, } - ), - ] - } else { - return [] + ) + ) } + + return result } function generateNextPartInstanceObjects( diff --git a/packages/job-worker/src/playout/timings/partPlayback.ts b/packages/job-worker/src/playout/timings/partPlayback.ts index 3449264cce1..5bdfb1b4ad8 100644 --- a/packages/job-worker/src/playout/timings/partPlayback.ts +++ b/packages/job-worker/src/playout/timings/partPlayback.ts @@ -228,5 +228,8 @@ export function reportPartInstanceHasStopped( if (timestampUpdated) { playoutModel.queuePartInstanceTimingEvent(partInstance.partInstance._id) + // A part just stopped — good opportunity to re-evaluate whether any previous-part entries + // have passed their timeline group end time and can be pruned. + playoutModel.prunePreviousPartInstances(timestamp) } } diff --git a/packages/job-worker/src/rundownPlaylists.ts b/packages/job-worker/src/rundownPlaylists.ts index 894d140cb41..f6819c4dc76 100644 --- a/packages/job-worker/src/rundownPlaylists.ts +++ b/packages/job-worker/src/rundownPlaylists.ts @@ -237,7 +237,7 @@ export function produceRundownPlaylistInfoFromRundown( created: getCurrentTime(), currentPartInfo: null, nextPartInfo: null, - previousPartInfo: null, + previousPartsInfo: [], rundownIdsInOrder: [], tTimers: [ { index: 1, label: '', mode: null, state: null }, @@ -338,7 +338,7 @@ function defaultPlaylistForRundown( created: getCurrentTime(), currentPartInfo: null, nextPartInfo: null, - previousPartInfo: null, + previousPartsInfo: [], rundownIdsInOrder: [], tTimers: [ { index: 1, label: '', mode: null, state: null }, diff --git a/packages/live-status-gateway/src/collections/partInstancesHandler.ts b/packages/live-status-gateway/src/collections/partInstancesHandler.ts index b200e12fb52..322c93a7f66 100644 --- a/packages/live-status-gateway/src/collections/partInstancesHandler.ts +++ b/packages/live-status-gateway/src/collections/partInstancesHandler.ts @@ -13,7 +13,7 @@ import { CollectionHandlers } from '../liveStatusServer.js' import { PickKeys } from '@sofie-automation/shared-lib/dist/lib/types' export interface SelectedPartInstances { - previous: DBPartInstance | undefined + previous: DBPartInstance[] current: DBPartInstance | undefined next: DBPartInstance | undefined firstInSegmentPlayout: DBPartInstance | undefined @@ -23,7 +23,7 @@ export interface SelectedPartInstances { const PLAYLIST_KEYS = [ '_id', 'activationId', - 'previousPartInfo', + 'previousPartsInfo', 'currentPartInfo', 'nextPartInfo', 'rundownIdsInOrder', @@ -47,7 +47,7 @@ export class PartInstancesHandler extends PublicationCollection< constructor(logger: Logger, coreHandler: CoreHandler) { super(CollectionName.PartInstances, CorelibPubSub.partInstances, logger, coreHandler) this._collectionData = { - previous: undefined, + previous: [], current: undefined, next: undefined, firstInSegmentPlayout: undefined, @@ -68,9 +68,9 @@ export class PartInstancesHandler extends PublicationCollection< private updateCollectionData(): boolean { if (!this._collectionData) return false const collection = this.getCollectionOrFail() - const previousPartInstance = this._currentPlaylist?.previousPartInfo?.partInstanceId - ? collection.findOne(this._currentPlaylist.previousPartInfo.partInstanceId) - : undefined + const previousPartInstances = (this._currentPlaylist?.previousPartsInfo ?? []).flatMap((info) => + info.partInstanceId ? ([collection.findOne(info.partInstanceId)].filter(Boolean) as DBPartInstance[]) : [] + ) const currentPartInstance = this._currentPlaylist?.currentPartInfo?.partInstanceId ? collection.findOne(this._currentPlaylist.currentPartInfo.partInstanceId) : undefined @@ -87,8 +87,8 @@ export class PartInstancesHandler extends PublicationCollection< ) as DBPartInstance let hasAnythingChanged = false - if (previousPartInstance !== this._collectionData.previous) { - this._collectionData.previous = previousPartInstance + if (!areElementsShallowEqual(this._collectionData.previous, previousPartInstances)) { + this._collectionData.previous = previousPartInstances hasAnythingChanged = true } if (currentPartInstance !== this._collectionData.current) { @@ -113,7 +113,7 @@ export class PartInstancesHandler extends PublicationCollection< private clearCollectionData() { if (!this._collectionData) return this._collectionData = { - previous: undefined, + previous: [], current: undefined, next: undefined, firstInSegmentPlayout: undefined, diff --git a/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts b/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts index 5d2ed60e64a..39baaf6cd84 100644 --- a/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts +++ b/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts @@ -28,7 +28,7 @@ const PLAYLIST_KEYS = [ 'activationId', 'currentPartInfo', 'nextPartInfo', - 'previousPartInfo', + 'previousPartsInfo', 'rundownIdsInOrder', ] as const type Playlist = PickKeys @@ -117,13 +117,18 @@ export class PieceInstancesHandler extends PublicationCollection< if (!this._collectionData) return false const collection = this.getCollectionOrFail() - const inPreviousPartInstance = this._currentPlaylist?.previousPartInfo?.partInstanceId - ? this.processAndPrunePieceInstanceTimings( - this._partInstances?.previous, - collection.find({ partInstanceId: this._currentPlaylist.previousPartInfo.partInstanceId }), - true - ) - : [] + // Compute active pieces for each previous part, skipping any whose plannedStoppedPlayback has passed + // previousPartsInfo is already pruned to only contain still-active parts; per-piece timing is handled by filterActive + const inPreviousPartInstances: PieceInstanceWithTimings[] = ( + this._currentPlaylist?.previousPartsInfo ?? [] + ).flatMap((info, index) => { + if (!info.partInstanceId) return [] + return this.processAndPrunePieceInstanceTimings( + this._partInstances?.previous[index], + collection.find({ partInstanceId: info.partInstanceId }), + true + ) + }) const inCurrentPartInstance = this._currentPlaylist?.currentPartInfo?.partInstanceId ? this.processAndPrunePieceInstanceTimings( this._partInstances?.current, @@ -139,14 +144,7 @@ export class PieceInstancesHandler extends PublicationCollection< ) : [] - const active = [...inCurrentPartInstance] - // Only include the pieces from the previous part if the part is still considered to be playing - if ( - this._partInstances?.previous?.timings && - (this._partInstances.previous.timings.plannedStoppedPlayback ?? 0) > Date.now() - ) { - active.push(...inPreviousPartInstance) - } + const active = [...inCurrentPartInstance, ...inPreviousPartInstances] let hasAnythingChanged = false if (!_.isEqual(this._collectionData.active, active)) { @@ -219,7 +217,7 @@ export class PieceInstancesHandler extends PublicationCollection< this._partInstanceIds = this._currentPlaylist ? _.compact( [ - this._currentPlaylist.previousPartInfo?.partInstanceId, + ...(this._currentPlaylist.previousPartsInfo ?? []).map((info) => info.partInstanceId), this._currentPlaylist.nextPartInfo?.partInstanceId, this._currentPlaylist.currentPartInfo?.partInstanceId, ].sort() diff --git a/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts b/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts index 5ab57a08746..a0818544b9d 100644 --- a/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts +++ b/packages/live-status-gateway/src/topics/__tests__/activePlaylist.spec.ts @@ -29,7 +29,7 @@ const DEFAULT_UNCONFIGURED_T_TIMERS: ActivePlaylistEvent['tTimers'] = [ function makeEmptyTestPartInstances(): SelectedPartInstances { return { - previous: undefined, + previous: [], current: undefined, firstInSegmentPlayout: undefined, inCurrentSegment: [], diff --git a/packages/live-status-gateway/src/topics/__tests__/utils.ts b/packages/live-status-gateway/src/topics/__tests__/utils.ts index 612264befd8..1dd07e1ebb0 100644 --- a/packages/live-status-gateway/src/topics/__tests__/utils.ts +++ b/packages/live-status-gateway/src/topics/__tests__/utils.ts @@ -29,7 +29,7 @@ export function makeTestPlaylist(id?: string): DBRundownPlaylist { modified: 1695799420147, name: 'My Playlist', nextPartInfo: null, - previousPartInfo: null, + previousPartsInfo: [], rundownIdsInOrder: [protectString(RUNDOWN_1_ID), protectString(RUNDOWN_2_ID)], studioId: protectString('STUDIO_1'), timing: { type: PlaylistTimingType.None }, diff --git a/packages/webui/src/__mocks__/defaultCollectionObjects.ts b/packages/webui/src/__mocks__/defaultCollectionObjects.ts index 1f5795572a4..3cd03a1ec9c 100644 --- a/packages/webui/src/__mocks__/defaultCollectionObjects.ts +++ b/packages/webui/src/__mocks__/defaultCollectionObjects.ts @@ -43,7 +43,7 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI rehearsal: false, currentPartInfo: null, nextPartInfo: null, - previousPartInfo: null, + previousPartsInfo: [], timing: { type: 'none' as any, }, diff --git a/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts b/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts index 555d7fa022f..7eb37445085 100644 --- a/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts +++ b/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts @@ -27,7 +27,7 @@ function makeMockPlaylist(): DBRundownPlaylist { modified: 0, currentPartInfo: null, nextPartInfo: null, - previousPartInfo: null, + previousPartsInfo: [], timing: { type: PlaylistTimingType.None, }, diff --git a/packages/webui/src/client/lib/rundownPlaylistUtil.ts b/packages/webui/src/client/lib/rundownPlaylistUtil.ts index ca1c67fb76a..a128da54139 100644 --- a/packages/webui/src/client/lib/rundownPlaylistUtil.ts +++ b/packages/webui/src/client/lib/rundownPlaylistUtil.ts @@ -90,7 +90,7 @@ export class RundownPlaylistClientUtil { } static getSelectedPartInstances( - playlist: Pick, + playlist: Pick, rundownIds0?: RundownId[] ): { currentPartInstance: PartInstance | undefined @@ -105,7 +105,7 @@ export class RundownPlaylistClientUtil { const ids = _.compact([ playlist.currentPartInfo?.partInstanceId, - playlist.previousPartInfo?.partInstanceId, + playlist.previousPartsInfo?.[0]?.partInstanceId, playlist.nextPartInfo?.partInstanceId, ]) const instances = @@ -135,7 +135,9 @@ export class RundownPlaylistClientUtil { return { currentPartInstance: instances.find((inst) => inst._id === playlist.currentPartInfo?.partInstanceId), nextPartInstance: instances.find((inst) => inst._id === playlist.nextPartInfo?.partInstanceId), - previousPartInstance: instances.find((inst) => inst._id === playlist.previousPartInfo?.partInstanceId), + previousPartInstance: instances.find( + (inst) => inst._id === playlist.previousPartsInfo?.[0]?.partInstanceId + ), partInstanceToCountTimeFrom, } } diff --git a/packages/webui/src/client/ui/ClockView/DirectorScreen/DirectorScreen.tsx b/packages/webui/src/client/ui/ClockView/DirectorScreen/DirectorScreen.tsx index e10711ec93a..cb43c369345 100644 --- a/packages/webui/src/client/ui/ClockView/DirectorScreen/DirectorScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/DirectorScreen/DirectorScreen.tsx @@ -399,9 +399,9 @@ function useDirectorScreenSubscriptions(props: DirectorScreenProps): void { _id: 1, currentPartInfo: 1, nextPartInfo: 1, - previousPartInfo: 1, + previousPartsInfo: 1, }, - }) as Pick | undefined + }) as Pick | undefined if (playlist) { return RundownPlaylistClientUtil.getSelectedPartInstances(playlist) diff --git a/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx b/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx index 6784c923967..d67f7e9496c 100644 --- a/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx @@ -442,9 +442,9 @@ export function usePresenterScreenSubscriptions(props: PresenterScreenProps): vo _id: 1, currentPartInfo: 1, nextPartInfo: 1, - previousPartInfo: 1, + previousPartsInfo: 1, }, - }) as Pick | undefined + }) as Pick | undefined if (playlist) { return RundownPlaylistClientUtil.getSelectedPartInstances(playlist) diff --git a/packages/webui/src/client/ui/MediaStatus/MediaStatus.tsx b/packages/webui/src/client/ui/MediaStatus/MediaStatus.tsx index 862135c2914..8d9bb2996af 100644 --- a/packages/webui/src/client/ui/MediaStatus/MediaStatus.tsx +++ b/packages/webui/src/client/ui/MediaStatus/MediaStatus.tsx @@ -100,7 +100,7 @@ function useRundownPlaylists(playlistIds: RundownPlaylistId[]) { nextPartInfo: 0, queuedSegmentId: 0, nextTimeOffset: 0, - previousPartInfo: 0, + previousPartsInfo: 0, publicPlayoutPersistentState: 0, privatePlayoutPersistentState: 0, resetTime: 0, diff --git a/packages/webui/src/client/ui/RundownView.tsx b/packages/webui/src/client/ui/RundownView.tsx index 4e50b1d3337..95e15b4c59d 100644 --- a/packages/webui/src/client/ui/RundownView.tsx +++ b/packages/webui/src/client/ui/RundownView.tsx @@ -205,7 +205,7 @@ export function RundownView(props: Readonly): JSX.Element { const partInstances = useTracker( () => playlist && RundownPlaylistClientUtil.getSelectedPartInstances(playlist), - [playlist?._id, playlist?.nextPartInfo, playlist?.currentPartInfo, playlist?.previousPartInfo] + [playlist?._id, playlist?.nextPartInfo, playlist?.currentPartInfo, playlist?.previousPartsInfo] ) const somePartInstance = partInstances?.currentPartInstance || partInstances?.nextPartInstance diff --git a/packages/webui/src/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx b/packages/webui/src/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx index fe5e9871f9e..7ccdf261a99 100644 --- a/packages/webui/src/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx @@ -118,7 +118,7 @@ export const RundownTimingProvider = withTracker< const { currentPartInstance } = findCurrentAndPreviousPartInstance( activePartInstances, playlist.currentPartInfo?.partInstanceId, - playlist.previousPartInfo?.partInstanceId + playlist.previousPartsInfo?.[0]?.partInstanceId ) const currentRundown = currentPartInstance diff --git a/packages/webui/src/client/ui/RundownView/RundownViewSubscriptions.ts b/packages/webui/src/client/ui/RundownView/RundownViewSubscriptions.ts index 6278eb097f9..912302257ba 100644 --- a/packages/webui/src/client/ui/RundownView/RundownViewSubscriptions.ts +++ b/packages/webui/src/client/ui/RundownView/RundownViewSubscriptions.ts @@ -93,9 +93,9 @@ export function useRundownViewSubscriptions(playlistId: RundownPlaylistId): bool fields: { currentPartInfo: 1, nextPartInfo: 1, - previousPartInfo: 1, + previousPartsInfo: 1, }, - }) as Pick | undefined + }) as Pick | undefined if (playlist) { const rundownIds = RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) // Use meteorSubscribe so that this subscription doesn't mess with this.subscriptionsReady() @@ -107,7 +107,7 @@ export function useRundownViewSubscriptions(playlistId: RundownPlaylistId): bool [ playlist.currentPartInfo?.partInstanceId, playlist.nextPartInfo?.partInstanceId, - playlist.previousPartInfo?.partInstanceId, + playlist.previousPartsInfo?.[0]?.partInstanceId, ].filter((p): p is PartInstanceId => p !== null), {} ) diff --git a/packages/webui/src/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx b/packages/webui/src/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx index 790bfaad166..0c547815e4f 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx @@ -159,7 +159,7 @@ export class SegmentTimelinePartClass extends React.Component, state: Readonly ): Partial { - const isPrevious = nextProps.playlist.previousPartInfo?.partInstanceId === nextProps.part.instance._id + const isPrevious = nextProps.playlist.previousPartsInfo?.[0]?.partInstanceId === nextProps.part.instance._id const isLive = nextProps.playlist.currentPartInfo?.partInstanceId === nextProps.part.instance._id const isNext = nextProps.playlist.nextPartInfo?.partInstanceId === nextProps.part.instance._id diff --git a/packages/webui/src/client/ui/SegmentTimeline/SegmentContextMenu.tsx b/packages/webui/src/client/ui/SegmentTimeline/SegmentContextMenu.tsx index fb65af321f5..1490f33652d 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/SegmentContextMenu.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/SegmentContextMenu.tsx @@ -123,7 +123,7 @@ export function SegmentContextMenu({ isSegmentEditAble && part?.instance._id !== playlist.currentPartInfo?.partInstanceId && part?.instance._id !== playlist.nextPartInfo?.partInstanceId && - part?.instance._id !== playlist.previousPartInfo?.partInstanceId + part?.instance._id !== playlist.previousPartsInfo?.[0]?.partInstanceId const segmentHasEditableContent = hasUserEditableContent(segment) const partHasEditableContent = hasUserEditableContent(part?.instance.part) diff --git a/packages/webui/src/client/ui/Shelf/AdLibPanel.tsx b/packages/webui/src/client/ui/Shelf/AdLibPanel.tsx index 4e869c9382a..13986386ada 100644 --- a/packages/webui/src/client/ui/Shelf/AdLibPanel.tsx +++ b/packages/webui/src/client/ui/Shelf/AdLibPanel.tsx @@ -132,7 +132,7 @@ function actionToAdLibPieceUi( interface IFetchAndFilterProps { playlist: Pick< DBRundownPlaylist, - '_id' | 'currentPartInfo' | 'nextPartInfo' | 'previousPartInfo' | 'rundownIdsInOrder' + '_id' | 'currentPartInfo' | 'nextPartInfo' | 'previousPartsInfo' | 'rundownIdsInOrder' > showStyleBase: Pick filter?: RundownLayoutFilterBase @@ -151,7 +151,7 @@ export function useFetchAndFilter( ? fetchAndFilter({ playlist: playlist as Pick< DBRundownPlaylist, - '_id' | 'studioId' | 'currentPartInfo' | 'nextPartInfo' | 'previousPartInfo' | 'rundownIdsInOrder' + '_id' | 'studioId' | 'currentPartInfo' | 'nextPartInfo' | 'previousPartsInfo' | 'rundownIdsInOrder' >, showStyleBase: showStyleBase as Pick, filter, @@ -169,7 +169,7 @@ export function useFetchAndFilter( playlist?.studioId, playlist?.currentPartInfo?.partInstanceId, playlist?.nextPartInfo?.partInstanceId, - playlist?.previousPartInfo?.partInstanceId, + playlist?.previousPartsInfo?.[0]?.partInstanceId, playlist?.rundownIdsInOrder, showStyleBase?._id, showStyleBase?.sourceLayers, From acb87a227347c0a47468f101bfed8d469a3f1905 Mon Sep 17 00:00:00 2001 From: ianshade Date: Thu, 23 Apr 2026 07:04:06 +0200 Subject: [PATCH 2/6] chore(EAV-949): correct missing partInstance error messages --- packages/job-worker/src/playout/__tests__/lib.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/job-worker/src/playout/__tests__/lib.ts b/packages/job-worker/src/playout/__tests__/lib.ts index db60ce8d2e7..f2e5bf36785 100644 --- a/packages/job-worker/src/playout/__tests__/lib.ts +++ b/packages/job-worker/src/playout/__tests__/lib.ts @@ -29,9 +29,9 @@ export async function getSelectedPartInstances( if (currentPartInstance === undefined) throw new Error(`Missing currentPartInstance "${playlist.currentPartInfo?.partInstanceId}"`) if (nextPartInstance === undefined) - throw new Error(`Missing currentPartInstance "${playlist.nextPartInfo?.partInstanceId}"`) + throw new Error(`Missing nextPartInstance "${playlist.nextPartInfo?.partInstanceId}"`) if (previousPartInstance === undefined) - throw new Error(`Missing currentPartInstance "${playlist.previousPartsInfo?.[0]?.partInstanceId}"`) + throw new Error(`Missing previousPartInstance "${playlist.previousPartsInfo?.[0]?.partInstanceId}"`) return { currentPartInstance, nextPartInstance, previousPartInstance } } From 6d46444c4467e400e329bee88095d53196cd286a Mon Sep 17 00:00:00 2001 From: ianshade Date: Thu, 23 Apr 2026 07:39:52 +0200 Subject: [PATCH 3/6] chore(EAV-949): add extra previous part to findLookaheadForLayer test --- .../lookahead/__tests__/findForLayer/constants.ts | 8 +++++++- .../__tests__/findForLayer/searchDistance.test.ts | 11 +++++++---- .../__tests__/findForLayer/timing.test.ts | 14 ++++++++------ 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/constants.ts b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/constants.ts index eaad0d20442..33c88cbea49 100644 --- a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/constants.ts +++ b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/constants.ts @@ -24,11 +24,17 @@ export const findForLayerTestConstants = { }, previous: [ { - part: { _id: 'pPrev', part: 'prev' }, + part: { _id: 'pPrev0', part: 'prev0' }, allPieces: [createFakePiece('1'), createFakePiece('2'), createFakePiece('3')], onTimeline: true, nowInPart: 2000, }, + { + part: { _id: 'pPrev1', part: 'prev1' }, + allPieces: [createFakePiece('13'), createFakePiece('14'), createFakePiece('15')], + onTimeline: true, + nowInPart: 900, + }, ] as any as PartInstanceAndPieceInstances[], current: { part: { _id: 'pCur', part: 'cur' }, diff --git a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/searchDistance.test.ts b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/searchDistance.test.ts index aecb94c4467..e10601ee38b 100644 --- a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/searchDistance.test.ts +++ b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/searchDistance.test.ts @@ -21,7 +21,10 @@ const onAirPlayoutState = findForLayerTestConstants.playoutState.onAir describe('findLookaheadForLayer – search distance', () => { test('searchDistance = 0 ignores future parts', () => { - findLookaheadObjectsForPartMock.mockReturnValueOnce([] as any).mockReturnValueOnce(['cur0', 'cur1'] as any) + findLookaheadObjectsForPartMock + .mockReturnValueOnce([] as any) + .mockReturnValueOnce([] as any) + .mockReturnValueOnce(['cur0', 'cur1'] as any) const res = findLookaheadForLayer( context, @@ -37,9 +40,9 @@ describe('findLookaheadForLayer – search distance', () => { expect(res.timed).toEqual(['cur0', 'cur1']) expect(res.future).toHaveLength(0) - expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(3) - expectInstancesToMatch(findLookaheadObjectsForPartMock, 2, layer, current, previous[0], onAirPlayoutState) - expectInstancesToMatch(findLookaheadObjectsForPartMock, 3, layer, nextFuture, current, onAirPlayoutState) + expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(4) + expectInstancesToMatch(findLookaheadObjectsForPartMock, 3, layer, current, previous[1], onAirPlayoutState) + expectInstancesToMatch(findLookaheadObjectsForPartMock, 4, layer, nextFuture, current, onAirPlayoutState) }) test('returns nothing when maxSearchDistance is too small', () => { diff --git a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/timing.test.ts b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/timing.test.ts index 4b6b68114ea..d1f507e0ba4 100644 --- a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/timing.test.ts +++ b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/timing.test.ts @@ -22,6 +22,7 @@ const layer = findForLayerTestConstants.layer describe('findLookaheadForLayer – timing', () => { test('current part with timed next part (all goes into timed)', () => { findLookaheadObjectsForPartMock + .mockReturnValueOnce([] as any) .mockReturnValueOnce([] as any) .mockReturnValueOnce(['cur0', 'cur1'] as any) .mockReturnValueOnce(['nT0', 'nT1'] as any) @@ -40,13 +41,14 @@ describe('findLookaheadForLayer – timing', () => { expect(res.timed).toEqual(['cur0', 'cur1', 'nT0', 'nT1']) // should have all pieces expect(res.future).toHaveLength(0) // should be empty - expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(3) - expectInstancesToMatch(findLookaheadObjectsForPartMock, 2, layer, current, previous[0], onAirPlayoutState) - expectInstancesToMatch(findLookaheadObjectsForPartMock, 3, layer, nextTimed, current, onAirPlayoutState) + expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(4) + expectInstancesToMatch(findLookaheadObjectsForPartMock, 3, layer, current, previous[1], onAirPlayoutState) + expectInstancesToMatch(findLookaheadObjectsForPartMock, 4, layer, nextTimed, current, onAirPlayoutState) }) test('current part with un-timed next part (next goes into future)', () => { findLookaheadObjectsForPartMock + .mockReturnValueOnce([] as any) .mockReturnValueOnce([] as any) .mockReturnValueOnce(['cur0', 'cur1'] as any) .mockReturnValueOnce(['nF0', 'nF1'] as any) @@ -65,8 +67,8 @@ describe('findLookaheadForLayer – timing', () => { expect(res.timed).toEqual(['cur0', 'cur1']) // Should only contain the current part's pieces expect(res.future).toEqual(['nF0', 'nF1']) // Should only contain the future pieces - expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(3) - expectInstancesToMatch(findLookaheadObjectsForPartMock, 2, layer, current, previous[0], onAirPlayoutState) - expectInstancesToMatch(findLookaheadObjectsForPartMock, 3, layer, nextFuture, current, onAirPlayoutState) + expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(4) + expectInstancesToMatch(findLookaheadObjectsForPartMock, 3, layer, current, previous[1], onAirPlayoutState) + expectInstancesToMatch(findLookaheadObjectsForPartMock, 4, layer, nextFuture, current, onAirPlayoutState) }) }) From 32d7796138ccccffb3213aecd366630f2315618a Mon Sep 17 00:00:00 2001 From: ianshade Date: Thu, 23 Apr 2026 08:22:10 +0200 Subject: [PATCH 4/6] fix(EAV-949): prevent dangling unstarted group reference --- .../timeline/__tests__/rundown.test.ts | 41 +++++++++++++++++++ .../src/playout/timeline/rundown.ts | 24 ++++------- 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/packages/job-worker/src/playout/timeline/__tests__/rundown.test.ts b/packages/job-worker/src/playout/timeline/__tests__/rundown.test.ts index 8670bc84cd9..0b8c13f1e6a 100644 --- a/packages/job-worker/src/playout/timeline/__tests__/rundown.test.ts +++ b/packages/job-worker/src/playout/timeline/__tests__/rundown.test.ts @@ -996,5 +996,46 @@ describe('buildTimelineObjsForRundown', () => { expect(objs.timeline.find((obj) => obj.id === prev0GroupId)).toBeFalsy() expect(objs.timingContext?.previousPartOverlap).toBeUndefined() }) + + it('does not create dangling references when previous[0] is skipped and previous[1] is active', () => { + const context = setupDefaultJobEnvironment() + + const prev0NoPlayback: SelectedPartInstanceTimelineInfo = { + partTimes: createPartCurrentTimes(currentTime, 8000), + partInstance: createMockPartInstance('prev0noPB'), + pieceInstances: [createMockPieceInstance('piece_prev0noPB')], + calculatedTimings: { ...DEFAULT_PART_TIMINGS, fromPartRemaining: 5000 }, + regenerateTimelineAt: undefined, + } + + const prev1Active = makeActivePrevInfo('prev1active', 2999, 3000, 2000) + + const selectedPartInfos: SelectedPartInstancesTimelineInfo = { + previous: [prev0NoPlayback, prev1Active], + current: { + partTimes: createPartCurrentTimes(currentTime, 9500), + partInstance: createMockPartInstance('current', {}, { timings: { plannedStartedPlayback: 9499 } }), + pieceInstances: [], + calculatedTimings: { ...DEFAULT_PART_TIMINGS, fromPartRemaining: 3000 }, + regenerateTimelineAt: undefined, + }, + } + + const playlist = createMockPlaylist(selectedPartInfos) + const objs = buildTimelineObjsForRundown(context, playlist, selectedPartInfos, true) + + const prev0GroupId = getPartGroupId(prev0NoPlayback.partInstance) + const prev1GroupId = getPartGroupId(prev1Active.partInstance) + const currentGroupId = objs.timingContext!.currentPartGroup.id + + expect(objs.timeline.find((obj) => obj.id === prev0GroupId)).toBeFalsy() + + // prev1 must be emitted and must chain to the current group (not the skipped prev0 group) + const prev1Group = objs.timeline.find((obj) => obj.id === prev1GroupId) + expect(prev1Group).toBeTruthy() + // prev1 chains to the current group using currentPartInstanceTimings.fromPartRemaining (3000), + // because prev0 was skipped so nextPartTimings stays as currentPartInstanceTimings + expect(prev1Group!.enable).toMatchObject({ end: `#${currentGroupId}.start + 3000` }) + }) }) }) diff --git a/packages/job-worker/src/playout/timeline/rundown.ts b/packages/job-worker/src/playout/timeline/rundown.ts index 0427867b38e..45fd4ebe6d0 100644 --- a/packages/job-worker/src/playout/timeline/rundown.ts +++ b/packages/job-worker/src/playout/timeline/rundown.ts @@ -501,6 +501,8 @@ function generatePreviousPartInstancesObjects( currentPartInstanceTimings: PartCalculatedTimings ): Array { const result: Array = [] + let nextGroupId = timingContext.currentPartGroup.id + let nextPartTimings: PartCalculatedTimings = currentPartInstanceTimings for (let i = 0; i < previousPartsInfo.length; i++) { const previousPartInfo = previousPartsInfo[i] @@ -515,15 +517,7 @@ function generatePreviousPartInstancesObjects( * (comes from previous[i-1].calculatedTimings.fromPartRemaining, which is the "fromPartRemaining" * stored on the part that was taken TO previous[i-1] FROM previous[i]) */ - const prevPartOverlapDuration = - i === 0 - ? currentPartInstanceTimings.fromPartRemaining - : previousPartsInfo[i - 1].calculatedTimings.fromPartRemaining - - // The "next" group in the chain: previous[0] ends relative to currentPartGroup; older ones end - // relative to the immediately-newer previous group. - const nextGroupId = - i === 0 ? timingContext.currentPartGroup.id : getPartGroupId(previousPartsInfo[i - 1].partInstance) + const prevPartOverlapDuration = nextPartTimings.fromPartRemaining const previousPartGroup = createPartGroup(previousPartInfo.partInstance, { start: partStartedPlayback, @@ -531,8 +525,8 @@ function generatePreviousPartInstancesObjects( }) previousPartGroup.priority = -1 - // Only set the most-recent overlap in the timing context (used downstream by AB-playback etc.) - if (i === 0) { + // Only set the first generated overlap in the timing context (used downstream by AB-playback etc.) + if (timingContext.previousPartOverlap === undefined) { timingContext.previousPartOverlap = prevPartOverlapDuration } @@ -552,16 +546,16 @@ function generatePreviousPartInstancesObjects( groupClasses, previousPartGroup, previousPartInfo, - // Pass the relevant "next" timings for context-sensitive piece rendering. - // For the immediately-previous part this is the current part's timings; - // for older parts it is the immediately-newer previous part's timings. - i === 0 ? currentPartInstanceTimings : previousPartsInfo[i - 1].calculatedTimings, + nextPartTimings, { isRehearsal: !!activePlaylist.rehearsal, isInHold: activePlaylist.holdState === RundownHoldState.ACTIVE, } ) ) + + nextGroupId = getPartGroupId(previousPartInfo.partInstance) + nextPartTimings = previousPartInfo.calculatedTimings } return result From 718f4c4621abbfd025f29d6c716056ecd5478d1c Mon Sep 17 00:00:00 2001 From: ianshade Date: Thu, 23 Apr 2026 08:32:00 +0200 Subject: [PATCH 5/6] fix(EAV-949): migrate legacy previousPartInfo during snapshot restore --- packages/job-worker/src/playout/snapshot.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/job-worker/src/playout/snapshot.ts b/packages/job-worker/src/playout/snapshot.ts index eddfea4a5ba..10611a93722 100644 --- a/packages/job-worker/src/playout/snapshot.ts +++ b/packages/job-worker/src/playout/snapshot.ts @@ -735,6 +735,7 @@ function fixupImportedSelectedPartInstanceIds( } if (property === 'previous') { + convertPreviousPartInfoToArray(snapshot) // previousPartsInfo is an array — remap each entry const snapshotInfos = snapshot.playlist.previousPartsInfo if (snapshotInfos?.length) { @@ -758,3 +759,10 @@ function fixupImportedSelectedPartInstanceIds( } } } +function convertPreviousPartInfoToArray(snapshot: CoreRundownPlaylistSnapshot) { + const legacyPreviousPartInfo = (snapshot.playlist as any).previousPartInfo + if (!Array.isArray(snapshot.playlist.previousPartsInfo)) { + snapshot.playlist.previousPartsInfo = legacyPreviousPartInfo ? [legacyPreviousPartInfo] : [] + } + delete (snapshot.playlist as any).previousPartInfo +} From 44ff075e451ce408262c103b588ba2ea0abd56e7 Mon Sep 17 00:00:00 2001 From: ianshade Date: Thu, 23 Apr 2026 10:52:34 +0200 Subject: [PATCH 6/6] fix(EAV-949): potentially incorrect lookup of previous partinstances --- .../src/collections/pieceInstancesHandler.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts b/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts index 39baaf6cd84..4984d1d64e4 100644 --- a/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts +++ b/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts @@ -117,14 +117,18 @@ export class PieceInstancesHandler extends PublicationCollection< if (!this._collectionData) return false const collection = this.getCollectionOrFail() + const previousPartInstancesById = new Map( + this._partInstances?.previous.map((partInstance) => [partInstance._id, partInstance]) ?? [] + ) + // Compute active pieces for each previous part, skipping any whose plannedStoppedPlayback has passed // previousPartsInfo is already pruned to only contain still-active parts; per-piece timing is handled by filterActive const inPreviousPartInstances: PieceInstanceWithTimings[] = ( this._currentPlaylist?.previousPartsInfo ?? [] - ).flatMap((info, index) => { + ).flatMap((info) => { if (!info.partInstanceId) return [] return this.processAndPrunePieceInstanceTimings( - this._partInstances?.previous[index], + previousPartInstancesById.get(info.partInstanceId), collection.find({ partInstanceId: info.partInstanceId }), true )