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..f2e5bf36785 100644 --- a/packages/job-worker/src/playout/__tests__/lib.ts +++ b/packages/job-worker/src/playout/__tests__/lib.ts @@ -21,17 +21,17 @@ 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, ]) 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.previousPartInfo?.partInstanceId}"`) + throw new Error(`Missing previousPartInstance "${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..33c88cbea49 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,20 @@ 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: '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' }, 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..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(['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(2) - expectInstancesToMatch(findLookaheadObjectsForPartMock, 1, layer, current, previous, onAirPlayoutState) - expectInstancesToMatch(findLookaheadObjectsForPartMock, 2, 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', () => { @@ -51,7 +54,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..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,8 @@ 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) @@ -39,13 +41,15 @@ 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(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) @@ -63,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(2) - expectInstancesToMatch(findLookaheadObjectsForPartMock, 1, layer, current, previous, onAirPlayoutState) - expectInstancesToMatch(findLookaheadObjectsForPartMock, 2, layer, nextFuture, current, onAirPlayoutState) + expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(4) + expectInstancesToMatch(findLookaheadObjectsForPartMock, 3, layer, current, previous[1], onAirPlayoutState) + expectInstancesToMatch(findLookaheadObjectsForPartMock, 4, 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..10611a93722 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,50 @@ 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') { + convertPreviousPartInfoToArray(snapshot) + // 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, + } + } + } +} +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 } 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..0b8c13f1e6a 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,159 @@ 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() + }) + + 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/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..45fd4ebe6d0 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,60 @@ 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 = [] + let nextGroupId = timingContext.currentPartGroup.id + let nextPartTimings: PartCalculatedTimings = currentPartInstanceTimings + + 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 = nextPartTimings.fromPartRemaining 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 first generated overlap in the timing context (used downstream by AB-playback etc.) + if (timingContext.previousPartOverlap === undefined) { + 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 +546,19 @@ function generatePreviousPartInstanceObjects( groupClasses, previousPartGroup, previousPartInfo, - currentPartInstanceTimings, + nextPartTimings, { isRehearsal: !!activePlaylist.rehearsal, isInHold: activePlaylist.holdState === RundownHoldState.ACTIVE, } - ), - ] - } else { - return [] + ) + ) + + nextGroupId = getPartGroupId(previousPartInfo.partInstance) + nextPartTimings = previousPartInfo.calculatedTimings } + + 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..4984d1d64e4 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,22 @@ 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 - ) - : [] + 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) => { + if (!info.partInstanceId) return [] + return this.processAndPrunePieceInstanceTimings( + previousPartInstancesById.get(info.partInstanceId), + collection.find({ partInstanceId: info.partInstanceId }), + true + ) + }) const inCurrentPartInstance = this._currentPlaylist?.currentPartInfo?.partInstanceId ? this.processAndPrunePieceInstanceTimings( this._partInstances?.current, @@ -139,14 +148,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 +221,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,