From 1c7a8e73bb0cfdca9e97e0e44209cff46b9bb85e Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Thu, 13 Mar 2025 15:04:07 +0000 Subject: [PATCH] feat: time of day pieces SOFIE-197 (#43) --- meteor/__mocks__/helpers/database.ts | 1 + .../server/api/deviceTriggers/TagsService.ts | 5 +- meteor/server/api/ingest/packageInfo.ts | 1 + meteor/server/api/rest/v1/typeConversion.ts | 2 + meteor/server/lib/rest/v1/studios.ts | 1 + meteor/server/migration/1_50_0.ts | 2 +- .../rundown/regenerateItems.ts | 4 +- .../src/api/showStyle.ts | 8 + .../src/documents/index.ts | 1 + .../src/documents/rundownPiece.ts | 29 +++ .../corelib/src/dataModel/ExpectedPackages.ts | 9 + packages/corelib/src/dataModel/Piece.ts | 13 +- .../corelib/src/dataModel/PieceInstance.ts | 2 +- .../playout/__tests__/processAndPrune.test.ts | 202 +++++++++++++----- packages/corelib/src/playout/infinites.ts | 39 +++- .../corelib/src/playout/processAndPrune.ts | 138 +++++++++--- packages/job-worker/src/__mocks__/context.ts | 1 + .../__tests__/context-events.test.ts | 1 + .../job-worker/src/blueprints/context/lib.ts | 28 ++- .../PartAndPieceInstanceActionService.ts | 4 + .../PartAndPieceInstanceActionService.test.ts | 5 +- .../job-worker/src/blueprints/postProcess.ts | 80 +++++++ .../src/ingest/expectedMediaItems.ts | 4 +- .../job-worker/src/ingest/expectedPackages.ts | 15 ++ .../src/ingest/expectedPlayoutItems.ts | 5 +- .../src/ingest/generationRundown.ts | 10 +- .../src/ingest/model/IngestModel.ts | 11 +- .../model/implementation/IngestModelImpl.ts | 68 ++++-- .../model/implementation/SaveIngestModel.ts | 15 +- .../src/ingest/syncChangesToPartInstance.ts | 5 +- .../src/playout/__tests__/playout.test.ts | 4 +- .../playout/__tests__/resolvedPieces.test.ts | 194 +++++++---------- packages/job-worker/src/playout/adlibJobs.ts | 2 + packages/job-worker/src/playout/adlibUtils.ts | 17 +- packages/job-worker/src/playout/debug.ts | 1 + packages/job-worker/src/playout/infinites.ts | 63 +++++- .../lookahead/__tests__/lookahead.test.ts | 16 +- .../src/playout/lookahead/findObjects.ts | 2 +- .../job-worker/src/playout/lookahead/index.ts | 74 ++++--- .../job-worker/src/playout/resolvedPieces.ts | 26 +-- packages/job-worker/src/playout/setNext.ts | 25 ++- packages/job-worker/src/playout/snapshot.ts | 24 ++- packages/job-worker/src/playout/take.ts | 8 +- .../timeline/__tests__/rundown.test.ts | 91 +++----- .../src/playout/timeline/generate.ts | 70 ++++-- .../src/playout/timeline/multi-gateway.ts | 18 +- .../job-worker/src/playout/timeline/part.ts | 2 +- .../src/playout/timeline/pieceGroup.ts | 10 +- .../src/playout/timeline/rundown.ts | 132 +++++++++--- .../src/playout/timings/piecePlayback.ts | 35 ++- .../src/collections/pieceInstancesHandler.ts | 14 +- packages/openapi/api/definitions/studios.yaml | 3 + .../src/core/model/StudioSettings.ts | 5 + .../peripheralDevice/peripheralDeviceAPI.ts | 1 - .../webui/src/client/lib/RundownResolver.ts | 4 +- packages/webui/src/client/lib/rundown.ts | 12 +- .../webui/src/client/lib/rundownLayouts.ts | 11 +- .../src/client/lib/rundownPlaylistUtil.ts | 2 +- packages/webui/src/client/lib/shelf.ts | 9 +- .../src/client/lib/ui/pieceUiClassNames.ts | 2 + .../ui/ClipTrimPanel/ClipTrimDialog.tsx | 5 +- .../src/client/ui/MediaStatus/MediaStatus.tsx | 4 +- .../webui/src/client/ui/Prompter/prompter.ts | 11 +- .../RundownView/SelectedElementsContext.tsx | 2 +- .../src/client/ui/Settings/Studio/Generic.tsx | 17 ++ 65 files changed, 1155 insertions(+), 470 deletions(-) create mode 100644 packages/blueprints-integration/src/documents/rundownPiece.ts diff --git a/meteor/__mocks__/helpers/database.ts b/meteor/__mocks__/helpers/database.ts index ea62db5c5b..48eebaed1e 100644 --- a/meteor/__mocks__/helpers/database.ts +++ b/meteor/__mocks__/helpers/database.ts @@ -468,6 +468,7 @@ export async function setupMockShowStyleBlueprint( rundown, globalAdLibPieces: [], globalActions: [], + globalPieces: [], baseline: { timelineObjects: [] }, } }, diff --git a/meteor/server/api/deviceTriggers/TagsService.ts b/meteor/server/api/deviceTriggers/TagsService.ts index 499a764f5b..48bce89d75 100644 --- a/meteor/server/api/deviceTriggers/TagsService.ts +++ b/meteor/server/api/deviceTriggers/TagsService.ts @@ -4,12 +4,14 @@ import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceIns import { PieceInstanceFields, ContentCache } from './reactiveContentCacheForPieceInstances' import { SourceLayers } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' import { + createPartCurrentTimes, PieceInstanceWithTimings, processAndPrunePieceInstanceTimings, } from '@sofie-automation/corelib/dist/playout/processAndPrune' import { applyAndValidateOverrides } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' import { IWrappedAdLib } from '@sofie-automation/meteor-lib/dist/triggers/actionFilterChainCompilers' import { areSetsEqual, doSetsIntersect } from '@sofie-automation/corelib/dist/lib' +import { getCurrentTime } from '../../lib/lib' export class TagsService { protected onAirPiecesTags: Set = new Set() @@ -130,12 +132,11 @@ export class TagsService { ): PieceInstanceWithTimings[] { // Approximate when 'now' is in the PartInstance, so that any adlibbed Pieces will be timed roughly correctly const partStarted = partInstanceTimings?.plannedStartedPlayback - const nowInPart = partStarted === undefined ? 0 : Date.now() - partStarted return processAndPrunePieceInstanceTimings( sourceLayers, pieceInstances as PieceInstance[], - nowInPart, + createPartCurrentTimes(getCurrentTime(), partStarted), false, false ) diff --git a/meteor/server/api/ingest/packageInfo.ts b/meteor/server/api/ingest/packageInfo.ts index d3d52ccaf6..7d60627df9 100644 --- a/meteor/server/api/ingest/packageInfo.ts +++ b/meteor/server/api/ingest/packageInfo.ts @@ -35,6 +35,7 @@ export async function onUpdatedPackageInfo(packageId: ExpectedPackageId, _doc: P case ExpectedPackageDBType.ADLIB_ACTION: case ExpectedPackageDBType.BASELINE_ADLIB_PIECE: case ExpectedPackageDBType.BASELINE_ADLIB_ACTION: + case ExpectedPackageDBType.BASELINE_PIECE: case ExpectedPackageDBType.RUNDOWN_BASELINE_OBJECTS: onUpdatedPackageInfoForRundownDebounce(pkg) break diff --git a/meteor/server/api/rest/v1/typeConversion.ts b/meteor/server/api/rest/v1/typeConversion.ts index 11aad92142..8f35c2d75e 100644 --- a/meteor/server/api/rest/v1/typeConversion.ts +++ b/meteor/server/api/rest/v1/typeConversion.ts @@ -388,6 +388,7 @@ export function studioSettingsFrom(apiStudioSettings: APIStudioSettings): Comple allowPieceDirectPlay: apiStudioSettings.allowPieceDirectPlay ?? true, // Backwards compatible enableBuckets: apiStudioSettings.enableBuckets ?? true, // Backwards compatible enableEvaluationForm: apiStudioSettings.enableEvaluationForm ?? true, // Backwards compatible + rundownGlobalPiecesPrepareTime: apiStudioSettings.rundownGlobalPiecesPrepareTime, } } @@ -413,6 +414,7 @@ export function APIStudioSettingsFrom(settings: IStudioSettings): Complete() for (const piece of pieces) { - partIdLookup.set(piece._id, piece.startPartId) + if (piece.startPartId) partIdLookup.set(piece._id, piece.startPartId) } for (const adlib of adlibPieces) { if (adlib.partId) partIdLookup.set(adlib._id, adlib.partId) diff --git a/meteor/server/publications/pieceContentStatusUI/rundown/regenerateItems.ts b/meteor/server/publications/pieceContentStatusUI/rundown/regenerateItems.ts index 6ebd473b79..690f3341e4 100644 --- a/meteor/server/publications/pieceContentStatusUI/rundown/regenerateItems.ts +++ b/meteor/server/publications/pieceContentStatusUI/rundown/regenerateItems.ts @@ -107,7 +107,7 @@ export async function regenerateForPieceIds( { _id: protectString(`piece_${pieceId}`), - partId: pieceDoc.startPartId, + partId: pieceDoc.startPartId ?? undefined, rundownId: pieceDoc.startRundownId, pieceId: pieceId, @@ -180,7 +180,7 @@ export async function regenerateForPieceInstanceIds( const res: UIPieceContentStatus = { _id: protectString(`piece_${pieceId}`), - partId: pieceDoc.piece.startPartId, + partId: pieceDoc.piece.startPartId ?? undefined, rundownId: pieceDoc.rundownId, pieceId: pieceId, diff --git a/packages/blueprints-integration/src/api/showStyle.ts b/packages/blueprints-integration/src/api/showStyle.ts index 9ccf91c393..eba6dfef88 100644 --- a/packages/blueprints-integration/src/api/showStyle.ts +++ b/packages/blueprints-integration/src/api/showStyle.ts @@ -35,6 +35,8 @@ import type { IBlueprintSegment, IBlueprintPiece, IBlueprintPart, + IBlueprintRundownPiece, + IBlueprintRundownPieceDB, } from '../documents' import type { IBlueprintShowStyleVariant, IOutputLayer, ISourceLayer } from '../showStyle' import type { TSR, OnGenerateTimelineObj, TimelineObjectCoreExt } from '../timeline' @@ -266,6 +268,7 @@ export interface BlueprintResultRundown { rundown: IBlueprintRundown globalAdLibPieces: IBlueprintAdLibPiece[] globalActions: IBlueprintActionManifest[] + globalPieces: IBlueprintRundownPiece[] baseline: BlueprintResultBaseline } export interface BlueprintResultSegment { @@ -292,6 +295,11 @@ export interface BlueprintSyncIngestNewData { actions: IBlueprintActionManifest[] /** A list of adlibs that have pieceInstances in the partInstance in question */ referencedAdlibs: IBlueprintAdLibPieceDB[] + /** + * The list of pieces which belong to the Rundown, and may be active + * Note: Some of these may have played and been stopped before the current PartInstance + */ + rundownPieces: IBlueprintRundownPieceDB[] } // TODO: add something like this later? diff --git a/packages/blueprints-integration/src/documents/index.ts b/packages/blueprints-integration/src/documents/index.ts index acd254dedb..5ce67cc685 100644 --- a/packages/blueprints-integration/src/documents/index.ts +++ b/packages/blueprints-integration/src/documents/index.ts @@ -7,5 +7,6 @@ export * from './pieceInstance' export * from './pieceGeneric' export * from './playlistTiming' export * from './rundown' +export * from './rundownPiece' export * from './rundownPlaylist' export * from './segment' diff --git a/packages/blueprints-integration/src/documents/rundownPiece.ts b/packages/blueprints-integration/src/documents/rundownPiece.ts new file mode 100644 index 0000000000..970d4317e4 --- /dev/null +++ b/packages/blueprints-integration/src/documents/rundownPiece.ts @@ -0,0 +1,29 @@ +import { IBlueprintPieceGeneric } from './pieceGeneric' + +/** + * A variant of a Piece, that is owned by the Rundown. + * This + */ +export interface IBlueprintRundownPiece + extends Omit, 'lifespan'> { + /** When the piece should be active on the timeline. */ + enable: { + start: number + duration?: number + + // For now, these pieces are always absolute (using wall time) rather than relative to the rundown + isAbsolute: true + } + + /** Whether the piece is a real piece, or exists as a marker to stop an infinite piece. If virtual, it does not add any contents to the timeline */ + virtual?: boolean + + /** Whether the piece affects the output of the Studio or is describing an invisible state within the Studio */ + notInVision?: boolean +} + +/** The Rundown piece sent from Core */ +export interface IBlueprintRundownPieceDB + extends IBlueprintRundownPiece { + _id: string +} diff --git a/packages/corelib/src/dataModel/ExpectedPackages.ts b/packages/corelib/src/dataModel/ExpectedPackages.ts index 404fd2bfdd..18a588684f 100644 --- a/packages/corelib/src/dataModel/ExpectedPackages.ts +++ b/packages/corelib/src/dataModel/ExpectedPackages.ts @@ -32,6 +32,7 @@ export type ExpectedPackageFromRundownBaseline = | ExpectedPackageDBFromBaselineAdLibAction | ExpectedPackageDBFromBaselineAdLibPiece | ExpectedPackageDBFromRundownBaselineObjects + | ExpectedPackageDBFromBaselinePiece export type ExpectedPackageDBFromBucket = ExpectedPackageDBFromBucketAdLib | ExpectedPackageDBFromBucketAdLibAction @@ -47,6 +48,7 @@ export enum ExpectedPackageDBType { ADLIB_ACTION = 'adlib_action', BASELINE_ADLIB_PIECE = 'baseline_adlib_piece', BASELINE_ADLIB_ACTION = 'baseline_adlib_action', + BASELINE_PIECE = 'baseline_piece', BUCKET_ADLIB = 'bucket_adlib', BUCKET_ADLIB_ACTION = 'bucket_adlib_action', RUNDOWN_BASELINE_OBJECTS = 'rundown_baseline_objects', @@ -79,6 +81,13 @@ export interface ExpectedPackageDBFromPiece extends ExpectedPackageDBBase { /** The rundown of the Piece this package belongs to */ rundownId: RundownId } +export interface ExpectedPackageDBFromBaselinePiece extends ExpectedPackageDBBase { + fromPieceType: ExpectedPackageDBType.BASELINE_PIECE + /** The Piece this package belongs to */ + pieceId: PieceId + /** The rundown of the Piece this package belongs to */ + rundownId: RundownId +} export interface ExpectedPackageDBFromBaselineAdLibPiece extends ExpectedPackageDBBase { fromPieceType: ExpectedPackageDBType.BASELINE_ADLIB_PIECE diff --git a/packages/corelib/src/dataModel/Piece.ts b/packages/corelib/src/dataModel/Piece.ts index 17a864341b..00bc148f9a 100644 --- a/packages/corelib/src/dataModel/Piece.ts +++ b/packages/corelib/src/dataModel/Piece.ts @@ -53,6 +53,15 @@ export interface PieceGeneric extends Omit { export interface Piece extends PieceGeneric, Omit { + /** Timeline enabler. When the piece should be active on the timeline. */ + enable: { + start: number | 'now' // TODO - now will be removed from this eventually, but as it is not an acceptable value 99% of the time, that is not really breaking + duration?: number + + // Pieces owned by the Rundown should always be absolute + isAbsolute?: boolean + } + /** * This is the id of the rundown this piece starts playing in. * Currently this is the only rundown the piece could be playing in @@ -62,12 +71,12 @@ export interface Piece * This is the id of the segment this piece starts playing in. * It is the only segment the piece could be playing in, unless the piece has a lifespan which spans beyond the segment */ - startSegmentId: SegmentId + startSegmentId: SegmentId | null /** * This is the id of the part this piece starts playing in. * If the lifespan is WithinPart, it is the only part the piece could be playing in. */ - startPartId: PartId + startPartId: PartId | null /** Whether this piece is a special piece */ pieceType: IBlueprintPieceType diff --git a/packages/corelib/src/dataModel/PieceInstance.ts b/packages/corelib/src/dataModel/PieceInstance.ts index fc8f3f9db8..c18c93861b 100644 --- a/packages/corelib/src/dataModel/PieceInstance.ts +++ b/packages/corelib/src/dataModel/PieceInstance.ts @@ -34,7 +34,7 @@ export interface PieceInstance { _id: PieceInstanceId /** The rundown this piece belongs to */ rundownId: RundownId - /** The part instace this piece belongs to */ + /** The part instance this piece belongs to. */ partInstanceId: PartInstanceId /** Whether this PieceInstance is a temprorary wrapping of a Piece */ diff --git a/packages/corelib/src/playout/__tests__/processAndPrune.test.ts b/packages/corelib/src/playout/__tests__/processAndPrune.test.ts index 53ad35ab02..718dfd633b 100644 --- a/packages/corelib/src/playout/__tests__/processAndPrune.test.ts +++ b/packages/corelib/src/playout/__tests__/processAndPrune.test.ts @@ -5,6 +5,8 @@ import { PieceInstance, PieceInstancePiece, ResolvedPieceInstance } from '../../ import { literal } from '../../lib' import { protectString } from '../../protectedString' import { + createPartCurrentTimes, + PartCurrentTimes, PieceInstanceWithTimings, processAndPrunePieceInstanceTimings, resolvePrunedPieceInstance, @@ -44,7 +46,7 @@ describe('processAndPrunePieceInstanceTimings', () => { }) } - function runAndTidyResult(pieceInstances: PieceInstance[], nowInPart: number, includeVirtual?: boolean) { + function runAndTidyResult(pieceInstances: PieceInstance[], partTimes: PartCurrentTimes, includeVirtual?: boolean) { const resolvedInstances = processAndPrunePieceInstanceTimings( { one: { @@ -61,7 +63,7 @@ describe('processAndPrunePieceInstanceTimings', () => { }, }, pieceInstances, - nowInPart, + partTimes, undefined, includeVirtual ) @@ -79,7 +81,7 @@ describe('processAndPrunePieceInstanceTimings', () => { createPieceInstance('two', { start: 1000 }, 'two', PieceLifespan.OutOnRundownEnd), ] - const resolvedInstances = runAndTidyResult(pieceInstances, 500) + const resolvedInstances = runAndTidyResult(pieceInstances, createPartCurrentTimes(500, 0)) expect(resolvedInstances).toEqual([ { _id: 'one', @@ -101,7 +103,7 @@ describe('processAndPrunePieceInstanceTimings', () => { createPieceInstance('two', { start: 1000, duration: 5000 }, 'one', PieceLifespan.OutOnRundownEnd), ] - const resolvedInstances = runAndTidyResult(pieceInstances, 500) + const resolvedInstances = runAndTidyResult(pieceInstances, createPartCurrentTimes(500, 0)) expect(resolvedInstances).toEqual([ { _id: 'one', @@ -127,7 +129,7 @@ describe('processAndPrunePieceInstanceTimings', () => { createPieceInstance('five', { start: 4000 }, 'one', PieceLifespan.OutOnShowStyleEnd), ] - const resolvedInstances = runAndTidyResult(pieceInstances, 500) + const resolvedInstances = runAndTidyResult(pieceInstances, createPartCurrentTimes(500, 0)) expect(resolvedInstances).toEqual([ { _id: 'zero', @@ -177,7 +179,7 @@ describe('processAndPrunePieceInstanceTimings', () => { createPieceInstance('zero', { start: 6000 }, 'one', PieceLifespan.OutOnShowStyleEnd, true), ] - const resolvedInstances = runAndTidyResult(pieceInstances, 500) + const resolvedInstances = runAndTidyResult(pieceInstances, createPartCurrentTimes(500, 0)) expect(resolvedInstances).toEqual([ { _id: 'zero', @@ -209,7 +211,7 @@ describe('processAndPrunePieceInstanceTimings', () => { createPieceInstance('five', { start: 6000 }, 'one', PieceLifespan.OutOnShowStyleEnd, true), ] - const resolvedInstances = runAndTidyResult(pieceInstances, 500, true) + const resolvedInstances = runAndTidyResult(pieceInstances, createPartCurrentTimes(500, 0), true) expect(resolvedInstances).toEqual([ { _id: 'zero', @@ -259,7 +261,7 @@ describe('processAndPrunePieceInstanceTimings', () => { createPieceInstance('five', { start: 6000 }, 'one', PieceLifespan.OutOnShowStyleEnd), ] - const resolvedInstances = runAndTidyResult(pieceInstances, 500) + const resolvedInstances = runAndTidyResult(pieceInstances, createPartCurrentTimes(500, 0)) expect(resolvedInstances).toEqual([ { _id: 'zero', @@ -305,7 +307,7 @@ describe('processAndPrunePieceInstanceTimings', () => { createPieceInstance('two', { start: 1000 }, 'one', PieceLifespan.OutOnSegmentEnd, 5500), ] - const resolvedInstances = runAndTidyResult(pieceInstances, 500) + const resolvedInstances = runAndTidyResult(pieceInstances, createPartCurrentTimes(500, 0)) expect(resolvedInstances).toEqual([ { _id: 'one', @@ -323,7 +325,7 @@ describe('processAndPrunePieceInstanceTimings', () => { createPieceInstance('four', { start: 1000 }, 'one', PieceLifespan.OutOnRundownChange, 4000), ] - const resolvedInstances = runAndTidyResult(pieceInstances, 500) + const resolvedInstances = runAndTidyResult(pieceInstances, createPartCurrentTimes(500, 0)) expect(resolvedInstances).toEqual([ { _id: 'three', @@ -339,7 +341,7 @@ describe('processAndPrunePieceInstanceTimings', () => { createPieceInstance('two', { start: 1000 }, 'one', PieceLifespan.OutOnShowStyleEnd, 5500), ] - const resolvedInstances = runAndTidyResult(pieceInstances, 500) + const resolvedInstances = runAndTidyResult(pieceInstances, createPartCurrentTimes(500, 0)) expect(resolvedInstances).toEqual([ { _id: 'one', @@ -366,7 +368,7 @@ describe('processAndPrunePieceInstanceTimings', () => { }), ] - const resolvedInstances = runAndTidyResult(pieceInstances, 500) + const resolvedInstances = runAndTidyResult(pieceInstances, createPartCurrentTimes(500, 0)) expect(resolvedInstances).toEqual([ { _id: 'one', @@ -399,7 +401,7 @@ describe('processAndPrunePieceInstanceTimings', () => { }), ] - const resolvedInstances = runAndTidyResult(pieceInstances, 500) + const resolvedInstances = runAndTidyResult(pieceInstances, createPartCurrentTimes(500, 0)) expect(resolvedInstances).toEqual([ { _id: 'two', @@ -427,7 +429,7 @@ describe('processAndPrunePieceInstanceTimings', () => { pieceInstances[1].piece.virtual = true - const resolvedInstances = runAndTidyResult(pieceInstances, 500) + const resolvedInstances = runAndTidyResult(pieceInstances, createPartCurrentTimes(500, 0)) // don't expect virtual Pieces in the results, but 'one' should be pruned too expect(resolvedInstances).toEqual([]) @@ -457,7 +459,7 @@ describe('processAndPrunePieceInstanceTimings', () => { pieceInstances[0].piece.prerollDuration = 200 pieceInstances[1].piece.prerollDuration = 200 - const resolvedInstances = runAndTidyResult(pieceInstances, 500) + const resolvedInstances = runAndTidyResult(pieceInstances, createPartCurrentTimes(500, 0)) expect(resolvedInstances).toEqual([ { @@ -468,6 +470,100 @@ describe('processAndPrunePieceInstanceTimings', () => { }, ]) }) + + describe('absolute timed (rundown owned) pieces', () => { + test('simple collision', () => { + const now = 9000 + const partStart = 8000 + + const pieceInstances = [ + createPieceInstance('one', { start: 0 }, 'one', PieceLifespan.OutOnRundownChange), + createPieceInstance( + 'two', + { start: now + 2000, isAbsolute: true }, + 'one', + PieceLifespan.OutOnRundownChange + ), + createPieceInstance('three', { start: 6000 }, 'one', PieceLifespan.OutOnRundownChange), + ] + + const resolvedInstances = runAndTidyResult(pieceInstances, createPartCurrentTimes(now, partStart)) + expect(resolvedInstances).toEqual([ + { + _id: 'one', + priority: 5, + start: 0, + end: 3000, + }, + { + _id: 'two', + priority: 5, + start: partStart + 3000, + end: partStart + 6000, + }, + { + _id: 'three', + priority: 5, + start: 6000, + end: undefined, + }, + ]) + }) + + test('collision with same start time', () => { + const now = 9000 + const partStart = 8000 + + const pieceInstances = [ + createPieceInstance('one', { start: 0 }, 'one', PieceLifespan.OutOnRundownChange), + createPieceInstance( + 'two', + { start: partStart + 2000, isAbsolute: true }, + 'one', + PieceLifespan.OutOnRundownChange + ), + createPieceInstance('three', { start: 2000 }, 'one', PieceLifespan.OutOnRundownChange), + ] + + const resolvedInstances = runAndTidyResult(pieceInstances, createPartCurrentTimes(now, partStart)) + expect(resolvedInstances).toEqual([ + { + _id: 'one', + priority: 5, + start: 0, + end: 2000, + }, + { + _id: 'two', + priority: 5, + start: partStart + 2000, + end: undefined, + }, + ]) + + { + // check stability + pieceInstances[1].piece.enable = { start: 2000 } + pieceInstances[2].piece.enable = { start: partStart + 2000, isAbsolute: true } + + const resolvedInstances = runAndTidyResult(pieceInstances, createPartCurrentTimes(now, partStart)) + expect(resolvedInstances).toEqual([ + { + _id: 'one', + priority: 5, + start: 0, + end: 2000, + }, + { + _id: 'three', + priority: 5, + start: partStart + 2000, + end: undefined, + }, + ]) + } + }) + }) }) describe('resolvePrunedPieceInstances', () => { @@ -503,10 +599,10 @@ describe('resolvePrunedPieceInstances', () => { } test('numeric start, no duration', async () => { - const nowInPart = 123 + const partTimes = createPartCurrentTimes(123, 0) const piece = createPieceInstance({ start: 2000 }) - expect(resolvePrunedPieceInstance(nowInPart, clone(piece))).toStrictEqual({ + expect(resolvePrunedPieceInstance(partTimes, clone(piece))).toStrictEqual({ instance: clone(piece), timelinePriority: piece.priority, resolvedStart: 2000, @@ -515,10 +611,10 @@ describe('resolvePrunedPieceInstances', () => { }) test('numeric start, with planned duration', async () => { - const nowInPart = 123 + const partTimes = createPartCurrentTimes(123, 0) const piece = createPieceInstance({ start: 2000, duration: 3400 }) - expect(resolvePrunedPieceInstance(nowInPart, clone(piece))).toStrictEqual({ + expect(resolvePrunedPieceInstance(partTimes, clone(piece))).toStrictEqual({ instance: clone(piece), timelinePriority: piece.priority, resolvedStart: 2000, @@ -527,127 +623,127 @@ describe('resolvePrunedPieceInstances', () => { }) test('now start, no duration', async () => { - const nowInPart = 123 + const partTimes = createPartCurrentTimes(123, 0) const piece = createPieceInstance({ start: 'now' }) - expect(resolvePrunedPieceInstance(nowInPart, clone(piece))).toStrictEqual({ + expect(resolvePrunedPieceInstance(partTimes, clone(piece))).toStrictEqual({ instance: clone(piece), timelinePriority: piece.priority, - resolvedStart: nowInPart, + resolvedStart: partTimes.nowInPart, resolvedDuration: undefined, } satisfies ResolvedPieceInstance) }) test('now start, with planned duration', async () => { - const nowInPart = 123 + const partTimes = createPartCurrentTimes(123, 0) const piece = createPieceInstance({ start: 'now', duration: 3400 }) - expect(resolvePrunedPieceInstance(nowInPart, clone(piece))).toStrictEqual({ + expect(resolvePrunedPieceInstance(partTimes, clone(piece))).toStrictEqual({ instance: clone(piece), timelinePriority: piece.priority, - resolvedStart: nowInPart, + resolvedStart: partTimes.nowInPart, resolvedDuration: 3400, } satisfies ResolvedPieceInstance) }) test('now start, with end cap', async () => { - const nowInPart = 123 + const partTimes = createPartCurrentTimes(123, 0) const piece = createPieceInstance({ start: 'now' }, 5000) - expect(resolvePrunedPieceInstance(nowInPart, clone(piece))).toStrictEqual({ + expect(resolvePrunedPieceInstance(partTimes, clone(piece))).toStrictEqual({ instance: clone(piece), timelinePriority: piece.priority, - resolvedStart: nowInPart, - resolvedDuration: 5000 - nowInPart, + resolvedStart: partTimes.nowInPart, + resolvedDuration: 5000 - partTimes.nowInPart, } satisfies ResolvedPieceInstance) }) test('now start, with end cap and longer planned duration', async () => { - const nowInPart = 123 + const partTimes = createPartCurrentTimes(123, 0) const piece = createPieceInstance({ start: 'now', duration: 6000 }, 5000) - expect(resolvePrunedPieceInstance(nowInPart, clone(piece))).toStrictEqual({ + expect(resolvePrunedPieceInstance(partTimes, clone(piece))).toStrictEqual({ instance: clone(piece), timelinePriority: piece.priority, - resolvedStart: nowInPart, - resolvedDuration: 5000 - nowInPart, + resolvedStart: partTimes.nowInPart, + resolvedDuration: 5000 - partTimes.nowInPart, } satisfies ResolvedPieceInstance) }) test('now start, with end cap and shorter planned duration', async () => { - const nowInPart = 123 + const partTimes = createPartCurrentTimes(123, 0) const piece = createPieceInstance({ start: 'now', duration: 3000 }, 5000) - expect(resolvePrunedPieceInstance(nowInPart, clone(piece))).toStrictEqual({ + expect(resolvePrunedPieceInstance(partTimes, clone(piece))).toStrictEqual({ instance: clone(piece), timelinePriority: piece.priority, - resolvedStart: nowInPart, + resolvedStart: partTimes.nowInPart, resolvedDuration: 3000, } satisfies ResolvedPieceInstance) }) test('now start, with userDuration.endRelativeToPart', async () => { - const nowInPart = 123 + const partTimes = createPartCurrentTimes(123, 0) const piece = createPieceInstance({ start: 'now' }, undefined, { endRelativeToPart: 4000, }) - expect(resolvePrunedPieceInstance(nowInPart, clone(piece))).toStrictEqual({ + expect(resolvePrunedPieceInstance(partTimes, clone(piece))).toStrictEqual({ instance: clone(piece), timelinePriority: piece.priority, - resolvedStart: nowInPart, - resolvedDuration: 4000 - nowInPart, + resolvedStart: partTimes.nowInPart, + resolvedDuration: 4000 - partTimes.nowInPart, } satisfies ResolvedPieceInstance) }) test('numeric start, with userDuration.endRelativeToNow', async () => { - const nowInPart = 123 + const partTimes = createPartCurrentTimes(123, 0) const piece = createPieceInstance({ start: 500 }, undefined, { endRelativeToNow: 4000, }) - expect(resolvePrunedPieceInstance(nowInPart, clone(piece))).toStrictEqual({ + expect(resolvePrunedPieceInstance(partTimes, clone(piece))).toStrictEqual({ instance: clone(piece), timelinePriority: piece.priority, resolvedStart: 500, - resolvedDuration: 4000 - 500 + nowInPart, + resolvedDuration: 4000 - 500 + partTimes.nowInPart, } satisfies ResolvedPieceInstance) }) test('now start, with userDuration.endRelativeToNow', async () => { - const nowInPart = 123 + const partTimes = createPartCurrentTimes(123, 0) const piece = createPieceInstance({ start: 'now' }, undefined, { endRelativeToNow: 4000, }) - expect(resolvePrunedPieceInstance(nowInPart, clone(piece))).toStrictEqual({ + expect(resolvePrunedPieceInstance(partTimes, clone(piece))).toStrictEqual({ instance: clone(piece), timelinePriority: piece.priority, - resolvedStart: nowInPart, + resolvedStart: partTimes.nowInPart, resolvedDuration: 4000, } satisfies ResolvedPieceInstance) }) test('now start, with end cap, planned duration and userDuration.endRelativeToPart', async () => { - const nowInPart = 123 + const partTimes = createPartCurrentTimes(123, 0) const piece = createPieceInstance({ start: 'now', duration: 3000 }, 5000, { endRelativeToPart: 2800 }) - expect(resolvePrunedPieceInstance(nowInPart, clone(piece))).toStrictEqual({ + expect(resolvePrunedPieceInstance(partTimes, clone(piece))).toStrictEqual({ instance: clone(piece), timelinePriority: piece.priority, - resolvedStart: nowInPart, - resolvedDuration: 2800 - nowInPart, + resolvedStart: partTimes.nowInPart, + resolvedDuration: 2800 - partTimes.nowInPart, } satisfies ResolvedPieceInstance) }) test('now start, with end cap, planned duration and userDuration.endRelativeToNow', async () => { - const nowInPart = 123 + const partTimes = createPartCurrentTimes(123, 0) const piece = createPieceInstance({ start: 'now', duration: 3000 }, 5000, { endRelativeToNow: 2800 }) - expect(resolvePrunedPieceInstance(nowInPart, clone(piece))).toStrictEqual({ + expect(resolvePrunedPieceInstance(partTimes, clone(piece))).toStrictEqual({ instance: clone(piece), timelinePriority: piece.priority, - resolvedStart: nowInPart, + resolvedStart: partTimes.nowInPart, resolvedDuration: 2800, } satisfies ResolvedPieceInstance) }) diff --git a/packages/corelib/src/playout/infinites.ts b/packages/corelib/src/playout/infinites.ts index eac8b92f5a..d0a14cd259 100644 --- a/packages/corelib/src/playout/infinites.ts +++ b/packages/corelib/src/playout/infinites.ts @@ -204,6 +204,7 @@ export function getPlayheadTrackingInfinitesForPart( case PieceLifespan.OutOnSegmentEnd: isValid = currentPartInstance.segmentId === intoPart.segmentId && + !!candidatePiece.piece.startPartId && partsToReceiveOnSegmentEndFromSet.has(candidatePiece.piece.startPartId) break case PieceLifespan.OutOnRundownEnd: @@ -238,13 +239,16 @@ export function getPlayheadTrackingInfinitesForPart( markPieceInstanceAsContinuation(p, instance) if (p.infinite) { - // This was copied from before, so we know we can force the time to 0 - instance.piece = { - ...instance.piece, - enable: { - start: 0, - }, + if (!instance.piece.enable.isAbsolute) { + // This was copied from before, so we know we can force the time to 0 + instance.piece = { + ...instance.piece, + enable: { + start: 0, + }, + } } + instance.infinite = { ...p.infinite, infiniteInstanceIndex: p.infinite.infiniteInstanceIndex + 1, @@ -294,11 +298,16 @@ export function isPiecePotentiallyActiveInPart( return false case PieceLifespan.OutOnSegmentEnd: return ( + !!pieceToCheck.startPartId && pieceToCheck.startSegmentId === part.segmentId && partsToReceiveOnSegmentEndFrom.has(pieceToCheck.startPartId) ) case PieceLifespan.OutOnRundownEnd: - if (pieceToCheck.startRundownId === part.rundownId) { + if ( + pieceToCheck.startRundownId === part.rundownId && + pieceToCheck.startPartId && + pieceToCheck.startSegmentId + ) { if (pieceToCheck.startSegmentId === part.segmentId) { return partsToReceiveOnSegmentEndFrom.has(pieceToCheck.startPartId) } else { @@ -315,6 +324,7 @@ export function isPiecePotentiallyActiveInPart( } else { // Predicting what will happen at arbitrary point in the future return ( + !!pieceToCheck.startPartId && pieceToCheck.startSegmentId === part.segmentId && partsToReceiveOnSegmentEndFrom.has(pieceToCheck.startPartId) ) @@ -327,6 +337,7 @@ export function isPiecePotentiallyActiveInPart( } else { // Predicting what will happen at arbitrary point in the future return ( + !!pieceToCheck.startSegmentId && pieceToCheck.startRundownId === part.rundownId && segmentsToReceiveOnRundownEndFrom.has(pieceToCheck.startSegmentId) ) @@ -389,8 +400,8 @@ export function getPieceInstancesForPart( if (pieceA.startPartId === pieceB.startPartId) { return pieceA.enable.start < pieceB.enable.start } - const pieceAIndex = orderedPartIds.indexOf(pieceA.startPartId) - const pieceBIndex = orderedPartIds.indexOf(pieceB.startPartId) + const pieceAIndex = pieceA.startPartId === null ? -2 : orderedPartIds.indexOf(pieceA.startPartId) + const pieceBIndex = pieceB.startPartId === null ? -2 : orderedPartIds.indexOf(pieceB.startPartId) if (pieceAIndex === -1) { return false @@ -528,6 +539,16 @@ export function isCandidateMoreImportant( best: ReadonlyDeep, candidate: ReadonlyDeep ): boolean | undefined { + // If one is absolute timed, prefer that + if (best.piece.enable.isAbsolute && !candidate.piece.enable.isAbsolute) { + // Prefer the absolute best + return false + } + if (!best.piece.enable.isAbsolute && candidate.piece.enable.isAbsolute) { + // Prefer the absolute candidate + return true + } + // Prioritise the one from this part over previous part if (best.infinite?.fromPreviousPart && !candidate.infinite?.fromPreviousPart) { // Prefer the candidate as it is not from previous diff --git a/packages/corelib/src/playout/processAndPrune.ts b/packages/corelib/src/playout/processAndPrune.ts index f7269c086f..d02cedde45 100644 --- a/packages/corelib/src/playout/processAndPrune.ts +++ b/packages/corelib/src/playout/processAndPrune.ts @@ -10,19 +10,48 @@ import { ReadonlyDeep } from 'type-fest' /** * Get the `enable: { start: ?? }` for the new piece in terms that can be used as an `end` for another object */ -function getPieceStartTimeAsReference(newPieceStart: number | 'now'): number | RelativeResolvedEndCap { - return typeof newPieceStart === 'number' ? newPieceStart : { offsetFromNow: 0 } +function getPieceStartTimeAsReference( + newPieceStart: number | 'now', + partTimes: PartCurrentTimes, + pieceToAffect: ReadonlyDeep +): number | RelativeResolvedEndCap { + if (typeof newPieceStart !== 'number') return { offsetFromNow: 0 } + + if (pieceToAffect.piece.enable.isAbsolute) { + // If the piece is absolute timed, then the end needs to be adjusted to be absolute + if (pieceToAffect.piece.enable.start === 'now') { + return { offsetFromNow: newPieceStart } + } else { + // Translate to an absolute timestamp + return partTimes.currentTime - partTimes.nowInPart + newPieceStart + } + } + + return newPieceStart } -function getPieceStartTimeWithinPart(p: ReadonlyDeep): 'now' | number { +function getPieceStartTimeWithinPart(p: ReadonlyDeep, partTimes: PartCurrentTimes): 'now' | number { + const pieceEnable = p.piece.enable + if (pieceEnable.isAbsolute) { + // Note: these can't be adlibbed, so we don't need to consider adding the preroll + + if (pieceEnable.start === 'now') { + // Should never happen, but just in case + return pieceEnable.start + } else { + // Translate this to the part + return pieceEnable.start - partTimes.currentTime + partTimes.nowInPart + } + } + // If the piece is dynamically inserted, then its preroll should be factored into its start time, but not for any infinite continuations const isStartOfAdlib = !!p.dynamicallyInserted && !(p.infinite?.fromPreviousPart || p.infinite?.fromPreviousPlayhead) - if (isStartOfAdlib && p.piece.enable.start !== 'now') { - return p.piece.enable.start + (p.piece.prerollDuration ?? 0) + if (isStartOfAdlib && pieceEnable.start !== 'now') { + return pieceEnable.start + (p.piece.prerollDuration ?? 0) } else { - return p.piece.enable.start + return pieceEnable.start } } @@ -59,24 +88,43 @@ export interface PieceInstanceWithTimings extends ReadonlyDeep { * This is a maximum end point of the pieceInstance. * If the pieceInstance also has a enable.duration or userDuration set then the shortest one will need to be used * This can be: - * - 'now', if it was stopped by something that does not need a preroll (or is virtual) - * - '#something.start + 100', if it was stopped by something that needs a preroll - * - '100', if not relative to now at all + * - '100', if relative to the start of the part + * - { offsetFromNow: 100 }, if stopped by an absolute time */ resolvedEndCap?: number | RelativeResolvedEndCap priority: number } +export interface PartCurrentTimes { + /** The current time when this was sampled */ + readonly currentTime: number + /** The time the part started playback, if it has begun */ + readonly partStartTime: number | null + /** An approximate current time within the part */ + readonly nowInPart: number +} + +export function createPartCurrentTimes( + currentTime: number, + partStartTime: number | undefined | null +): PartCurrentTimes { + return { + currentTime, + partStartTime: partStartTime ?? null, + nowInPart: typeof partStartTime === 'number' ? currentTime - partStartTime : 0, + } +} + /** * Process the infinite pieces to determine the start time and a maximum end time for each. * Any pieces which have no chance of being shown (duplicate start times) are pruned * The stacking order of infinites is considered, to define the stop times - * Note: `nowInPart` is only needed to order the PieceInstances. The result of this can be cached until that order changes + * Note: `nowInPart` is only needed to order the PieceInstances. The result of this can be cached until that order changes. */ export function processAndPrunePieceInstanceTimings( sourceLayers: SourceLayers, pieces: ReadonlyDeep, - nowInPart: number, + partTimes: PartCurrentTimes, keepDisabledPieces?: boolean, includeVirtual?: boolean ): PieceInstanceWithTimings[] { @@ -90,7 +138,7 @@ export function processAndPrunePieceInstanceTimings( } } - const groupedPieces = groupByToMapFunc( + const piecesGroupedByExclusiveGroupOrLayer = groupByToMapFunc( keepDisabledPieces ? pieces : pieces.filter((p) => !p.disabled), // At this stage, if a Piece is disabled, the `keepDisabledPieces` must be turned on. If that's the case // we split out the disabled Pieces onto the sourceLayerId they actually exist on, instead of putting them @@ -99,13 +147,16 @@ export function processAndPrunePieceInstanceTimings( (p) => p.disabled ? p.piece.sourceLayerId : exclusiveGroupMap.get(p.piece.sourceLayerId) || p.piece.sourceLayerId ) - for (const pieces of groupedPieces.values()) { - // Group and sort the pieces so that we can step through each point in time + for (const piecesInExclusiveGroupOrLayer of piecesGroupedByExclusiveGroupOrLayer.values()) { + // Group and sort the pieces so that we can step through each point in time in order + const piecesByStartMap = groupByToMapFunc(piecesInExclusiveGroupOrLayer, (p) => + getPieceStartTimeWithinPart(p, partTimes) + ) const piecesByStart: Array<[number | 'now', ReadonlyDeep]> = _.sortBy( - Array.from(groupByToMapFunc(pieces, (p) => getPieceStartTimeWithinPart(p)).entries()).map(([k, v]) => + Array.from(piecesByStartMap.entries()).map(([k, v]) => literal<[number | 'now', ReadonlyDeep]>([k === 'now' ? 'now' : Number(k), v]) ), - ([k]) => (k === 'now' ? nowInPart : k) + ([k]) => (k === 'now' ? partTimes.nowInPart : k) ) // Step through time @@ -115,10 +166,34 @@ export function processAndPrunePieceInstanceTimings( // Apply the updates // Note: order is important, the higher layers must be done first - updateWithNewPieces(results, activePieces, newPieces, newPiecesStart, includeVirtual, 'other') - updateWithNewPieces(results, activePieces, newPieces, newPiecesStart, includeVirtual, 'onSegmentEnd') - updateWithNewPieces(results, activePieces, newPieces, newPiecesStart, includeVirtual, 'onRundownEnd') - updateWithNewPieces(results, activePieces, newPieces, newPiecesStart, includeVirtual, 'onShowStyleEnd') + updateWithNewPieces(results, partTimes, activePieces, newPieces, newPiecesStart, includeVirtual, 'other') + updateWithNewPieces( + results, + partTimes, + activePieces, + newPieces, + newPiecesStart, + includeVirtual, + 'onSegmentEnd' + ) + updateWithNewPieces( + results, + partTimes, + activePieces, + newPieces, + newPiecesStart, + includeVirtual, + 'onRundownEnd' + ) + updateWithNewPieces( + results, + partTimes, + activePieces, + newPieces, + newPiecesStart, + includeVirtual, + 'onShowStyleEnd' + ) } } @@ -127,6 +202,7 @@ export function processAndPrunePieceInstanceTimings( } function updateWithNewPieces( results: PieceInstanceWithTimings[], + partTimes: PartCurrentTimes, activePieces: PieceInstanceOnInfiniteLayers, newPieces: PieceInstanceOnInfiniteLayers, newPiecesStart: number | 'now', @@ -137,7 +213,7 @@ function updateWithNewPieces( if (newPiece) { const activePiece = activePieces[key] if (activePiece) { - activePiece.resolvedEndCap = getPieceStartTimeAsReference(newPiecesStart) + activePiece.resolvedEndCap = getPieceStartTimeAsReference(newPiecesStart, partTimes, activePiece) } // track the new piece activePieces[key] = newPiece @@ -162,7 +238,11 @@ function updateWithNewPieces( (newPiecesStart !== 0 || isCandidateBetterToBeContinued(activePieces.other, newPiece)) ) { // These modes should stop the 'other' when they start if not hidden behind a higher priority onEnd - activePieces.other.resolvedEndCap = getPieceStartTimeAsReference(newPiecesStart) + activePieces.other.resolvedEndCap = getPieceStartTimeAsReference( + newPiecesStart, + partTimes, + activePieces.other + ) activePieces.other = undefined } } @@ -229,21 +309,25 @@ function findPieceInstancesOnInfiniteLayers(pieces: ReadonlyDeep ShowStyleBlueprintManifest = () => ({ rundown, globalAdLibPieces: [], globalActions: [], + globalPieces: [], baseline: { timelineObjects: [] }, } }, diff --git a/packages/job-worker/src/blueprints/__tests__/context-events.test.ts b/packages/job-worker/src/blueprints/__tests__/context-events.test.ts index ee51108643..77a7238e73 100644 --- a/packages/job-worker/src/blueprints/__tests__/context-events.test.ts +++ b/packages/job-worker/src/blueprints/__tests__/context-events.test.ts @@ -273,6 +273,7 @@ describe('Test blueprint api context', () => { rundownId, })) as PieceInstance expect(pieceInstance).toBeTruthy() + expect(pieceInstance.partInstanceId).toBe(partInstance._id) // Check what was generated const context = await getContext(rundown, undefined, partInstance, undefined) diff --git a/packages/job-worker/src/blueprints/context/lib.ts b/packages/job-worker/src/blueprints/context/lib.ts index 8d3c68708d..2ee739f0c9 100644 --- a/packages/job-worker/src/blueprints/context/lib.ts +++ b/packages/job-worker/src/blueprints/context/lib.ts @@ -2,7 +2,11 @@ import { AdLibAction } from '@sofie-automation/corelib/dist/dataModel/AdlibActio import { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' -import { deserializePieceTimelineObjectsBlob, PieceGeneric } from '@sofie-automation/corelib/dist/dataModel/Piece' +import { + deserializePieceTimelineObjectsBlob, + Piece, + PieceGeneric, +} from '@sofie-automation/corelib/dist/dataModel/Piece' import { PieceInstance, PieceInstancePiece, @@ -39,6 +43,7 @@ import { IBlueprintPieceInstance, IBlueprintResolvedPieceInstance, IBlueprintRundownDB, + IBlueprintRundownPieceDB, IBlueprintRundownPlaylist, IBlueprintSegmentDB, IBlueprintSegmentRundown, @@ -253,6 +258,27 @@ export function convertPieceToBlueprints(piece: ReadonlyDeep return obj } +/** + * Convert a Rundown owned Piece into IBlueprintAdLibPieceDB, for passing into the blueprints + * Note: This does not check whether has the correct ownership + * @param piece the Piece to convert + * @returns a cloned complete and clean IBlueprintRundownPieceDB + */ +export function convertRundownPieceToBlueprints(piece: ReadonlyDeep): IBlueprintRundownPieceDB { + const obj: Complete = { + ...convertPieceGenericToBlueprintsInner(piece), + _id: unprotectString(piece._id), + enable: { + ...piece.enable, + start: piece.enable.start === 'now' ? 0 : piece.enable.start, + isAbsolute: true, + }, + virtual: piece.virtual, + notInVision: piece.notInVision, + } + return obj +} + /** * Convert a DBPart into IBlueprintPartDB, for passing into the blueprints * @param part the Part to convert diff --git a/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts b/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts index 3004f27e41..8f35761f0b 100644 --- a/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts +++ b/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts @@ -255,6 +255,9 @@ export class PartAndPieceInstanceActionService { }) if (!pieceDB) throw new Error(`Cannot find Piece ${piece._id}`) + if (!pieceDB.startPartId || !pieceDB.startSegmentId) + throw new Error(`Piece ${piece._id} does not belong to a part`) + const rundown = this._playoutModel.getRundown(pieceDB.startRundownId) const segment = rundown?.getSegment(pieceDB.startSegmentId) const part = segment?.getPart(pieceDB.startPartId) @@ -535,6 +538,7 @@ export async function applyActionSideEffects( await syncPlayheadInfinitesForNextPartInstance( context, playoutModel, + undefined, playoutModel.currentPartInstance, playoutModel.nextPartInstance ) 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 0ac9ee361c..8700cf479c 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 @@ -57,6 +57,7 @@ import { postProcessPieces, postProcessTimelineObjects } from '../../../postProc import { ActionPartChange, PartAndPieceInstanceActionService } from '../PartAndPieceInstanceActionService' import { mock } from 'jest-mock-extended' import { QuickLoopService } from '../../../../playout/model/services/QuickLoopService' +import { SelectedPartInstance } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' const { postProcessPieces: postProcessPiecesOrig, postProcessTimelineObjects: postProcessTimelineObjectsOrig } = jest.requireActual('../../../postProcess') @@ -233,7 +234,9 @@ describe('Test blueprint api context', () => { nextPartInstance: PlayoutPartInstanceModel | DBPartInstance | PieceInstance | undefined | null, previousPartInstance?: PlayoutPartInstanceModel | DBPartInstance | PieceInstance | null ) { - const convertInfo = (info: PlayoutPartInstanceModel | DBPartInstance | PieceInstance | null) => { + const convertInfo = ( + info: PlayoutPartInstanceModel | DBPartInstance | PieceInstance | null + ): SelectedPartInstance | null => { if (!info) { return null } else if ('partInstanceId' in info) { diff --git a/packages/job-worker/src/blueprints/postProcess.ts b/packages/job-worker/src/blueprints/postProcess.ts index 90afdb2fc9..483a52f2a7 100644 --- a/packages/job-worker/src/blueprints/postProcess.ts +++ b/packages/job-worker/src/blueprints/postProcess.ts @@ -13,6 +13,7 @@ import { PieceLifespan, IBlueprintPieceType, ITranslatableMessage, + IBlueprintRundownPiece, } from '@sofie-automation/blueprints-integration' import { AdLibActionId, @@ -357,6 +358,85 @@ export function postProcessAdLibActions( }) } +/** + * Process and validate some IBlueprintRundownPiece into Piece + * @param context Context from the job queue + * @param pieces IBlueprintPiece to process + * @param blueprintId Id of the Blueprint the Pieces are from + * @param rundownId Id of the Rundown the Pieces belong to + * @param setInvalid If true all Pieces will be marked as `invalid`, this should be set to match the owning Part + */ +export function postProcessGlobalPieces( + context: JobContext, + pieces: Array, + blueprintId: BlueprintId, + rundownId: RundownId, + setInvalid?: boolean +): Piece[] { + const span = context.startSpan('blueprints.postProcess.postProcessPieces') + + const uniqueIds = new Map() + const timelineUniqueIds = new Set() + + const processedPieces = pieces.map((orgPiece: IBlueprintRundownPiece) => { + if (!orgPiece.externalId) + throw new Error( + `Error in blueprint "${blueprintId}" externalId not set for rundown piece ("${orgPiece.name}")` + ) + + const docId = getIdHash( + 'Piece', + uniqueIds, + `${rundownId}_${blueprintId}_rundown_piece_${orgPiece.sourceLayerId}_${orgPiece.externalId}` + ) + + const piece: Piece = { + ...orgPiece, + content: omit(orgPiece.content, 'timelineObjects'), + + pieceType: IBlueprintPieceType.Normal, + lifespan: PieceLifespan.OutOnRundownChange, + + _id: protectString(docId), + startRundownId: rundownId, + startSegmentId: null, + startPartId: null, + invalid: setInvalid ?? false, + timelineObjectsString: EmptyPieceTimelineObjectsBlob, + } + + if (piece.pieceType !== IBlueprintPieceType.Normal) { + // transition pieces must not be infinite, lets enforce that + piece.lifespan = PieceLifespan.WithinPart + } + if (piece.extendOnHold) { + // HOLD pieces must not be infinite, as they become that when being held + piece.lifespan = PieceLifespan.WithinPart + } + + if (piece.enable.start === 'now') + throw new Error( + `Error in blueprint "${blueprintId}" rundown piece cannot have a start of 'now'! ("${piece.name}")` + ) + + const timelineObjects = postProcessTimelineObjects( + piece._id, + blueprintId, + orgPiece.content.timelineObjects, + timelineUniqueIds + ) + piece.timelineObjectsString = serializePieceTimelineObjectsBlob(timelineObjects) + + // Fill in ids of unnamed expectedPackages + setDefaultIdOnExpectedPackages(piece.expectedPackages) + + return piece + }) + + span?.end() + return processedPieces +} + /** * Process and validate TSRTimelineObj for the StudioBaseline into TimelineObjRundown * @param blueprintId Id of the Blueprint the TSRTimelineObj are from diff --git a/packages/job-worker/src/ingest/expectedMediaItems.ts b/packages/job-worker/src/ingest/expectedMediaItems.ts index f694ab3bc7..150d63816a 100644 --- a/packages/job-worker/src/ingest/expectedMediaItems.ts +++ b/packages/job-worker/src/ingest/expectedMediaItems.ts @@ -89,7 +89,7 @@ function generateExpectedMediaItemsFull( ...generateExpectedMediaItems( doc._id, { - partId: doc.startPartId, + partId: doc.startPartId ?? undefined, rundownId: doc.startRundownId, }, studioId, @@ -254,7 +254,7 @@ export async function updateExpectedMediaItemsForRundownBaseline( const expectedMediaItems = generateExpectedMediaItemsFull( context.studio._id, ingestModel.rundownId, - [], + ingestModel.getGlobalPieces(), baselineAdlibPieces, baselineAdlibActions ) diff --git a/packages/job-worker/src/ingest/expectedPackages.ts b/packages/job-worker/src/ingest/expectedPackages.ts index b94c6498b9..09fd46ec25 100644 --- a/packages/job-worker/src/ingest/expectedPackages.ts +++ b/packages/job-worker/src/ingest/expectedPackages.ts @@ -148,6 +148,21 @@ export async function updateExpectedPackagesForRundownBaseline( preserveTypesDuringSave.add(ExpectedPackageDBType.RUNDOWN_BASELINE_OBJECTS) } + // Add expected packages for global pieces + for (const piece of ingestModel.getGlobalPieces()) { + if (piece.expectedPackages) { + const bases = generateExpectedPackageBases(context.studio, piece._id, piece.expectedPackages) + for (const base of bases) { + expectedPackages.push({ + ...base, + rundownId: ingestModel.rundownId, + pieceId: piece._id, + fromPieceType: ExpectedPackageDBType.BASELINE_PIECE, + }) + } + } + } + // Preserve anything existing for (const expectedPackage of ingestModel.expectedPackagesForRundownBaseline) { if (preserveTypesDuringSave.has(expectedPackage.fromPieceType)) { diff --git a/packages/job-worker/src/ingest/expectedPlayoutItems.ts b/packages/job-worker/src/ingest/expectedPlayoutItems.ts index 84d5b11a9f..649011b23b 100644 --- a/packages/job-worker/src/ingest/expectedPlayoutItems.ts +++ b/packages/job-worker/src/ingest/expectedPlayoutItems.ts @@ -62,6 +62,9 @@ export async function updateExpectedPlayoutItemsForRundownBaseline( for (const action of baselineAdlibActions) { baselineExpectedPlayoutItems.push(...extractExpectedPlayoutItems(studioId, rundownId, undefined, action)) } + for (const piece of ingestModel.getGlobalPieces()) { + baselineExpectedPlayoutItems.push(...extractExpectedPlayoutItems(studioId, rundownId, undefined, piece)) + } if (baseline) { for (const item of baseline.expectedPlayoutItems ?? []) { @@ -93,7 +96,7 @@ export function updateExpectedPlayoutItemsForPartModel(context: JobContext, part const expectedPlayoutItems: ExpectedPlayoutItemRundown[] = [] for (const piece of part.pieces) { expectedPlayoutItems.push( - ...extractExpectedPlayoutItems(studioId, part.part.rundownId, piece.startPartId, piece) + ...extractExpectedPlayoutItems(studioId, part.part.rundownId, piece.startPartId ?? undefined, piece) ) } for (const piece of part.adLibPieces) { diff --git a/packages/job-worker/src/ingest/generationRundown.ts b/packages/job-worker/src/ingest/generationRundown.ts index c327ded88f..0f11563245 100644 --- a/packages/job-worker/src/ingest/generationRundown.ts +++ b/packages/job-worker/src/ingest/generationRundown.ts @@ -11,6 +11,7 @@ import { WatchedPackagesHelper } from '../blueprints/context/watchedPackages' import { postProcessAdLibPieces, postProcessGlobalAdLibActions, + postProcessGlobalPieces, postProcessRundownBaselineItems, } from '../blueprints/postProcess' import { logger } from '../logging' @@ -295,6 +296,7 @@ export async function regenerateRundownAndBaselineFromIngestData( logger.info(`... got ${rundownRes.baseline.timelineObjects.length} objects from baseline.`) logger.info(`... got ${rundownRes.globalAdLibPieces.length} adLib objects from baseline.`) logger.info(`... got ${(rundownRes.globalActions || []).length} adLib actions from baseline.`) + logger.info(`... got ${(rundownRes.globalPieces || []).length} global pieces from baseline.`) const timelineObjectsBlob = serializePieceTimelineObjectsBlob( postProcessRundownBaselineItems(showStyle.base.blueprintId, rundownRes.baseline.timelineObjects) @@ -312,8 +314,14 @@ export async function regenerateRundownAndBaselineFromIngestData( dbRundown._id, rundownRes.globalActions || [] ) + const globalPieces = postProcessGlobalPieces( + context, + rundownRes.globalPieces || [], + showStyle.base.blueprintId, + dbRundown._id + ) - await ingestModel.setRundownBaseline(timelineObjectsBlob, adlibPieces, adlibActions) + await ingestModel.setRundownBaseline(timelineObjectsBlob, adlibPieces, adlibActions, globalPieces) await updateExpectedPackagesForRundownBaseline(context, ingestModel, rundownRes.baseline) diff --git a/packages/job-worker/src/ingest/model/IngestModel.ts b/packages/job-worker/src/ingest/model/IngestModel.ts index bfd015e210..f726a733bf 100644 --- a/packages/job-worker/src/ingest/model/IngestModel.ts +++ b/packages/job-worker/src/ingest/model/IngestModel.ts @@ -2,6 +2,7 @@ import { ExpectedMediaItemRundown } from '@sofie-automation/corelib/dist/dataMod import { ExpectedPackageDBFromBaselineAdLibAction, ExpectedPackageDBFromBaselineAdLibPiece, + ExpectedPackageDBFromBaselinePiece, ExpectedPackageDBFromRundownBaselineObjects, ExpectedPackageFromRundown, } from '@sofie-automation/corelib/dist/dataModel/ExpectedPackages' @@ -37,6 +38,7 @@ export type ExpectedPackageForIngestModelBaseline = | ExpectedPackageDBFromBaselineAdLibAction | ExpectedPackageDBFromBaselineAdLibPiece | ExpectedPackageDBFromRundownBaselineObjects + | ExpectedPackageDBFromBaselinePiece export type ExpectedPackageForIngestModel = ExpectedPackageFromRundown | ExpectedPackageForIngestModelBaseline export interface IngestModelReadonly { @@ -130,6 +132,11 @@ export interface IngestModelReadonly { */ getAllPieces(): ReadonlyDeep[] + /** + * Get the Pieces which belong to the Rundown, not a Part + */ + getGlobalPieces(): ReadonlyDeep[] + /** * Search for a Part through the whole Rundown * @param id Id of the Part @@ -245,11 +252,13 @@ export interface IngestModel extends IngestModelReadonly, BaseModel, INotificati * @param timelineObjectsBlob Rundown baseline timeline objects * @param adlibPieces Rundown adlib pieces * @param adlibActions Rundown adlib actions + * @param pieces Rundown owned pieces */ setRundownBaseline( timelineObjectsBlob: PieceTimelineObjectsBlob, adlibPieces: RundownBaselineAdLibItem[], - adlibActions: RundownBaselineAdLibAction[] + adlibActions: RundownBaselineAdLibAction[], + pieces: Piece[] ): Promise /** diff --git a/packages/job-worker/src/ingest/model/implementation/IngestModelImpl.ts b/packages/job-worker/src/ingest/model/implementation/IngestModelImpl.ts index 15f33f777d..2b9e8be1f0 100644 --- a/packages/job-worker/src/ingest/model/implementation/IngestModelImpl.ts +++ b/packages/job-worker/src/ingest/model/implementation/IngestModelImpl.ts @@ -116,6 +116,8 @@ export class IngestModelImpl implements IngestModel, DatabasePersistedModel { } protected readonly segmentsImpl: Map + readonly #piecesWithChanges = new Set() + #piecesImpl: ReadonlyArray readonly #rundownBaselineExpectedPackagesStore: ExpectedPackagesStore @@ -224,6 +226,8 @@ export class IngestModelImpl implements IngestModel, DatabasePersistedModel { }) } + this.#piecesImpl = groupedPieces.get(null) ?? [] + this.#rundownBaselineObjs = new LazyInitialise(async () => context.directCollections.RundownBaselineObjects.findFetch({ rundownId: this.rundownId, @@ -253,6 +257,7 @@ export class IngestModelImpl implements IngestModel, DatabasePersistedModel { ) this.segmentsImpl = new Map() + this.#piecesImpl = [] this.#rundownBaselineObjs = new LazyInitialise(async () => []) this.#rundownBaselineAdLibPieces = new LazyInitialise(async () => []) @@ -334,6 +339,10 @@ export class IngestModelImpl implements IngestModel, DatabasePersistedModel { return this.getAllOrderedParts().flatMap((part) => part.pieces) } + getGlobalPieces(): ReadonlyDeep[] { + return [...this.#piecesImpl] + } + findPart(partId: PartId): IngestPartModel | undefined { for (const segment of this.segmentsImpl.values()) { if (!segment || segment.deleted) continue @@ -477,7 +486,8 @@ export class IngestModelImpl implements IngestModel, DatabasePersistedModel { async setRundownBaseline( timelineObjectsBlob: PieceTimelineObjectsBlob, adlibPieces: RundownBaselineAdLibItem[], - adlibActions: RundownBaselineAdLibAction[] + adlibActions: RundownBaselineAdLibAction[], + pieces: Piece[] ): Promise { const [loadedRundownBaselineObjs, loadedRundownBaselineAdLibPieces, loadedRundownBaselineAdLibActions] = await Promise.all([ @@ -499,11 +509,13 @@ export class IngestModelImpl implements IngestModel, DatabasePersistedModel { ) // Compare and update the adlibPieces - const newAdlibPieces = adlibPieces.map((piece) => ({ - ...clone(piece), - partId: undefined, - rundownId: this.rundownId, - })) + const newAdlibPieces = adlibPieces.map( + (piece): AdLibPiece => ({ + ...clone(piece), + partId: undefined, + rundownId: this.rundownId, + }) + ) this.#rundownBaselineAdLibPieces.setValue( diffAndReturnLatestObjects( this.#rundownBaselineAdLibPiecesWithChanges, @@ -513,11 +525,13 @@ export class IngestModelImpl implements IngestModel, DatabasePersistedModel { ) // Compare and update the adlibActions - const newAdlibActions = adlibActions.map((action) => ({ - ...clone(action), - partId: undefined, - rundownId: this.rundownId, - })) + const newAdlibActions = adlibActions.map( + (action): RundownBaselineAdLibAction => ({ + ...clone(action), + partId: undefined, + rundownId: this.rundownId, + }) + ) this.#rundownBaselineAdLibActions.setValue( diffAndReturnLatestObjects( this.#rundownBaselineAdLibActionsWithChanges, @@ -525,6 +539,17 @@ export class IngestModelImpl implements IngestModel, DatabasePersistedModel { newAdlibActions ) ) + + // Compare and update the rundown pieces + const newPieces = pieces.map( + (piece): Piece => ({ + ...clone(piece), + startRundownId: this.rundownId, + startPartId: null, + startSegmentId: null, + }) + ) + this.#piecesImpl = diffAndReturnLatestObjects(this.#piecesWithChanges, this.#piecesImpl, newPieces) } setRundownOrphaned(orphaned: RundownOrphanedReason | undefined): void { @@ -628,9 +653,26 @@ export class IngestModelImpl implements IngestModel, DatabasePersistedModel { for (const segment of this.segmentsImpl.values()) { if (segment.deleted) { logOrThrowError(new Error(`Failed no changes in model assertion, Segment has been changed`)) + break } else { const err = segment.segmentModel.checkNoChanges() - if (err) logOrThrowError(err) + if (err) { + logOrThrowError(err) + break + } + } + } + + if (this.#piecesWithChanges.size) { + logOrThrowError(new Error(`Failed no changes in model assertion, Rundown Pieces have been changed`)) + } else { + for (const piece of this.#piecesImpl.values()) { + if (!piece) { + logOrThrowError( + new Error(`Failed no changes in model assertion, Rundown Pieces have been changed`) + ) + break + } } } } finally { @@ -688,6 +730,8 @@ export class IngestModelImpl implements IngestModel, DatabasePersistedModel { saveHelper.addExpectedPackagesStore(this.#rundownBaselineExpectedPackagesStore) this.#rundownBaselineExpectedPackagesStore.clearChangedFlags() + saveHelper.addChangedPieces(this.#piecesImpl, this.#piecesWithChanges) + await Promise.all([ this.#rundownHasChanged && this.#rundownImpl ? this.context.directCollections.Rundowns.replace(this.#rundownImpl) diff --git a/packages/job-worker/src/ingest/model/implementation/SaveIngestModel.ts b/packages/job-worker/src/ingest/model/implementation/SaveIngestModel.ts index 63dbd7fd7f..cf918230f0 100644 --- a/packages/job-worker/src/ingest/model/implementation/SaveIngestModel.ts +++ b/packages/job-worker/src/ingest/model/implementation/SaveIngestModel.ts @@ -3,7 +3,7 @@ import { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' import { ExpectedMediaItem } from '@sofie-automation/corelib/dist/dataModel/ExpectedMediaItem' import { ExpectedPackageDB } from '@sofie-automation/corelib/dist/dataModel/ExpectedPackages' import { ExpectedPlayoutItem } from '@sofie-automation/corelib/dist/dataModel/ExpectedPlayoutItem' -import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PieceId, RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { Piece } from '@sofie-automation/corelib/dist/dataModel/Piece' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' @@ -56,6 +56,19 @@ export class SaveIngestModelHelper { } } + addChangedPieces(pieces: ReadonlyArray, changedPieceIds: Set): void { + for (const piece of pieces) { + this.#pieces.addDocument(piece, changedPieceIds.has(piece._id)) + } + + const currentPieceIds = new Set(pieces.map((p) => p._id)) + for (const changedPieceId of changedPieceIds) { + if (!currentPieceIds.has(changedPieceId)) { + this.#pieces.deleteDocument(changedPieceId) + } + } + } + commit(context: JobContext): Array> { // Log deleted ids: const deletedIds: { [key: string]: ProtectedString[] } = { diff --git a/packages/job-worker/src/ingest/syncChangesToPartInstance.ts b/packages/job-worker/src/ingest/syncChangesToPartInstance.ts index eef8ee049d..53a48e3b17 100644 --- a/packages/job-worker/src/ingest/syncChangesToPartInstance.ts +++ b/packages/job-worker/src/ingest/syncChangesToPartInstance.ts @@ -25,6 +25,7 @@ import { convertPartInstanceToBlueprints, convertPartToBlueprints, convertPieceInstanceToBlueprints, + convertRundownPieceToBlueprints, } from '../blueprints/context/lib' import { validateAdlibTestingPartInstanceProperties } from '../playout/adlibTesting' import { ReadonlyDeep } from 'type-fest' @@ -47,7 +48,7 @@ type SyncedInstance = { * This defers out to the Blueprints to do the syncing * @param context Context of the job ebeing run * @param playoutModel Playout model containing containing the Rundown being ingested - * @param ingestModel Ingest model for the Rundown + * @param ingestModel Ingest model for the Rundown. This is being written to mongodb while this method runs */ export async function syncChangesToPartInstances( context: JobContext, @@ -182,6 +183,7 @@ export async function syncChangesToPartInstances( adLibPieces: newPart && ingestPart ? ingestPart.adLibPieces.map(convertAdLibPieceToBlueprints) : [], actions: newPart && ingestPart ? ingestPart.adLibActions.map(convertAdLibActionToBlueprints) : [], referencedAdlibs: referencedAdlibs, + rundownPieces: ingestModel.getGlobalPieces().map(convertRundownPieceToBlueprints), } const partInstanceSnapshot = existingPartInstance.snapshotMakeCopy() @@ -241,6 +243,7 @@ export async function syncChangesToPartInstances( await syncPlayheadInfinitesForNextPartInstance( context, playoutModel, + ingestModel, playoutModel.currentPartInstance, playoutModel.nextPartInstance ) diff --git a/packages/job-worker/src/playout/__tests__/playout.test.ts b/packages/job-worker/src/playout/__tests__/playout.test.ts index 623a2d6959..6af4b7a372 100644 --- a/packages/job-worker/src/playout/__tests__/playout.test.ts +++ b/packages/job-worker/src/playout/__tests__/playout.test.ts @@ -608,7 +608,7 @@ describe('Playout API', () => { : now) + Math.random() * TIME_RANDOM, }, - } + } satisfies PlayoutChangedResult }), ], }) @@ -701,7 +701,7 @@ describe('Playout API', () => { : now) + Math.random() * TIME_RANDOM, }, - } + } satisfies PlayoutChangedResult }), ], }) diff --git a/packages/job-worker/src/playout/__tests__/resolvedPieces.test.ts b/packages/job-worker/src/playout/__tests__/resolvedPieces.test.ts index 5e0329df2f..c2e4db247e 100644 --- a/packages/job-worker/src/playout/__tests__/resolvedPieces.test.ts +++ b/packages/job-worker/src/playout/__tests__/resolvedPieces.test.ts @@ -13,6 +13,8 @@ import { } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import { EmptyPieceTimelineObjectsBlob } from '@sofie-automation/corelib/dist/dataModel/Piece' import { + createPartCurrentTimes, + PartCurrentTimes, processAndPrunePieceInstanceTimings, resolvePrunedPieceInstance, } from '@sofie-automation/corelib/dist/playout/processAndPrune' @@ -93,8 +95,9 @@ describe('Resolved Pieces', () => { nowInPart: number | null, pieceInstances: PieceInstance[] ): ResolvedPieceInstance[] { - const preprocessedPieces = processAndPrunePieceInstanceTimings(sourceLayers, pieceInstances, nowInPart ?? 0) - return preprocessedPieces.map((instance) => resolvePrunedPieceInstance(nowInPart ?? 0, instance)) + const partTimes = createPartCurrentTimes(5000, nowInPart) + const preprocessedPieces = processAndPrunePieceInstanceTimings(sourceLayers, pieceInstances, partTimes) + return preprocessedPieces.map((instance) => resolvePrunedPieceInstance(partTimes, instance)) } test('simple single piece', async () => { @@ -398,18 +401,18 @@ describe('Resolved Pieces', () => { } function createPartInstanceInfo( - partStarted: number, - nowInPart: number, + partTimes: PartCurrentTimes, + // partStarted: number, + // nowInPart: number, partInstance: DBPartInstance, currentPieces: PieceInstance[] ): SelectedPartInstanceTimelineInfo { - const pieceInstances = processAndPrunePieceInstanceTimings(sourceLayers, currentPieces, nowInPart) + const pieceInstances = processAndPrunePieceInstanceTimings(sourceLayers, currentPieces, partTimes) return { partInstance, pieceInstances, - nowInPart, - partStarted, + partTimes, // Approximate `calculatedTimings`, for the partInstances which already have it cached calculatedTimings: getPartTimingsOrDefaults(partInstance, pieceInstances), regenerateTimelineAt: undefined, @@ -421,9 +424,10 @@ describe('Resolved Pieces', () => { expect(sourceLayerId).toBeTruthy() const now = 990000 + const partTimes = createPartCurrentTimes(now, now) const piece001 = createPieceInstance(sourceLayerId, { start: 0 }) - const currentPartInfo = createPartInstanceInfo(now, 0, createPartInstance(), [piece001]) + const currentPartInfo = createPartInstanceInfo(partTimes, createPartInstance(), [piece001]) const resolvedPieces = getResolvedPiecesForPartInstancesOnTimeline( context, @@ -456,13 +460,9 @@ describe('Resolved Pieces', () => { ) const now = 990000 - const nowInPart = 2000 - const partStarted = now - nowInPart + const partTimes = createPartCurrentTimes(now, now - 2000) - const currentPartInfo = createPartInstanceInfo(partStarted, nowInPart, createPartInstance(), [ - piece001, - virtualPiece, - ]) + const currentPartInfo = createPartInstanceInfo(partTimes, createPartInstance(), [piece001, virtualPiece]) // Check the result const simpleResolvedPieces = getResolvedPiecesForPartInstancesOnTimeline( @@ -473,8 +473,8 @@ describe('Resolved Pieces', () => { expect(stripResult(simpleResolvedPieces)).toEqual([ { _id: piece001._id, - resolvedStart: partStarted, - resolvedDuration: nowInPart, + resolvedStart: partTimes.partStartTime!, + resolvedDuration: partTimes.nowInPart, }, { // TODO - this object should not be present? @@ -501,13 +501,9 @@ describe('Resolved Pieces', () => { ) const now = 990000 - const nowInPart = 2000 - const partStarted = now - nowInPart + const partTimes = createPartCurrentTimes(now, now - 2000) - const currentPartInfo = createPartInstanceInfo(partStarted, nowInPart, createPartInstance(), [ - piece001, - virtualPiece, - ]) + const currentPartInfo = createPartInstanceInfo(partTimes, createPartInstance(), [piece001, virtualPiece]) const simpleResolvedPieces = getResolvedPiecesForPartInstancesOnTimeline( context, @@ -517,13 +513,13 @@ describe('Resolved Pieces', () => { expect(stripResult(simpleResolvedPieces)).toEqual([ { _id: piece001._id, - resolvedStart: partStarted, + resolvedStart: partTimes.partStartTime!, resolvedDuration: 7000, }, { // TODO - this object should not be present? _id: virtualPiece._id, - resolvedStart: partStarted + 7000, + resolvedStart: partTimes.partStartTime! + 7000, resolvedDuration: undefined, }, ] satisfies StrippedResult) @@ -536,10 +532,9 @@ describe('Resolved Pieces', () => { const piece001 = createPieceInstance(sourceLayerId, { start: 0, duration: 0 }) const now = 990000 - const nowInPart = 2000 - const partStarted = now - nowInPart + const partTimes = createPartCurrentTimes(now, now - 2000) - const currentPartInfo = createPartInstanceInfo(partStarted, nowInPart, createPartInstance(), [piece001]) + const currentPartInfo = createPartInstanceInfo(partTimes, createPartInstance(), [piece001]) const simpleResolvedPieces = getResolvedPiecesForPartInstancesOnTimeline( context, @@ -549,7 +544,7 @@ describe('Resolved Pieces', () => { expect(stripResult(simpleResolvedPieces)).toEqual([ { _id: piece001._id, - resolvedStart: partStarted, + resolvedStart: partTimes.partStartTime!, resolvedDuration: 0, }, ] satisfies StrippedResult) @@ -577,10 +572,9 @@ describe('Resolved Pieces', () => { ) const now = 990000 - const nowInPart = 2000 - const partStarted = now - nowInPart + const partTimes = createPartCurrentTimes(now, now - 2000) - const currentPartInfo = createPartInstanceInfo(partStarted, nowInPart, createPartInstance(), [ + const currentPartInfo = createPartInstanceInfo(partTimes, createPartInstance(), [ piece001, infinite1, infinite2, @@ -594,17 +588,17 @@ describe('Resolved Pieces', () => { expect(stripResult(simpleResolvedPieces)).toEqual([ { _id: infinite1._id, - resolvedStart: partStarted + 1000, + resolvedStart: partTimes.partStartTime! + 1000, resolvedDuration: 4000, }, { _id: piece001._id, - resolvedStart: partStarted + 3000, + resolvedStart: partTimes.partStartTime! + 3000, resolvedDuration: 2000, }, { _id: infinite2._id, - resolvedStart: partStarted + 5000, + resolvedStart: partTimes.partStartTime! + 5000, resolvedDuration: undefined, }, ] satisfies StrippedResult) @@ -626,10 +620,9 @@ describe('Resolved Pieces', () => { ) const now = 990000 - const nowInPart = 2000 - const partStarted = now - nowInPart + const partTimes = createPartCurrentTimes(now, now - 2000) - const currentPartInfo = createPartInstanceInfo(partStarted, nowInPart, createPartInstance(), [piece001]) + const currentPartInfo = createPartInstanceInfo(partTimes, createPartInstance(), [piece001]) const simpleResolvedPieces = getResolvedPiecesForPartInstancesOnTimeline( context, @@ -639,7 +632,7 @@ describe('Resolved Pieces', () => { expect(stripResult(simpleResolvedPieces)).toEqual([ { _id: piece001._id, - resolvedStart: partStarted + 3000, + resolvedStart: partTimes.partStartTime! + 3000, resolvedDuration: 1200, }, ] satisfies StrippedResult) @@ -661,10 +654,9 @@ describe('Resolved Pieces', () => { ) const now = 990000 - const nowInPart = 7000 - const partStarted = now - nowInPart + const partTimes = createPartCurrentTimes(now, now - 7000) - const currentPartInfo = createPartInstanceInfo(partStarted, nowInPart, createPartInstance(), [piece001]) + const currentPartInfo = createPartInstanceInfo(partTimes, createPartInstance(), [piece001]) const simpleResolvedPieces = getResolvedPiecesForPartInstancesOnTimeline( context, @@ -674,7 +666,7 @@ describe('Resolved Pieces', () => { expect(stripResult(simpleResolvedPieces)).toEqual([ { _id: piece001._id, - resolvedStart: partStarted + 4000, + resolvedStart: partTimes.partStartTime! + 4000, resolvedDuration: -4000 + 7000 + 1300, }, ] satisfies StrippedResult) @@ -689,20 +681,12 @@ describe('Resolved Pieces', () => { const piece010 = createPieceInstance(sourceLayerId, { start: 0 }) const now = 990000 - const nowInPart = 2000 - const currentPartStarted = now - nowInPart - const previousPartStarted = currentPartStarted - 5000 - - const previousPartInfo = createPartInstanceInfo( - previousPartStarted, - nowInPart + 5000, - createPartInstance(), - [piece001] - ) + const currentPartTimes = createPartCurrentTimes(now, now - 2000) + const previousPartTimes = createPartCurrentTimes(now, now - 7000) - const currentPartInfo = createPartInstanceInfo(currentPartStarted, nowInPart, createPartInstance(), [ - piece010, - ]) + const previousPartInfo = createPartInstanceInfo(previousPartTimes, createPartInstance(), [piece001]) + + const currentPartInfo = createPartInstanceInfo(currentPartTimes, createPartInstance(), [piece010]) const simpleResolvedPieces = getResolvedPiecesForPartInstancesOnTimeline( context, @@ -715,12 +699,12 @@ describe('Resolved Pieces', () => { expect(stripResult(simpleResolvedPieces)).toEqual([ { _id: piece001._id, - resolvedStart: previousPartStarted, + resolvedStart: previousPartTimes.partStartTime!, resolvedDuration: 5000, }, { _id: piece010._id, - resolvedStart: currentPartStarted, + resolvedStart: currentPartTimes.partStartTime!, resolvedDuration: undefined, }, ] satisfies StrippedResult) @@ -743,21 +727,16 @@ describe('Resolved Pieces', () => { ) const now = 990000 - const nowInPart = 2000 - const currentPartStarted = now - nowInPart - const previousPartStarted = currentPartStarted - 5000 - - const previousPartInfo = createPartInstanceInfo( - previousPartStarted, - nowInPart + 5000, - createPartInstance(), - [piece001, cappedInfinitePiece] - ) + const currentPartTimes = createPartCurrentTimes(now, now - 2000) + const previousPartTimes = createPartCurrentTimes(now, now - 7000) - const currentPartInfo = createPartInstanceInfo(currentPartStarted, nowInPart, createPartInstance(), [ - piece010, + const previousPartInfo = createPartInstanceInfo(previousPartTimes, createPartInstance(), [ + piece001, + cappedInfinitePiece, ]) + const currentPartInfo = createPartInstanceInfo(currentPartTimes, createPartInstance(), [piece010]) + const simpleResolvedPieces = getResolvedPiecesForPartInstancesOnTimeline( context, { @@ -769,17 +748,17 @@ describe('Resolved Pieces', () => { expect(stripResult(simpleResolvedPieces)).toEqual([ { _id: piece001._id, - resolvedStart: previousPartStarted, + resolvedStart: previousPartTimes.partStartTime!, resolvedDuration: 1000, }, { _id: cappedInfinitePiece._id, - resolvedStart: previousPartStarted + 1000, + resolvedStart: previousPartTimes.partStartTime! + 1000, resolvedDuration: 4000, }, { _id: piece010._id, - resolvedStart: currentPartStarted, + resolvedStart: currentPartTimes.partStartTime!, resolvedDuration: undefined, }, ] satisfies StrippedResult) @@ -820,18 +799,15 @@ describe('Resolved Pieces', () => { } const now = 990000 - const nowInPart = 2000 - const currentPartStarted = now - nowInPart - const previousPartStarted = currentPartStarted - 5000 - - const previousPartInfo = createPartInstanceInfo( - previousPartStarted, - nowInPart + 5000, - createPartInstance(), - [piece001, startingInfinitePiece] - ) + const currentPartTimes = createPartCurrentTimes(now, now - 2000) + const previousPartTimes = createPartCurrentTimes(now, now - 7000) + + const previousPartInfo = createPartInstanceInfo(previousPartTimes, createPartInstance(), [ + piece001, + startingInfinitePiece, + ]) - const currentPartInfo = createPartInstanceInfo(currentPartStarted, nowInPart, createPartInstance(), [ + const currentPartInfo = createPartInstanceInfo(currentPartTimes, createPartInstance(), [ piece010, continuingInfinitePiece, ]) @@ -847,17 +823,17 @@ describe('Resolved Pieces', () => { expect(stripResult(simpleResolvedPieces)).toEqual([ { _id: piece001._id, - resolvedStart: previousPartStarted, + resolvedStart: previousPartTimes.partStartTime!, resolvedDuration: 1000, }, { _id: continuingInfinitePiece._id, - resolvedStart: previousPartStarted + 1000, + resolvedStart: previousPartTimes.partStartTime! + 1000, resolvedDuration: 9400, }, { _id: piece010._id, - resolvedStart: currentPartStarted, + resolvedStart: currentPartTimes.partStartTime!, resolvedDuration: undefined, }, ] satisfies StrippedResult) @@ -872,14 +848,12 @@ describe('Resolved Pieces', () => { const piece010 = createPieceInstance(sourceLayerId, { start: 0 }) const now = 990000 - const nowInPart = 2000 - const currentPartStarted = now - nowInPart + const currentPartTimes = createPartCurrentTimes(now, now - 2000) const currentPartLength = 13000 - const nextPartStart = currentPartStarted + currentPartLength + const nextPartTimes = createPartCurrentTimes(now, currentPartTimes.partStartTime! + currentPartLength) const currentPartInfo = createPartInstanceInfo( - currentPartStarted, - nowInPart, + currentPartTimes, createPartInstance({ autoNext: true, expectedDuration: currentPartLength, @@ -887,7 +861,7 @@ describe('Resolved Pieces', () => { [piece001] ) - const nextPartInfo = createPartInstanceInfo(nextPartStart, 0, createPartInstance(), [piece010]) + const nextPartInfo = createPartInstanceInfo(nextPartTimes, createPartInstance(), [piece010]) const simpleResolvedPieces = getResolvedPiecesForPartInstancesOnTimeline( context, @@ -900,12 +874,12 @@ describe('Resolved Pieces', () => { expect(stripResult(simpleResolvedPieces)).toEqual([ { _id: piece001._id, - resolvedStart: currentPartStarted, + resolvedStart: currentPartTimes.partStartTime!, resolvedDuration: currentPartLength, }, { _id: piece010._id, - resolvedStart: nextPartStart, + resolvedStart: nextPartTimes.partStartTime!, resolvedDuration: undefined, }, ] satisfies StrippedResult) @@ -928,14 +902,12 @@ describe('Resolved Pieces', () => { ) const now = 990000 - const nowInPart = 2000 - const currentPartStarted = now - nowInPart + const currentPartTimes = createPartCurrentTimes(now, now - 2000) const currentPartLength = 13000 - const nextPartStart = currentPartStarted + currentPartLength + const nextPartTimes = createPartCurrentTimes(now, currentPartTimes.partStartTime! + currentPartLength) const currentPartInfo = createPartInstanceInfo( - currentPartStarted, - nowInPart, + currentPartTimes, createPartInstance({ autoNext: true, expectedDuration: currentPartLength, @@ -943,7 +915,7 @@ describe('Resolved Pieces', () => { [piece001, cappedInfinitePiece] ) - const nextPartInfo = createPartInstanceInfo(nextPartStart, 0, createPartInstance(), [piece010]) + const nextPartInfo = createPartInstanceInfo(nextPartTimes, createPartInstance(), [piece010]) const simpleResolvedPieces = getResolvedPiecesForPartInstancesOnTimeline( context, @@ -957,17 +929,17 @@ describe('Resolved Pieces', () => { expect(stripResult(simpleResolvedPieces)).toEqual([ { _id: piece001._id, - resolvedStart: currentPartStarted, + resolvedStart: currentPartTimes.partStartTime!, resolvedDuration: 1000, }, { _id: cappedInfinitePiece._id, - resolvedStart: currentPartStarted + 1000, + resolvedStart: currentPartTimes.partStartTime! + 1000, resolvedDuration: currentPartLength - 1000, }, { _id: piece010._id, - resolvedStart: nextPartStart, + resolvedStart: nextPartTimes.partStartTime!, resolvedDuration: undefined, }, ] satisfies StrippedResult) @@ -1008,14 +980,12 @@ describe('Resolved Pieces', () => { } const now = 990000 - const nowInPart = 2000 - const currentPartStarted = now - nowInPart + const currentPartTimes = createPartCurrentTimes(now, now - 2000) const currentPartLength = 13000 - const nextPartStart = currentPartStarted + currentPartLength + const nextPartTimes = createPartCurrentTimes(now, currentPartTimes.partStartTime! + currentPartLength) const currentPartInfo = createPartInstanceInfo( - currentPartStarted, - nowInPart, + currentPartTimes, createPartInstance({ autoNext: true, expectedDuration: currentPartLength, @@ -1023,7 +993,7 @@ describe('Resolved Pieces', () => { [piece001, startingInfinitePiece] ) - const nextPartInfo = createPartInstanceInfo(nextPartStart, 0, createPartInstance(), [ + const nextPartInfo = createPartInstanceInfo(nextPartTimes, createPartInstance(), [ piece010, continuingInfinitePiece, ]) @@ -1040,17 +1010,17 @@ describe('Resolved Pieces', () => { expect(stripResult(simpleResolvedPieces)).toEqual([ { _id: piece001._id, - resolvedStart: currentPartStarted, + resolvedStart: currentPartTimes.partStartTime!, resolvedDuration: 1000, }, { _id: startingInfinitePiece._id, - resolvedStart: currentPartStarted + 1000, + resolvedStart: currentPartTimes.partStartTime! + 1000, resolvedDuration: currentPartLength - 1000 + 3400, }, { _id: piece010._id, - resolvedStart: nextPartStart, + resolvedStart: nextPartTimes.partStartTime!, resolvedDuration: undefined, }, ] satisfies StrippedResult) diff --git a/packages/job-worker/src/playout/adlibJobs.ts b/packages/job-worker/src/playout/adlibJobs.ts index 1625239dd4..42135b5100 100644 --- a/packages/job-worker/src/playout/adlibJobs.ts +++ b/packages/job-worker/src/playout/adlibJobs.ts @@ -194,6 +194,7 @@ async function pieceTakeNowAsAdlib( await syncPlayheadInfinitesForNextPartInstance( context, playoutModel, + undefined, playoutModel.currentPartInstance, playoutModel.nextPartInstance ) @@ -373,6 +374,7 @@ export async function handleStopPiecesOnSourceLayers( await syncPlayheadInfinitesForNextPartInstance( context, playoutModel, + undefined, playoutModel.currentPartInstance, playoutModel.nextPartInstance ) diff --git a/packages/job-worker/src/playout/adlibUtils.ts b/packages/job-worker/src/playout/adlibUtils.ts index 8392d9ec51..6e193b5d35 100644 --- a/packages/job-worker/src/playout/adlibUtils.ts +++ b/packages/job-worker/src/playout/adlibUtils.ts @@ -69,6 +69,7 @@ export async function innerStartOrQueueAdLibPiece( await syncPlayheadInfinitesForNextPartInstance( context, playoutModel, + undefined, currentPartInstance, playoutModel.nextPartInstance ) @@ -323,13 +324,15 @@ export function innerStopPieces( const pieceInstanceModel = playoutModel.findPieceInstance(pieceInstance._id) if (pieceInstanceModel) { - const newDuration: Required['userDuration'] = playoutModel.isMultiGatewayMode - ? { - endRelativeToNow: offsetRelativeToNow, - } - : { - endRelativeToPart: relativeStopAt, - } + const newDuration: Required['userDuration'] = + playoutModel.isMultiGatewayMode || + pieceInstanceModel.pieceInstance.pieceInstance.piece.enable.isAbsolute + ? { + endRelativeToNow: offsetRelativeToNow, + } + : { + endRelativeToPart: relativeStopAt, + } pieceInstanceModel.pieceInstance.setDuration(newDuration) diff --git a/packages/job-worker/src/playout/debug.ts b/packages/job-worker/src/playout/debug.ts index b46cadcad5..b39ff9629c 100644 --- a/packages/job-worker/src/playout/debug.ts +++ b/packages/job-worker/src/playout/debug.ts @@ -25,6 +25,7 @@ export async function handleDebugSyncPlayheadInfinitesForNextPartInstance( await syncPlayheadInfinitesForNextPartInstance( context, playoutModel, + undefined, playoutModel.currentPartInstance, playoutModel.nextPartInstance ) diff --git a/packages/job-worker/src/playout/infinites.ts b/packages/job-worker/src/playout/infinites.ts index daec9a439b..e445c41fc9 100644 --- a/packages/job-worker/src/playout/infinites.ts +++ b/packages/job-worker/src/playout/infinites.ts @@ -2,21 +2,24 @@ import { PartInstanceId, RundownId, ShowStyleBaseId } from '@sofie-automation/co import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' import { Piece } from '@sofie-automation/corelib/dist/dataModel/Piece' -import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' +import { PieceInstance, wrapPieceToInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import { getPieceInstancesForPart as libgetPieceInstancesForPart, getPlayheadTrackingInfinitesForPart as libgetPlayheadTrackingInfinitesForPart, buildPiecesStartingInThisPartQuery, buildPastInfinitePiecesForThisPartQuery, } from '@sofie-automation/corelib/dist/playout/infinites' -import { processAndPrunePieceInstanceTimings } from '@sofie-automation/corelib/dist/playout/processAndPrune' +import { + createPartCurrentTimes, + processAndPrunePieceInstanceTimings, +} from '@sofie-automation/corelib/dist/playout/processAndPrune' import { JobContext } from '../jobs' import { ReadonlyDeep } from 'type-fest' import { PlayoutModel } from './model/PlayoutModel' import { PlayoutPartInstanceModel } from './model/PlayoutPartInstanceModel' import { PlayoutSegmentModel } from './model/PlayoutSegmentModel' import { getCurrentTime } from '../lib' -import { flatten } from '@sofie-automation/corelib/dist/lib' +import { clone, flatten, getRandomId } from '@sofie-automation/corelib/dist/lib' import _ = require('underscore') import { IngestModelReadonly } from '../ingest/model/IngestModel' import { SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' @@ -217,6 +220,7 @@ export async function fetchPiecesThatMayBeActiveForPart( export async function syncPlayheadInfinitesForNextPartInstance( context: JobContext, playoutModel: PlayoutModel, + unsavedIngestModel: Pick | undefined, fromPartInstance: PlayoutPartInstanceModel | null, toPartInstance: PlayoutPartInstanceModel | null ): Promise { @@ -253,11 +257,14 @@ export async function syncPlayheadInfinitesForNextPartInstance( toPartInstance.partInstance.part ) - const nowInPart = getCurrentTime() - (fromPartInstance.partInstance.timings?.plannedStartedPlayback ?? 0) + const partTimes = createPartCurrentTimes( + getCurrentTime(), + fromPartInstance.partInstance.timings?.plannedStartedPlayback + ) const prunedPieceInstances = processAndPrunePieceInstanceTimings( showStyleBase.sourceLayers, fromPartInstance.pieceInstances.map((p) => p.pieceInstance), - nowInPart, + partTimes, undefined, true ) @@ -282,6 +289,17 @@ export async function syncPlayheadInfinitesForNextPartInstance( ) toPartInstance.replaceInfinitesFromPreviousPlayhead(infinites) + } else if (toPartInstance && !fromPartInstance) { + // This is the first take of the rundown, ensure the baseline infinites are loaded + const baselineInfinites = await getBaselineInfinitesForPart( + context, + playoutModel, + unsavedIngestModel, + toPartInstance.partInstance.part, + toPartInstance.partInstance._id + ) + + toPartInstance.replaceInfinitesFromPreviousPlayhead(baselineInfinites) } if (span) span.end() } @@ -383,3 +401,38 @@ export function getPieceInstancesForPart( if (span) span.end() return res } + +export async function getBaselineInfinitesForPart( + context: JobContext, + playoutModel: PlayoutModel, + unsavedIngestModel: Pick | undefined, + part: ReadonlyDeep, + partInstanceId: PartInstanceId +): Promise { + // Find the pieces. If an ingest model is provided, use that instead of the database + const pieces = + unsavedIngestModel && unsavedIngestModel.rundownId === part.rundownId + ? unsavedIngestModel.getAllPieces().filter((p) => p.startPartId === null) + : await context.directCollections.Pieces.findFetch({ + startRundownId: part.rundownId, + startPartId: null, + }) + + const playlistActivationId = playoutModel.playlist.activationId + if (!playlistActivationId) throw new Error(`RundownPlaylist "${playoutModel.playlistId}" is not active`) + + return pieces.map((piece) => { + const instance = wrapPieceToInstance(clone(piece), playlistActivationId, partInstanceId, false) + + // All these pieces are expected to be outOnRundownChange infinites, as that is how they are ingested + + instance.infinite = { + infiniteInstanceId: getRandomId(), + infiniteInstanceIndex: 0, + infinitePieceId: instance.piece._id, + fromPreviousPart: true, + } + + return instance + }) +} 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 9719e71899..46512e7aff 100644 --- a/packages/job-worker/src/playout/lookahead/__tests__/lookahead.test.ts +++ b/packages/job-worker/src/playout/lookahead/__tests__/lookahead.test.ts @@ -23,6 +23,7 @@ type TgetOrderedPartsAfterPlayhead = jest.MockedFunction { @@ -272,8 +273,7 @@ describe('Lookahead', () => { const partInstancesInfo: SelectedPartInstancesTimelineInfo = {} partInstancesInfo.previous = { partInstance: { _id: 'abc2', part: { _id: 'abc' } } as any, - nowInPart: 987, - partStarted: getCurrentTime() + 546, + partTimes: createPartCurrentTimes(getCurrentTime(), getCurrentTime() + 546), pieceInstances: ['1', '2'] as any, calculatedTimings: { inTransitionStart: null } as any, regenerateTimelineAt: undefined, @@ -282,7 +282,7 @@ describe('Lookahead', () => { const expectedPrevious = { part: partInstancesInfo.previous.partInstance, onTimeline: true, - nowInPart: partInstancesInfo.previous.nowInPart, + nowInPart: partInstancesInfo.previous.partTimes.nowInPart, allPieces: partInstancesInfo.previous.pieceInstances, calculatedTimings: partInstancesInfo.previous.calculatedTimings, } @@ -296,8 +296,7 @@ describe('Lookahead', () => { // Add a current partInstancesInfo.current = { partInstance: { _id: 'curr', part: {} } as any, - nowInPart: 56, - partStarted: getCurrentTime() + 865, + partTimes: createPartCurrentTimes(getCurrentTime(), getCurrentTime() + 865), pieceInstances: ['3', '4'] as any, calculatedTimings: { inTransitionStart: null } as any, regenerateTimelineAt: undefined, @@ -305,7 +304,7 @@ describe('Lookahead', () => { const expectedCurrent = { part: partInstancesInfo.current.partInstance, onTimeline: true, - nowInPart: partInstancesInfo.current.nowInPart, + nowInPart: partInstancesInfo.current.partTimes.nowInPart, allPieces: partInstancesInfo.current.pieceInstances, calculatedTimings: partInstancesInfo.current.calculatedTimings, } @@ -317,8 +316,7 @@ describe('Lookahead', () => { // Add a next partInstancesInfo.next = { partInstance: { _id: 'nxt2', part: { _id: 'nxt' } } as any, - nowInPart: -85, - partStarted: getCurrentTime() + 142, + partTimes: createPartCurrentTimes(getCurrentTime(), getCurrentTime() + 142), pieceInstances: ['5'] as any, calculatedTimings: { inTransitionStart: null } as any, regenerateTimelineAt: undefined, @@ -326,7 +324,7 @@ describe('Lookahead', () => { const expectedNext = { part: partInstancesInfo.next.partInstance, onTimeline: false, - nowInPart: partInstancesInfo.next.nowInPart, + nowInPart: partInstancesInfo.next.partTimes.nowInPart, allPieces: partInstancesInfo.next.pieceInstances, calculatedTimings: partInstancesInfo.next.calculatedTimings, } diff --git a/packages/job-worker/src/playout/lookahead/findObjects.ts b/packages/job-worker/src/playout/lookahead/findObjects.ts index 05a13c6288..e8ec972dfa 100644 --- a/packages/job-worker/src/playout/lookahead/findObjects.ts +++ b/packages/job-worker/src/playout/lookahead/findObjects.ts @@ -19,7 +19,7 @@ function getBestPieceInstanceId(piece: ReadonlyDeep): string { return unprotectString(piece._id) } // Something is needed, and it must be distant future here, so accuracy is not important - return unprotectString(piece.piece.startPartId) + return unprotectString(piece.piece.startPartId ?? piece.rundownId) } function tryActivateKeyframesForObject( diff --git a/packages/job-worker/src/playout/lookahead/index.ts b/packages/job-worker/src/playout/lookahead/index.ts index 12ac4935ec..3156b70311 100644 --- a/packages/job-worker/src/playout/lookahead/index.ts +++ b/packages/job-worker/src/playout/lookahead/index.ts @@ -46,15 +46,29 @@ function getPrunedEndedPieceInstances(info: SelectedPartInstanceTimelineInfo) { if (!info.partInstance.timings?.plannedStartedPlayback) { return info.pieceInstances } else { - return info.pieceInstances.filter((p) => !hasPieceInstanceDefinitelyEnded(p, info.nowInPart)) + return info.pieceInstances.filter((p) => !hasPieceInstanceDefinitelyEnded(p, info.partTimes.nowInPart)) } } -function removeInfiniteContinuations(info: PartInstanceAndPieceInstances): PartInstanceAndPieceInstances { +function removeInfiniteContinuations( + info: PartInstanceAndPieceInstances, + isCurrentPart: boolean +): PartInstanceAndPieceInstances { const partId = info.part.part._id return { ...info, // Ignore PieceInstances that continue from the previous part, as they will not need lookahead - allPieces: info.allPieces.filter((inst) => !inst.infinite || inst.piece.startPartId === partId), + allPieces: info.allPieces.filter((inst) => { + // Always include non infinite pieces + if (!inst.infinite) return true + + // Only include rundown owned pieces in the current part + if (!inst.piece.startPartId) { + return isCurrentPart + } + + // Include infinite pieces in the part where they start + return inst.piece.startPartId === partId + }), } } @@ -92,35 +106,44 @@ export async function getLookeaheadObjects( const partInstancesInfo: PartInstanceAndPieceInstances[] = _.compact([ partInstancesInfo0.current - ? removeInfiniteContinuations({ - part: partInstancesInfo0.current.partInstance, - onTimeline: true, - nowInPart: partInstancesInfo0.current.nowInPart, - allPieces: getPrunedEndedPieceInstances(partInstancesInfo0.current), - calculatedTimings: partInstancesInfo0.current.calculatedTimings, - }) + ? removeInfiniteContinuations( + { + part: partInstancesInfo0.current.partInstance, + onTimeline: true, + nowInPart: partInstancesInfo0.current.partTimes.nowInPart, + allPieces: getPrunedEndedPieceInstances(partInstancesInfo0.current), + calculatedTimings: partInstancesInfo0.current.calculatedTimings, + }, + true + ) : undefined, partInstancesInfo0.next - ? removeInfiniteContinuations({ - part: partInstancesInfo0.next.partInstance, - onTimeline: !!partInstancesInfo0.current?.partInstance?.part?.autoNext, //TODO -QL - nowInPart: partInstancesInfo0.next.nowInPart, - allPieces: partInstancesInfo0.next.pieceInstances, - calculatedTimings: partInstancesInfo0.next.calculatedTimings, - }) + ? removeInfiniteContinuations( + { + part: partInstancesInfo0.next.partInstance, + onTimeline: !!partInstancesInfo0.current?.partInstance?.part?.autoNext, //TODO -QL + nowInPart: partInstancesInfo0.next.partTimes.nowInPart, + allPieces: partInstancesInfo0.next.pieceInstances, + calculatedTimings: partInstancesInfo0.next.calculatedTimings, + }, + false + ) : undefined, ]) // 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.nowInPart, - allPieces: getPrunedEndedPieceInstances(partInstancesInfo0.previous), - calculatedTimings: partInstancesInfo0.previous.calculatedTimings, - }) + previousPartInfo = removeInfiniteContinuations( + { + part: partInstancesInfo0.previous.partInstance, + onTimeline: true, + nowInPart: partInstancesInfo0.previous.partTimes.nowInPart, + allPieces: getPrunedEndedPieceInstances(partInstancesInfo0.previous), + calculatedTimings: partInstancesInfo0.previous.calculatedTimings, + }, + false + ) } // TODO: Do we need to use processAndPrunePieceInstanceTimings on these pieces? In theory yes, but that gets messy and expensive. @@ -129,6 +152,9 @@ export async function getLookeaheadObjects( const piecesByPart = new Map>() for (const piece of piecesToSearch) { + // Don't lookahead any rundown owned pieces, that should only happen once they become PieceInstances + if (!piece.startPartId) continue + const pieceInstance = wrapPieceToInstance(piece, protectString(''), protectString(''), true) const existing = piecesByPart.get(piece.startPartId) if (existing) { diff --git a/packages/job-worker/src/playout/resolvedPieces.ts b/packages/job-worker/src/playout/resolvedPieces.ts index f13ce67c72..65f7dbb91c 100644 --- a/packages/job-worker/src/playout/resolvedPieces.ts +++ b/packages/job-worker/src/playout/resolvedPieces.ts @@ -4,6 +4,7 @@ import { SourceLayers } from '@sofie-automation/corelib/dist/dataModel/ShowStyle import { JobContext } from '../jobs' import { getCurrentTime } from '../lib' import { + createPartCurrentTimes, processAndPrunePieceInstanceTimings, resolvePrunedPieceInstance, } from '@sofie-automation/corelib/dist/playout/processAndPrune' @@ -26,15 +27,14 @@ export function getResolvedPiecesForCurrentPartInstance( ): ResolvedPieceInstance[] { if (now === undefined) now = getCurrentTime() - const partStarted = partInstance.partInstance.timings?.plannedStartedPlayback - const nowInPart = partStarted ? now - partStarted : 0 + const partTimes = createPartCurrentTimes(now, partInstance.partInstance.timings?.plannedStartedPlayback) const preprocessedPieces = processAndPrunePieceInstanceTimings( sourceLayers, partInstance.pieceInstances.map((p) => p.pieceInstance), - nowInPart + partTimes ) - return preprocessedPieces.map((instance) => resolvePrunedPieceInstance(nowInPart, instance)) + return preprocessedPieces.map((instance) => resolvePrunedPieceInstance(partTimes, instance)) } export function getResolvedPiecesForPartInstancesOnTimeline( @@ -45,7 +45,7 @@ export function getResolvedPiecesForPartInstancesOnTimeline( // With no current part, there are no timings to consider if (!partInstancesInfo.current) return [] - const currentPartStarted = partInstancesInfo.current.partStarted ?? now + const currentPartStarted = partInstancesInfo.current.partTimes.partStartTime ?? now const nextPartStarted = partInstancesInfo.current.partInstance.part.autoNext && @@ -57,9 +57,9 @@ export function getResolvedPiecesForPartInstancesOnTimeline( // Calculate the next part if needed let nextResolvedPieces: ResolvedPieceInstance[] = [] if (partInstancesInfo.next && nextPartStarted != null) { - const nowInPart = partInstancesInfo.next.nowInPart + const partTimes = partInstancesInfo.next.partTimes nextResolvedPieces = partInstancesInfo.next.pieceInstances.map((instance) => - resolvePrunedPieceInstance(nowInPart, instance) + resolvePrunedPieceInstance(partTimes, instance) ) // Translate start to absolute times @@ -67,9 +67,9 @@ export function getResolvedPiecesForPartInstancesOnTimeline( } // Calculate the current part - const nowInCurrentPart = partInstancesInfo.current.nowInPart + const currentPartTimes = partInstancesInfo.current.partTimes const currentResolvedPieces = partInstancesInfo.current.pieceInstances.map((instance) => - resolvePrunedPieceInstance(nowInCurrentPart, instance) + resolvePrunedPieceInstance(currentPartTimes, instance) ) // Translate start to absolute times @@ -77,16 +77,16 @@ export function getResolvedPiecesForPartInstancesOnTimeline( // Calculate the previous part let previousResolvedPieces: ResolvedPieceInstance[] = [] - if (partInstancesInfo.previous?.partStarted) { - const nowInPart = partInstancesInfo.previous.nowInPart + if (partInstancesInfo.previous?.partTimes.partStartTime) { + const partTimes = partInstancesInfo.previous.partTimes previousResolvedPieces = partInstancesInfo.previous.pieceInstances.map((instance) => - resolvePrunedPieceInstance(nowInPart, instance) + resolvePrunedPieceInstance(partTimes, instance) ) // Translate start to absolute times offsetResolvedStartAndCapDuration( previousResolvedPieces, - partInstancesInfo.previous.partStarted, + partInstancesInfo.previous.partTimes.partStartTime, currentPartStarted ) } diff --git a/packages/job-worker/src/playout/setNext.ts b/packages/job-worker/src/playout/setNext.ts index 377bc0336d..0ded6ee6f9 100644 --- a/packages/job-worker/src/playout/setNext.ts +++ b/packages/job-worker/src/playout/setNext.ts @@ -8,6 +8,7 @@ import { PlayoutPartInstanceModel } from './model/PlayoutPartInstanceModel' import { PlayoutSegmentModel } from './model/PlayoutSegmentModel' import { fetchPiecesThatMayBeActiveForPart, + getBaselineInfinitesForPart, getPieceInstancesForPart, syncPlayheadInfinitesForNextPartInstance, } from './infinites' @@ -281,7 +282,13 @@ async function prepareExistingPartInstanceForBeingNexted( playoutModel: PlayoutModel, instance: PlayoutPartInstanceModel ): Promise { - await syncPlayheadInfinitesForNextPartInstance(context, playoutModel, playoutModel.currentPartInstance, instance) + await syncPlayheadInfinitesForNextPartInstance( + context, + playoutModel, + undefined, // Any ingest model must have been fully written before we get here + playoutModel.currentPartInstance, + instance + ) return instance } @@ -295,6 +302,8 @@ async function preparePartInstanceForPartBeingNexted( const rundown = playoutModel.getRundown(nextPart.rundownId) if (!rundown) throw new Error(`Could not find rundown ${nextPart.rundownId}`) + const partInstanceId = protectString('') // Replaced inside playoutModel.createInstanceForPart + const possiblePieces = await fetchPiecesThatMayBeActiveForPart(context, playoutModel, undefined, nextPart) const newPieceInstances = getPieceInstancesForPart( context, @@ -303,9 +312,21 @@ async function preparePartInstanceForPartBeingNexted( rundown, nextPart, possiblePieces, - protectString('') // Replaced inside playoutModel.createInstanceForPart + partInstanceId ) + if (currentPartInstance === null) { + // This is the first take of the rundown, ensure the baseline infinites are loaded + const baselineInfinites = await getBaselineInfinitesForPart( + context, + playoutModel, + undefined, // Any ingest model must have been fully written before we get here + nextPart, + partInstanceId + ) + newPieceInstances.push(...baselineInfinites) + } + return playoutModel.createInstanceForPart(nextPart, newPieceInstances) } diff --git a/packages/job-worker/src/playout/snapshot.ts b/packages/job-worker/src/playout/snapshot.ts index 44aa55005e..7de4210887 100644 --- a/packages/job-worker/src/playout/snapshot.ts +++ b/packages/job-worker/src/playout/snapshot.ts @@ -224,9 +224,10 @@ export async function handleRestorePlaylistSnapshot( delete pieceOld.rundownId } if (pieceOld.partId) { - piece.startPartId = pieceOld.partId + const partId = pieceOld.partId + piece.startPartId = partId delete pieceOld.partId - piece.startSegmentId = partSegmentIds[unprotectString(piece.startPartId)] + piece.startSegmentId = partSegmentIds[unprotectString(partId)] } } @@ -273,12 +274,16 @@ export async function handleRestorePlaylistSnapshot( for (const piece of snapshot.pieces) { const oldId = piece._id piece.startRundownId = getNewRundownId(piece.startRundownId) - piece.startPartId = - partIdMap.get(piece.startPartId) || - getRandomIdAndWarn(`piece.startPartId=${piece.startPartId} of piece=${piece._id}`) - piece.startSegmentId = - segmentIdMap.get(piece.startSegmentId) || - getRandomIdAndWarn(`piece.startSegmentId=${piece.startSegmentId} of piece=${piece._id}`) + if (piece.startPartId) { + piece.startPartId = + partIdMap.get(piece.startPartId) || + getRandomIdAndWarn(`piece.startPartId=${piece.startPartId} of piece=${piece._id}`) + } + if (piece.startSegmentId) { + piece.startSegmentId = + segmentIdMap.get(piece.startSegmentId) || + getRandomIdAndWarn(`piece.startSegmentId=${piece.startSegmentId} of piece=${piece._id}`) + } piece._id = getRandomId() pieceIdMap.set(oldId, piece._id) } @@ -329,7 +334,8 @@ export async function handleRestorePlaylistSnapshot( case ExpectedPackageDBType.ADLIB_PIECE: case ExpectedPackageDBType.ADLIB_ACTION: case ExpectedPackageDBType.BASELINE_ADLIB_PIECE: - case ExpectedPackageDBType.BASELINE_ADLIB_ACTION: { + case ExpectedPackageDBType.BASELINE_ADLIB_ACTION: + case ExpectedPackageDBType.BASELINE_PIECE: { expectedPackage.pieceId = pieceIdMap.get(expectedPackage.pieceId) || getRandomIdAndWarn(`expectedPackage.pieceId=${expectedPackage.pieceId}`) diff --git a/packages/job-worker/src/playout/take.ts b/packages/job-worker/src/playout/take.ts index d5b737ce26..f36c140d1a 100644 --- a/packages/job-worker/src/playout/take.ts +++ b/packages/job-worker/src/playout/take.ts @@ -22,7 +22,10 @@ import { WrappedShowStyleBlueprint } from '../blueprints/cache' import { innerStopPieces } from './adlibUtils' import { reportPartInstanceHasStarted, reportPartInstanceHasStopped } from './timings/partPlayback' import { convertPartInstanceToBlueprints, convertResolvedPieceInstanceToBlueprints } from '../blueprints/context/lib' -import { processAndPrunePieceInstanceTimings } from '@sofie-automation/corelib/dist/playout/processAndPrune' +import { + createPartCurrentTimes, + processAndPrunePieceInstanceTimings, +} from '@sofie-automation/corelib/dist/playout/processAndPrune' import { TakeNextPartProps } from '@sofie-automation/corelib/dist/worker/studio' import { runJobWithPlayoutModel } from './lock' import _ = require('underscore') @@ -542,10 +545,11 @@ export function updatePartInstanceOnTake( } // calculate and cache playout timing properties, so that we don't depend on the previousPartInstance: + const partTimes = createPartCurrentTimes(getCurrentTime(), null) const tmpTakePieces = processAndPrunePieceInstanceTimings( showStyle.sourceLayers, takePartInstance.pieceInstances.map((p) => p.pieceInstance), - 0 + partTimes ) const partPlayoutTimings = playoutModel.calculatePartTimings(currentPartInstance, takePartInstance, tmpTakePieces) 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 af3cdd32b5..d58d75ed12 100644 --- a/packages/job-worker/src/playout/timeline/__tests__/rundown.test.ts +++ b/packages/job-worker/src/playout/timeline/__tests__/rundown.test.ts @@ -10,7 +10,10 @@ import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { transformTimeline } from '@sofie-automation/corelib/dist/playout/timeline' import { deleteAllUndefinedProperties, getRandomId } from '@sofie-automation/corelib/dist/lib' import { PieceInstance, PieceInstancePiece } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' -import { PieceInstanceWithTimings } from '@sofie-automation/corelib/dist/playout/processAndPrune' +import { + createPartCurrentTimes, + PieceInstanceWithTimings, +} from '@sofie-automation/corelib/dist/playout/processAndPrune' import { EmptyPieceTimelineObjectsBlob } from '@sofie-automation/corelib/dist/dataModel/Piece' import { IBlueprintPieceType, PieceLifespan } from '@sofie-automation/blueprints-integration' import { getPartGroupId } from '@sofie-automation/corelib/dist/playout/ids' @@ -70,6 +73,8 @@ function transformTimelineIntoSimplifiedForm(res: RundownTimelineResult) { * inside of this will have their own tests to stress difference scenarios. */ describe('buildTimelineObjsForRundown', () => { + const currentTime = 5678 + function createMockPlaylist(selectedPartInfos: SelectedPartInstancesTimelineInfo): DBRundownPlaylist { function convertSelectedPartInstance( info: SelectedPartInstanceTimelineInfo | undefined @@ -196,8 +201,7 @@ describe('buildTimelineObjsForRundown', () => { const selectedPartInfos: SelectedPartInstancesTimelineInfo = { previous: { - nowInPart: 1234, - partStarted: 5678, + partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance('part0'), pieceInstances: [], calculatedTimings: DEFAULT_PART_TIMINGS, @@ -217,8 +221,7 @@ describe('buildTimelineObjsForRundown', () => { const selectedPartInfos: SelectedPartInstancesTimelineInfo = { current: { - nowInPart: 1234, - partStarted: 5678, + partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance('part0'), pieceInstances: [createMockPieceInstance('piece0')], calculatedTimings: DEFAULT_PART_TIMINGS, @@ -243,8 +246,7 @@ describe('buildTimelineObjsForRundown', () => { const selectedPartInfos: SelectedPartInstancesTimelineInfo = { current: { - nowInPart: 1234, - partStarted: 5678, + partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance( 'part0', {}, @@ -277,16 +279,14 @@ describe('buildTimelineObjsForRundown', () => { const selectedPartInfos: SelectedPartInstancesTimelineInfo = { current: { - nowInPart: 1234, - partStarted: 5678, + partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance('part0'), pieceInstances: [createMockPieceInstance('piece0')], calculatedTimings: DEFAULT_PART_TIMINGS, regenerateTimelineAt: undefined, }, next: { - nowInPart: 0, - partStarted: undefined, + partTimes: createPartCurrentTimes(currentTime, undefined), partInstance: createMockPartInstance('part1'), pieceInstances: [createMockPieceInstance('piece1')], calculatedTimings: DEFAULT_PART_TIMINGS, @@ -312,16 +312,14 @@ describe('buildTimelineObjsForRundown', () => { const selectedPartInfos: SelectedPartInstancesTimelineInfo = { current: { - nowInPart: 1234, - partStarted: 5678, + partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance('part0', { autoNext: true, expectedDuration: 5000 }), pieceInstances: [createMockPieceInstance('piece0')], calculatedTimings: DEFAULT_PART_TIMINGS, regenerateTimelineAt: undefined, }, next: { - nowInPart: 0, - partStarted: undefined, + partTimes: createPartCurrentTimes(currentTime, undefined), partInstance: createMockPartInstance('part1'), pieceInstances: [createMockPieceInstance('piece1')], calculatedTimings: DEFAULT_PART_TIMINGS, @@ -347,8 +345,7 @@ describe('buildTimelineObjsForRundown', () => { const selectedPartInfos: SelectedPartInstancesTimelineInfo = { previous: { - nowInPart: 9999, - partStarted: 1234, + partTimes: createPartCurrentTimes(currentTime, 1234), partInstance: createMockPartInstance( 'part9', { autoNext: true, expectedDuration: 5000 }, @@ -363,8 +360,7 @@ describe('buildTimelineObjsForRundown', () => { regenerateTimelineAt: undefined, }, current: { - nowInPart: 1234, - partStarted: 5678, + partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance('part0'), pieceInstances: [createMockPieceInstance('piece0')], calculatedTimings: DEFAULT_PART_TIMINGS, @@ -391,8 +387,7 @@ describe('buildTimelineObjsForRundown', () => { const selectedPartInfos: SelectedPartInstancesTimelineInfo = { previous: { - nowInPart: 9999, - partStarted: 1234, + partTimes: createPartCurrentTimes(currentTime, 1234), partInstance: createMockPartInstance( 'part9', { autoNext: true, expectedDuration: 5000 }, @@ -407,8 +402,7 @@ describe('buildTimelineObjsForRundown', () => { regenerateTimelineAt: undefined, }, current: { - nowInPart: 1234, - partStarted: 5678, + partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance('part0'), pieceInstances: [createMockPieceInstance('piece0')], calculatedTimings: { @@ -441,8 +435,7 @@ describe('buildTimelineObjsForRundown', () => { const selectedPartInfos: SelectedPartInstancesTimelineInfo = { previous: { - nowInPart: 9999, - partStarted: 1234, + partTimes: createPartCurrentTimes(currentTime, 1234), partInstance: createMockPartInstance( 'part9', { autoNext: true, expectedDuration: 5000 }, @@ -462,8 +455,7 @@ describe('buildTimelineObjsForRundown', () => { regenerateTimelineAt: undefined, }, current: { - nowInPart: 1234, - partStarted: 5678, + partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance('part0'), pieceInstances: [createMockPieceInstance('piece0')], calculatedTimings: { @@ -496,16 +488,14 @@ describe('buildTimelineObjsForRundown', () => { const selectedPartInfos: SelectedPartInstancesTimelineInfo = { current: { - nowInPart: 1234, - partStarted: 5678, + partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance('part0', { autoNext: true, expectedDuration: 5000 }), pieceInstances: [createMockPieceInstance('piece0')], calculatedTimings: DEFAULT_PART_TIMINGS, regenerateTimelineAt: undefined, }, next: { - nowInPart: 0, - partStarted: undefined, + partTimes: createPartCurrentTimes(currentTime, undefined), partInstance: createMockPartInstance('part1'), pieceInstances: [createMockPieceInstance('piece1')], calculatedTimings: { @@ -540,8 +530,7 @@ describe('buildTimelineObjsForRundown', () => { const selectedPartInfos: SelectedPartInstancesTimelineInfo = { current: { - nowInPart: 1234, - partStarted: 5678, + partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance( 'part0', { autoNext: true, expectedDuration: 5000 }, @@ -561,8 +550,7 @@ describe('buildTimelineObjsForRundown', () => { regenerateTimelineAt: undefined, }, next: { - nowInPart: 0, - partStarted: undefined, + partTimes: createPartCurrentTimes(currentTime, undefined), partInstance: createMockPartInstance( 'part1', {}, @@ -601,8 +589,7 @@ describe('buildTimelineObjsForRundown', () => { describe('infinite pieces', () => { const PREVIOUS_PART_INSTANCE: SelectedPartInstanceTimelineInfo = { - nowInPart: 9999, - partStarted: 1234, + partTimes: createPartCurrentTimes(currentTime, 1234), partInstance: createMockPartInstance( 'part9', { autoNext: true, expectedDuration: 5000 }, @@ -623,8 +610,7 @@ describe('buildTimelineObjsForRundown', () => { const selectedPartInfos: SelectedPartInstancesTimelineInfo = { previous: PREVIOUS_PART_INSTANCE, current: { - nowInPart: 1234, - partStarted: 5678, + partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance('part0'), pieceInstances: [ createMockPieceInstance('piece0'), @@ -655,8 +641,7 @@ describe('buildTimelineObjsForRundown', () => { ], }, current: { - nowInPart: 1234, - partStarted: 5678, + partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance('part0'), pieceInstances: [createMockPieceInstance('piece0')], calculatedTimings: DEFAULT_PART_TIMINGS, @@ -684,8 +669,7 @@ describe('buildTimelineObjsForRundown', () => { ], }, current: { - nowInPart: 1234, - partStarted: 5678, + partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance('part0'), pieceInstances: [createMockPieceInstance('piece0')], calculatedTimings: DEFAULT_PART_TIMINGS, @@ -712,8 +696,7 @@ describe('buildTimelineObjsForRundown', () => { pieceInstances: [...PREVIOUS_PART_INSTANCE.pieceInstances, infinitePiece], }, current: { - nowInPart: 1234, - partStarted: 5678, + partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance('part0'), pieceInstances: [createMockPieceInstance('piece0'), continueInfinitePiece(infinitePiece)], calculatedTimings: DEFAULT_PART_TIMINGS, @@ -736,8 +719,7 @@ describe('buildTimelineObjsForRundown', () => { const selectedPartInfos: SelectedPartInstancesTimelineInfo = { current: { - nowInPart: 1234, - partStarted: 5678, + partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance( 'part0', { autoNext: true, expectedDuration: 5000 }, @@ -752,8 +734,7 @@ describe('buildTimelineObjsForRundown', () => { regenerateTimelineAt: undefined, }, next: { - nowInPart: 0, - partStarted: undefined, + partTimes: createPartCurrentTimes(currentTime, undefined), partInstance: createMockPartInstance( 'part1', {}, @@ -782,8 +763,7 @@ describe('buildTimelineObjsForRundown', () => { const selectedPartInfos: SelectedPartInstancesTimelineInfo = { current: { - nowInPart: 1234, - partStarted: 5678, + partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance( 'part0', { autoNext: true, expectedDuration: 5000 }, @@ -798,8 +778,7 @@ describe('buildTimelineObjsForRundown', () => { regenerateTimelineAt: undefined, }, next: { - nowInPart: 0, - partStarted: undefined, + partTimes: createPartCurrentTimes(currentTime, undefined), partInstance: createMockPartInstance( 'part1', {}, @@ -831,8 +810,7 @@ describe('buildTimelineObjsForRundown', () => { const selectedPartInfos: SelectedPartInstancesTimelineInfo = { current: { - nowInPart: 1234, - partStarted: 5678, + partTimes: createPartCurrentTimes(currentTime, 5678), partInstance: createMockPartInstance( 'part0', { autoNext: true, expectedDuration: 5000 }, @@ -850,8 +828,7 @@ describe('buildTimelineObjsForRundown', () => { regenerateTimelineAt: undefined, }, next: { - nowInPart: 0, - partStarted: undefined, + partTimes: createPartCurrentTimes(currentTime, undefined), partInstance: createMockPartInstance( 'part1', {}, diff --git a/packages/job-worker/src/playout/timeline/generate.ts b/packages/job-worker/src/playout/timeline/generate.ts index acc0b8cbe4..8abb6042ed 100644 --- a/packages/job-worker/src/playout/timeline/generate.ts +++ b/packages/job-worker/src/playout/timeline/generate.ts @@ -22,6 +22,8 @@ import { getResolvedPiecesForPartInstancesOnTimeline } from '../resolvedPieces' import { processAndPrunePieceInstanceTimings, PieceInstanceWithTimings, + createPartCurrentTimes, + PartCurrentTimes, } from '@sofie-automation/corelib/dist/playout/processAndPrune' import { StudioPlayoutModel, StudioPlayoutModelBase } from '../../studio/model/StudioPlayoutModel' import { getLookeaheadObjects } from '../lookahead' @@ -43,6 +45,9 @@ import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyE import { PlayoutPartInstanceModel } from '../model/PlayoutPartInstanceModel' import { PersistentPlayoutStateStore } from '../../blueprints/context/services/PersistantStateStore' import { PlayoutChangedType } from '@sofie-automation/shared-lib/dist/peripheralDevice/peripheralDeviceAPI' +import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' + +const DEFAULT_ABSOLUTE_PIECE_PREPARE_TIME = 30000 function isModelForStudio(model: StudioPlayoutModelBase): model is StudioPlayoutModel { const tmp = model as StudioPlayoutModel @@ -246,8 +251,7 @@ export interface SelectedPartInstancesTimelineInfo { next?: SelectedPartInstanceTimelineInfo } export interface SelectedPartInstanceTimelineInfo { - nowInPart: number - partStarted: number | undefined + partTimes: PartCurrentTimes partInstance: ReadonlyDeep pieceInstances: PieceInstanceWithTimings[] calculatedTimings: PartCalculatedTimings @@ -255,29 +259,44 @@ export interface SelectedPartInstanceTimelineInfo { } function getPartInstanceTimelineInfo( + absolutePiecePrepareTime: number, currentTime: Time, sourceLayers: SourceLayers, partInstance: PlayoutPartInstanceModel | null ): SelectedPartInstanceTimelineInfo | undefined { if (!partInstance) return undefined - const partStarted = partInstance.partInstance.timings?.plannedStartedPlayback - const nowInPart = partStarted === undefined ? 0 : currentTime - partStarted - const pieceInstances = processAndPrunePieceInstanceTimings( - sourceLayers, - partInstance.pieceInstances.map((p) => p.pieceInstance), - nowInPart - ) + const partTimes = createPartCurrentTimes(currentTime, partInstance.partInstance.timings?.plannedStartedPlayback) + + let regenerateTimelineAt: Time | undefined = undefined + + const rawPieceInstances: ReadonlyDeep[] = [] + for (const { pieceInstance } of partInstance.pieceInstances) { + if ( + pieceInstance.piece.enable.isAbsolute && + typeof pieceInstance.piece.enable.start === 'number' && + pieceInstance.piece.enable.start > currentTime + absolutePiecePrepareTime + ) { + // This absolute timed piece is starting too far in the future, ignore it + regenerateTimelineAt = Math.min( + regenerateTimelineAt ?? Number.POSITIVE_INFINITY, + pieceInstance.piece.enable.start - absolutePiecePrepareTime + ) + + continue + } + + rawPieceInstances.push(pieceInstance) + } const partInstanceWithOverrides = partInstance.getPartInstanceWithQuickLoopOverrides() return { partInstance: partInstanceWithOverrides, - pieceInstances, - nowInPart, - partStarted, + pieceInstances: processAndPrunePieceInstanceTimings(sourceLayers, rawPieceInstances, partTimes), + partTimes, // Approximate `calculatedTimings`, for the partInstances which already have it cached - calculatedTimings: getPartTimingsOrDefaults(partInstanceWithOverrides, pieceInstances), - regenerateTimelineAt: undefined, // Future use + calculatedTimings: getPartTimingsOrDefaults(partInstanceWithOverrides, rawPieceInstances), + regenerateTimelineAt, } } @@ -318,10 +337,27 @@ async function getTimelineRundown( } const currentTime = getCurrentTime() + const absolutePiecePrepareTime = + context.studio.settings.rundownGlobalPiecesPrepareTime || DEFAULT_ABSOLUTE_PIECE_PREPARE_TIME const partInstancesInfo: SelectedPartInstancesTimelineInfo = { - current: getPartInstanceTimelineInfo(currentTime, showStyle.sourceLayers, currentPartInstance), - next: getPartInstanceTimelineInfo(currentTime, showStyle.sourceLayers, nextPartInstance), - previous: getPartInstanceTimelineInfo(currentTime, showStyle.sourceLayers, previousPartInstance), + current: getPartInstanceTimelineInfo( + absolutePiecePrepareTime, + currentTime, + showStyle.sourceLayers, + currentPartInstance + ), + next: getPartInstanceTimelineInfo( + absolutePiecePrepareTime, + currentTime, + showStyle.sourceLayers, + nextPartInstance + ), + previous: getPartInstanceTimelineInfo( + absolutePiecePrepareTime, + currentTime, + showStyle.sourceLayers, + previousPartInstance + ), } if (partInstancesInfo.next && nextPartInstance) { diff --git a/packages/job-worker/src/playout/timeline/multi-gateway.ts b/packages/job-worker/src/playout/timeline/multi-gateway.ts index 704e37726e..5da5296c7d 100644 --- a/packages/job-worker/src/playout/timeline/multi-gateway.ts +++ b/packages/job-worker/src/playout/timeline/multi-gateway.ts @@ -262,18 +262,24 @@ function setPlannedTimingsOnPieceInstance( } if (typeof pieceInstance.pieceInstance.piece.enable.start === 'number') { - const plannedStart = partPlannedStart + pieceInstance.pieceInstance.piece.enable.start + const plannedStart = + (pieceInstance.pieceInstance.piece.enable.isAbsolute ? 0 : partPlannedStart) + + pieceInstance.pieceInstance.piece.enable.start pieceInstance.setPlannedStartedPlayback(plannedStart) const userDurationEnd = pieceInstance.pieceInstance.userDuration && 'endRelativeToPart' in pieceInstance.pieceInstance.userDuration ? pieceInstance.pieceInstance.userDuration.endRelativeToPart : null - const plannedEnd = - userDurationEnd ?? - (pieceInstance.pieceInstance.piece.enable.duration - ? plannedStart + pieceInstance.pieceInstance.piece.enable.duration - : partPlannedEnd) + + let plannedEnd: number | undefined = userDurationEnd ?? undefined + if (plannedEnd === undefined) { + if (pieceInstance.pieceInstance.piece.enable.duration !== undefined) { + plannedEnd = plannedStart + pieceInstance.pieceInstance.piece.enable.duration + } else if (!pieceInstance.pieceInstance.piece.enable.isAbsolute) { + plannedEnd = partPlannedEnd + } + } pieceInstance.setPlannedStoppedPlayback(plannedEnd) } diff --git a/packages/job-worker/src/playout/timeline/part.ts b/packages/job-worker/src/playout/timeline/part.ts index 2c9dddbc3d..a13c76a599 100644 --- a/packages/job-worker/src/playout/timeline/part.ts +++ b/packages/job-worker/src/playout/timeline/part.ts @@ -32,7 +32,7 @@ export function transformPartIntoTimeline( ): Array { const span = context.startSpan('transformPartIntoTimeline') - const nowInParentGroup = partInfo.nowInPart + const nowInParentGroup = partInfo.partTimes.nowInPart const partTimings = partInfo.calculatedTimings const outTransition = partInfo.partInstance.part.outTransition ?? null diff --git a/packages/job-worker/src/playout/timeline/pieceGroup.ts b/packages/job-worker/src/playout/timeline/pieceGroup.ts index 45ceeb43e7..aa7f379dfb 100644 --- a/packages/job-worker/src/playout/timeline/pieceGroup.ts +++ b/packages/job-worker/src/playout/timeline/pieceGroup.ts @@ -137,7 +137,7 @@ export function createPieceGroupAndCap( // If the start has been adjusted, the end needs to be updated to compensate if (typeof pieceInstance.resolvedEndCap === 'number') { resolvedEndCap = pieceInstance.resolvedEndCap - (pieceStartOffset ?? 0) - } else if (pieceInstance.resolvedEndCap) { + } else if (pieceInstance.resolvedEndCap || controlObj.enable.end === 'now') { // TODO - there could already be a piece with a cap of 'now' that we could use as our end time // As the cap is for 'now', rather than try to get tsr to understand `end: 'now'`, we can create a 'now' object to tranlate it const nowObj = literal>({ @@ -157,7 +157,13 @@ export function createPieceGroupAndCap( priority: 0, }) capObjs.push(nowObj) - resolvedEndCap = `#${nowObj.id}.start + ${pieceInstance.resolvedEndCap.offsetFromNow}` + + resolvedEndCap = `#${nowObj.id}.start + ${pieceInstance.resolvedEndCap?.offsetFromNow ?? 0}` + + // If the object has an end of now, we can remove it as it will be replaced by the `resolvedEndCap` + if (controlObj.enable.end === 'now') { + delete controlObj.enable.end + } } if (controlObj.enable.duration !== undefined || controlObj.enable.end !== undefined) { diff --git a/packages/job-worker/src/playout/timeline/rundown.ts b/packages/job-worker/src/playout/timeline/rundown.ts index 0fcb057a88..b77e501984 100644 --- a/packages/job-worker/src/playout/timeline/rundown.ts +++ b/packages/job-worker/src/playout/timeline/rundown.ts @@ -273,9 +273,25 @@ function generateCurrentInfinitePieceObjects( return [] } - const infiniteGroup = createPartGroup(currentPartInfo.partInstance, { - start: `#${timingContext.currentPartGroup.id}.start`, // This gets overriden with a concrete time if the original piece is known to have already started - }) + const { infiniteGroupEnable, pieceEnable, nowInParent } = calculateInfinitePieceEnable( + currentPartInfo, + timingContext, + pieceInstance, + currentTime, + currentPartInstanceTimings + ) + + const { pieceInstanceWithUpdatedEndCap, cappedInfiniteGroupEnable } = applyInfinitePieceGroupEndCap( + currentPartInfo, + timingContext, + pieceInstance, + infiniteGroupEnable, + currentPartInstanceTimings, + nextPartInstanceTimings, + nextPartInfinites.get(pieceInstance.infinite.infiniteInstanceId) + ) + + const infiniteGroup = createPartGroup(currentPartInfo.partInstance, cappedInfiniteGroupEnable) infiniteGroup.id = getInfinitePartGroupId(pieceInstance._id) // This doesnt want to belong to a part, so force the ids infiniteGroup.priority = 1 @@ -285,6 +301,34 @@ function generateCurrentInfinitePieceObjects( groupClasses.push('continues_infinite') } + // Still show objects flagged as 'HoldMode.EXCEPT' if this is a infinite continuation as they belong to the previous too + const isOriginOfInfinite = pieceInstance.piece.startPartId !== currentPartInfo.partInstance.part._id + const isInHold = activePlaylist.holdState === RundownHoldState.ACTIVE + + return [ + infiniteGroup, + ...transformPieceGroupAndObjects( + activePlaylist._id, + infiniteGroup, + nowInParent, + pieceInstanceWithUpdatedEndCap, + pieceEnable, + 0, + groupClasses, + isInHold, + isOriginOfInfinite + ), + ] +} + +function calculateInfinitePieceEnable( + currentPartInfo: SelectedPartInstanceTimelineInfo, + timingContext: RundownTimelineTimingContext, + pieceInstance: ReadonlyDeep, + // infiniteGroup: TimelineObjGroupPart & OnGenerateTimelineObjExt, + currentTime: number, + currentPartInstanceTimings: PartCalculatedTimings +) { const pieceEnable = getPieceEnableInsidePart( pieceInstance, currentPartInstanceTimings, @@ -293,8 +337,28 @@ function generateCurrentInfinitePieceObjects( timingContext.currentPartGroup.enable.duration !== undefined ) - let nowInParent = currentPartInfo.nowInPart // Where is 'now' inside of the infiniteGroup? - if (pieceInstance.plannedStartedPlayback !== undefined) { + let infiniteGroupEnable: PartEnable = { + start: `#${timingContext.currentPartGroup.id}.start`, // This gets overriden with a concrete time if the original piece is known to have already started + } + + let nowInParent = currentPartInfo.partTimes.nowInPart // Where is 'now' inside of the infiniteGroup? + if (pieceInstance.piece.enable.isAbsolute) { + // Piece is absolute, so we should use the absolute time. This is a special case for pieces belonging to the rundown directly. + + const infiniteGroupStart = pieceInstance.plannedStartedPlayback ?? pieceInstance.piece.enable.start + + if (typeof infiniteGroupStart === 'number') { + nowInParent = currentTime - infiniteGroupStart + } else { + // We should never hit this, but in case start is "now" + nowInParent = 0 + } + + infiniteGroupEnable = { start: infiniteGroupStart } + pieceEnable.start = 0 + + // Future: should this consider the prerollDuration? + } else if (pieceInstance.plannedStartedPlayback !== undefined) { // We have a absolute start time, so we should use that. let infiniteGroupStart = pieceInstance.plannedStartedPlayback nowInParent = currentTime - pieceInstance.plannedStartedPlayback @@ -311,30 +375,47 @@ function generateCurrentInfinitePieceObjects( pieceEnable.start = 0 } - infiniteGroup.enable = { start: infiniteGroupStart } + infiniteGroupEnable = { start: infiniteGroupStart } // If an end time has been set by a hotkey, then update the duration to be correct if (pieceInstance.userDuration && pieceInstance.piece.enable.start !== 'now') { if ('endRelativeToPart' in pieceInstance.userDuration) { - infiniteGroup.enable.duration = + infiniteGroupEnable.duration = pieceInstance.userDuration.endRelativeToPart - pieceInstance.piece.enable.start } else { - infiniteGroup.enable.end = 'now' + infiniteGroupEnable.end = 'now' } } } + return { + pieceEnable, + infiniteGroupEnable, + nowInParent, + } +} + +function applyInfinitePieceGroupEndCap( + currentPartInfo: SelectedPartInstanceTimelineInfo, + timingContext: RundownTimelineTimingContext, + pieceInstance: ReadonlyDeep, + infiniteGroupEnable: Readonly, + currentPartInstanceTimings: PartCalculatedTimings, + nextPartInstanceTimings: PartCalculatedTimings | null, + infiniteInNextPart: PieceInstanceWithTimings | undefined +) { + const cappedInfiniteGroupEnable: PartEnable = { ...infiniteGroupEnable } + // If this infinite piece continues to the next part, and has a duration then we should respect that in case it is really close to the take const hasDurationOrEnd = (enable: TSR.Timeline.TimelineEnable) => enable.duration !== undefined || enable.end !== undefined - const infiniteInNextPart = nextPartInfinites.get(pieceInstance.infinite.infiniteInstanceId) if ( infiniteInNextPart && - !hasDurationOrEnd(infiniteGroup.enable) && + !hasDurationOrEnd(cappedInfiniteGroupEnable) && hasDurationOrEnd(infiniteInNextPart.piece.enable) ) { // infiniteGroup.enable.end = infiniteInNextPart.piece.enable.end - infiniteGroup.enable.duration = infiniteInNextPart.piece.enable.duration + cappedInfiniteGroupEnable.duration = infiniteInNextPart.piece.enable.duration } const pieceInstanceWithUpdatedEndCap: PieceInstanceWithTimings = { ...pieceInstance } @@ -342,16 +423,16 @@ function generateCurrentInfinitePieceObjects( if (pieceInstance.resolvedEndCap) { // If the cap is a number, it is relative to the part, not the parent group so needs to be handled here if (typeof pieceInstance.resolvedEndCap === 'number') { - infiniteGroup.enable.end = `#${timingContext.currentPartGroup.id}.start + ${pieceInstance.resolvedEndCap}` - delete infiniteGroup.enable.duration + cappedInfiniteGroupEnable.end = `#${timingContext.currentPartGroup.id}.start + ${pieceInstance.resolvedEndCap}` + delete cappedInfiniteGroupEnable.duration delete pieceInstanceWithUpdatedEndCap.resolvedEndCap } } else if ( // If this piece does not continue in the next part, then set it to end with the part it belongs to !infiniteInNextPart && currentPartInfo.partInstance.part.autoNext && - infiniteGroup.enable.duration === undefined && - infiniteGroup.enable.end === undefined + cappedInfiniteGroupEnable.duration === undefined && + cappedInfiniteGroupEnable.end === undefined ) { let endOffset = 0 @@ -363,27 +444,10 @@ function generateCurrentInfinitePieceObjects( endOffset -= nextPartInstanceTimings.fromPartKeepalive // cap relative to the currentPartGroup - infiniteGroup.enable.end = `#${timingContext.currentPartGroup.id}.end + ${endOffset}` + cappedInfiniteGroupEnable.end = `#${timingContext.currentPartGroup.id}.end + ${endOffset}` } - // Still show objects flagged as 'HoldMode.EXCEPT' if this is a infinite continuation as they belong to the previous too - const isOriginOfInfinite = pieceInstance.piece.startPartId !== currentPartInfo.partInstance.part._id - const isInHold = activePlaylist.holdState === RundownHoldState.ACTIVE - - return [ - infiniteGroup, - ...transformPieceGroupAndObjects( - activePlaylist._id, - infiniteGroup, - nowInParent, - pieceInstanceWithUpdatedEndCap, - pieceEnable, - 0, - groupClasses, - isInHold, - isOriginOfInfinite - ), - ] + return { pieceInstanceWithUpdatedEndCap, cappedInfiniteGroupEnable } } function generatePreviousPartInstanceObjects( diff --git a/packages/job-worker/src/playout/timings/piecePlayback.ts b/packages/job-worker/src/playout/timings/piecePlayback.ts index bd1d76b8a3..6169cf3e95 100644 --- a/packages/job-worker/src/playout/timings/piecePlayback.ts +++ b/packages/job-worker/src/playout/timings/piecePlayback.ts @@ -23,24 +23,19 @@ export function onPiecePlaybackStarted( ): void { const playlist = playoutModel.playlist + if (!playlist.activationId) { + logger.warn(`onPiecePlaybackStarted: Received for inactive RundownPlaylist "${playlist._id}"`) + return + } + const partInstance = playoutModel.getPartInstance(data.partInstanceId) if (!partInstance) { - if (!playlist.activationId) { - logger.warn(`onPiecePlaybackStarted: Received for inactive RundownPlaylist "${playlist._id}"`) - } else { - throw new Error(`PartInstance "${data.partInstanceId}" in RundownPlaylist "${playlist._id}" not found!`) - } - return + throw new Error(`PartInstance "${data.partInstanceId}" in RundownPlaylist "${playlist._id}" not found!`) } const pieceInstance = partInstance.getPieceInstance(data.pieceInstanceId) if (!pieceInstance) { - if (!playlist.activationId) { - logger.warn(`onPiecePlaybackStarted: Received for inactive RundownPlaylist "${playlist._id}"`) - } else { - throw new Error(`PieceInstance "${data.partInstanceId}" in RundownPlaylist "${playlist._id}" not found!`) - } - return + throw new Error(`PieceInstance "${data.partInstanceId}" in RundownPlaylist "${playlist._id}" not found!`) } const isPlaying = !!( @@ -75,6 +70,11 @@ export function onPiecePlaybackStopped( ): void { const playlist = playoutModel.playlist + if (!playlist.activationId) { + logger.warn(`onPiecePlaybackStopped: Received for inactive RundownPlaylist "${playlist._id}"`) + return + } + const partInstance = playoutModel.getPartInstance(data.partInstanceId) if (!partInstance) { // PartInstance not found, so we can rely on the onPartPlaybackStopped callback erroring @@ -83,12 +83,7 @@ export function onPiecePlaybackStopped( const pieceInstance = partInstance.getPieceInstance(data.pieceInstanceId) if (!pieceInstance) { - if (!playlist.activationId) { - logger.warn(`onPiecePlaybackStopped: Received for inactive RundownPlaylist "${playlist._id}"`) - } else { - throw new Error(`PieceInstance "${data.partInstanceId}" in RundownPlaylist "${playlist._id}" not found!`) - } - return + throw new Error(`PieceInstance "${data.partInstanceId}" in RundownPlaylist "${playlist._id}" not found!`) } const isPlaying = !!( @@ -171,6 +166,8 @@ function reportPieceHasStopped( pieceInstance.setPlannedStoppedPlayback(timestamp) } - playoutModel.queuePartInstanceTimingEvent(pieceInstance.pieceInstance.partInstanceId) + if (pieceInstance.pieceInstance.partInstanceId) { + playoutModel.queuePartInstanceTimingEvent(pieceInstance.pieceInstance.partInstanceId) + } } } diff --git a/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts b/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts index 902e6ebe6a..616f050a8a 100644 --- a/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts +++ b/packages/live-status-gateway/src/collections/pieceInstancesHandler.ts @@ -9,6 +9,7 @@ import _ = require('underscore') import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { PartInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { + createPartCurrentTimes, PieceInstanceWithTimings, processAndPrunePieceInstanceTimings, resolvePrunedPieceInstance, @@ -88,25 +89,24 @@ export class PieceInstancesHandler extends PublicationCollection< filterActive: boolean ): PieceInstanceWithTimings[] { // Approximate when 'now' is in the PartInstance, so that any adlibbed Pieces will be timed roughly correctly - const partStarted = partInstance?.timings?.plannedStartedPlayback - const nowInPart = partStarted === undefined ? 0 : Date.now() - partStarted + const partTimes = createPartCurrentTimes(Date.now(), partInstance?.timings?.plannedStartedPlayback) const prunedPieceInstances = processAndPrunePieceInstanceTimings( this._sourceLayers, pieceInstances, - nowInPart, - false, + partTimes, false ) if (!filterActive) return prunedPieceInstances return prunedPieceInstances.filter((pieceInstance) => { - const resolvedPieceInstance = resolvePrunedPieceInstance(nowInPart, pieceInstance) + const resolvedPieceInstance = resolvePrunedPieceInstance(partTimes, pieceInstance) return ( - resolvedPieceInstance.resolvedStart <= nowInPart && + resolvedPieceInstance.resolvedStart <= partTimes.nowInPart && (resolvedPieceInstance.resolvedDuration == null || - resolvedPieceInstance.resolvedStart + resolvedPieceInstance.resolvedDuration > nowInPart) && + resolvedPieceInstance.resolvedStart + resolvedPieceInstance.resolvedDuration > + partTimes.nowInPart) && pieceInstance.piece.virtual !== true && pieceInstance.disabled !== true ) diff --git a/packages/openapi/api/definitions/studios.yaml b/packages/openapi/api/definitions/studios.yaml index ae1e4851d8..225cf12ef0 100644 --- a/packages/openapi/api/definitions/studios.yaml +++ b/packages/openapi/api/definitions/studios.yaml @@ -558,6 +558,9 @@ components: allowPieceDirectPlay: type: boolean description: Whether to allow direct playing of a piece in the rundown + rundownGlobalPiecesPrepareTime: + type: number + description: How long before their start time a rundown owned piece be added to the timeline required: - frameRate diff --git a/packages/shared-lib/src/core/model/StudioSettings.ts b/packages/shared-lib/src/core/model/StudioSettings.ts index 08044eaac2..2a2b921c70 100644 --- a/packages/shared-lib/src/core/model/StudioSettings.ts +++ b/packages/shared-lib/src/core/model/StudioSettings.ts @@ -91,4 +91,9 @@ export interface IStudioSettings { * Doubleclick changes behaviour as selector for userediting */ enableUserEdits?: boolean + + /** + * How long before their start time a rundown owned piece be added to the timeline + */ + rundownGlobalPiecesPrepareTime?: number } diff --git a/packages/shared-lib/src/peripheralDevice/peripheralDeviceAPI.ts b/packages/shared-lib/src/peripheralDevice/peripheralDeviceAPI.ts index d00e62e2b4..f2253b555d 100644 --- a/packages/shared-lib/src/peripheralDevice/peripheralDeviceAPI.ts +++ b/packages/shared-lib/src/peripheralDevice/peripheralDeviceAPI.ts @@ -26,7 +26,6 @@ export type PiecePlaybackStoppedResult = PiecePlaybackStartedResult export interface TriggerRegenerationCallbackData { rundownPlaylistId: RundownPlaylistId - // partInstanceId: PartInstanceId regenerationToken: string } diff --git a/packages/webui/src/client/lib/RundownResolver.ts b/packages/webui/src/client/lib/RundownResolver.ts index 78abccf470..f34349b1ba 100644 --- a/packages/webui/src/client/lib/RundownResolver.ts +++ b/packages/webui/src/client/lib/RundownResolver.ts @@ -62,7 +62,7 @@ function fetchPiecesThatMayBeActiveForPart( segmentsToReceiveOnRundownEndFromSet: Set, rundownsToReceiveOnShowStyleEndFrom: RundownId[], /** Map of Pieces on Parts, passed through for performance */ - allPiecesCache?: Map + allPiecesCache?: Map ): Piece[] { let piecesStartingInPart: Piece[] const allPieces = allPiecesCache?.get(part._id) @@ -129,7 +129,7 @@ export function getPieceInstancesForPartInstance( currentSegment: Pick | undefined, currentPartInstancePieceInstances: PieceInstance[] | undefined, /** Map of Pieces on Parts, passed through for performance */ - allPiecesCache?: Map, + allPiecesCache?: Map, options?: FindOptions, pieceInstanceSimulation?: boolean ): PieceInstance[] { diff --git a/packages/webui/src/client/lib/rundown.ts b/packages/webui/src/client/lib/rundown.ts index 71db5aac0b..aac0b5fa64 100644 --- a/packages/webui/src/client/lib/rundown.ts +++ b/packages/webui/src/client/lib/rundown.ts @@ -26,6 +26,7 @@ import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/Rund import { literal, protectString, groupByToMap } from './tempLib' import { getCurrentTime } from './systemTime' import { + createPartCurrentTimes, processAndPrunePieceInstanceTimings, resolvePrunedPieceInstance, } from '@sofie-automation/corelib/dist/playout/processAndPrune' @@ -499,19 +500,20 @@ export namespace RundownUtils { pieceInstanceSimulation ) - const partStarted = partE.instance.timings?.plannedStartedPlayback - const nowInPart = partStarted ? getCurrentTime() - partStarted : 0 - + const partTimes = createPartCurrentTimes( + getCurrentTime(), + partE.instance.timings?.plannedStartedPlayback + ) const preprocessedPieces = processAndPrunePieceInstanceTimings( showStyleBase.sourceLayers, rawPieceInstances, - nowInPart, + partTimes, includeDisabledPieces ) // insert items into the timeline for resolution partE.pieces = preprocessedPieces.map((piece) => { - const resolvedPiece = resolvePrunedPieceInstance(nowInPart, piece) + const resolvedPiece = resolvePrunedPieceInstance(partTimes, piece) const resPiece: PieceExtended = { instance: piece, renderedDuration: resolvedPiece.resolvedDuration ?? null, diff --git a/packages/webui/src/client/lib/rundownLayouts.ts b/packages/webui/src/client/lib/rundownLayouts.ts index 8ce3944b78..585f2e8286 100644 --- a/packages/webui/src/client/lib/rundownLayouts.ts +++ b/packages/webui/src/client/lib/rundownLayouts.ts @@ -4,7 +4,10 @@ import { RundownPlaylistActivationId, StudioId, } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { processAndPrunePieceInstanceTimings } from '@sofie-automation/corelib/dist/playout/processAndPrune' +import { + createPartCurrentTimes, + processAndPrunePieceInstanceTimings, +} from '@sofie-automation/corelib/dist/playout/processAndPrune' import { UIShowStyleBase } from '@sofie-automation/meteor-lib/dist/api/showStyles' import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import { @@ -138,13 +141,11 @@ export function getUnfinishedPieceInstancesReactive( playlistActivationId: playlistActivationId, }).fetch() - const nowInPart = partInstance.timings?.plannedStartedPlayback - ? now - partInstance.timings.plannedStartedPlayback - : 0 + const partTimes = createPartCurrentTimes(now, partInstance.timings?.plannedStartedPlayback) prospectivePieces = processAndPrunePieceInstanceTimings( showStyleBase.sourceLayers, prospectivePieces, - nowInPart + partTimes ) let nearestEnd = Number.POSITIVE_INFINITY diff --git a/packages/webui/src/client/lib/rundownPlaylistUtil.ts b/packages/webui/src/client/lib/rundownPlaylistUtil.ts index b836e43695..1a3ed16321 100644 --- a/packages/webui/src/client/lib/rundownPlaylistUtil.ts +++ b/packages/webui/src/client/lib/rundownPlaylistUtil.ts @@ -164,7 +164,7 @@ export class RundownPlaylistClientUtil { static getPiecesForParts( parts: Array, piecesOptions?: Omit, 'projection'> // We are mangling fields, so block projection - ): Map { + ): Map { const allPieces = Pieces.find( { startPartId: { $in: parts } }, { diff --git a/packages/webui/src/client/lib/shelf.ts b/packages/webui/src/client/lib/shelf.ts index 7d30b95616..15b2eed2c4 100644 --- a/packages/webui/src/client/lib/shelf.ts +++ b/packages/webui/src/client/lib/shelf.ts @@ -3,13 +3,17 @@ import { PartInstance } from '@sofie-automation/meteor-lib/dist/collections/Part import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' -import { processAndPrunePieceInstanceTimings } from '@sofie-automation/corelib/dist/playout/processAndPrune' +import { + createPartCurrentTimes, + processAndPrunePieceInstanceTimings, +} from '@sofie-automation/corelib/dist/playout/processAndPrune' import { getUnfinishedPieceInstancesReactive } from './rundownLayouts' import { UIShowStyleBase } from '@sofie-automation/meteor-lib/dist/api/showStyles' import { PieceId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { PieceInstances } from '../collections' import { ReadonlyDeep } from 'type-fest' import { AdLibPieceUi } from '@sofie-automation/meteor-lib/dist/uiTypes/Adlib' +import { getCurrentTimeReactive } from './currentTimeReactive' export type { AdLibPieceUi } from '@sofie-automation/meteor-lib/dist/uiTypes/Adlib' @@ -61,10 +65,11 @@ export function getNextPiecesReactive( }).fetch() } + const partTimes = createPartCurrentTimes(getCurrentTimeReactive(), null) prospectivePieceInstances = processAndPrunePieceInstanceTimings( showsStyleBase.sourceLayers, prospectivePieceInstances, - 0 + partTimes ) return prospectivePieceInstances diff --git a/packages/webui/src/client/lib/ui/pieceUiClassNames.ts b/packages/webui/src/client/lib/ui/pieceUiClassNames.ts index 3250a84876..b9b86c49d4 100644 --- a/packages/webui/src/client/lib/ui/pieceUiClassNames.ts +++ b/packages/webui/src/client/lib/ui/pieceUiClassNames.ts @@ -32,10 +32,12 @@ export function pieceUiClassNames( : undefined, 'super-infinite': + !innerPiece.enable.isAbsolute && innerPiece.lifespan !== PieceLifespan.WithinPart && innerPiece.lifespan !== PieceLifespan.OutOnSegmentChange && innerPiece.lifespan !== PieceLifespan.OutOnSegmentEnd, 'infinite-starts': + !innerPiece.enable.isAbsolute && innerPiece.lifespan !== PieceLifespan.WithinPart && innerPiece.lifespan !== PieceLifespan.OutOnSegmentChange && innerPiece.lifespan !== PieceLifespan.OutOnSegmentEnd && diff --git a/packages/webui/src/client/ui/ClipTrimPanel/ClipTrimDialog.tsx b/packages/webui/src/client/ui/ClipTrimPanel/ClipTrimDialog.tsx index 8c5e023dc9..ce4486ac6f 100644 --- a/packages/webui/src/client/ui/ClipTrimPanel/ClipTrimDialog.tsx +++ b/packages/webui/src/client/ui/ClipTrimPanel/ClipTrimDialog.tsx @@ -56,6 +56,9 @@ export function ClipTrimDialog({ const handleAccept = useCallback((e: SomeEvent) => { onClose?.() + const startPartId = selectedPiece.startPartId + if (!startPartId) return + doUserAction( t, e, @@ -65,7 +68,7 @@ export function ClipTrimDialog({ e, ts, playlistId, - selectedPiece.startPartId, + startPartId, selectedPiece._id, state.inPoint, state.duration diff --git a/packages/webui/src/client/ui/MediaStatus/MediaStatus.tsx b/packages/webui/src/client/ui/MediaStatus/MediaStatus.tsx index 977b952892..f020d03ba2 100644 --- a/packages/webui/src/client/ui/MediaStatus/MediaStatus.tsx +++ b/packages/webui/src/client/ui/MediaStatus/MediaStatus.tsx @@ -458,14 +458,14 @@ function usePieceItems(partIds: PartId[], partMeta: Map) { const pieceItems = useTracker( () => pieces.map((piece) => { - const meta = partMeta.get(piece.startPartId) + const meta = piece.startPartId && partMeta.get(piece.startPartId) if (!meta) return return getListItemFromPieceAndPartMeta( piece._id, piece, meta, - piece.startPartId, + piece.startPartId ?? undefined, undefined, meta.segmentId, false diff --git a/packages/webui/src/client/ui/Prompter/prompter.ts b/packages/webui/src/client/ui/Prompter/prompter.ts index 9a47b5531b..03da1b7010 100644 --- a/packages/webui/src/client/ui/Prompter/prompter.ts +++ b/packages/webui/src/client/ui/Prompter/prompter.ts @@ -15,7 +15,10 @@ import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { RundownUtils } from '../../lib/rundown' import { RundownPlaylistClientUtil } from '../../lib/rundownPlaylistUtil' import { SourceLayers } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' -import { processAndPrunePieceInstanceTimings } from '@sofie-automation/corelib/dist/playout/processAndPrune' +import { + createPartCurrentTimes, + processAndPrunePieceInstanceTimings, +} from '@sofie-automation/corelib/dist/playout/processAndPrune' import * as _ from 'underscore' import { FindOptions } from '../../collections/lib' import { RundownPlaylistCollectionUtil } from '../../collections/rundownPlaylistUtil' @@ -23,6 +26,7 @@ import { normalizeArrayToMap, protectString } from '../../lib/tempLib' import { PieceInstances, Pieces, RundownPlaylists, Segments } from '../../collections' import { getPieceInstancesForPartInstance } from '../../lib/RundownResolver' import { UIShowStyleBases } from '../Collections' +import { getCurrentTime } from '../../lib/systemTime' // export interface NewPrompterAPI { // getPrompterData (playlistId: RundownPlaylistId): Promise @@ -146,7 +150,7 @@ export namespace PrompterAPI { let previousRundown: Rundown | null = null const rundownIds = rundowns.map((rundown) => rundown._id) - const allPiecesCache = new Map() + const allPiecesCache = new Map() Pieces.find({ startRundownId: { $in: rundownIds }, }).forEach((piece) => { @@ -239,10 +243,11 @@ export namespace PrompterAPI { const sourceLayers = rundownIdsToShowStyleBase.get(partInstance.rundownId) if (sourceLayers) { + const partTimes = createPartCurrentTimes(getCurrentTime(), null) const preprocessedPieces = processAndPrunePieceInstanceTimings( sourceLayers, rawPieceInstances, - 0, + partTimes, true ) diff --git a/packages/webui/src/client/ui/RundownView/SelectedElementsContext.tsx b/packages/webui/src/client/ui/RundownView/SelectedElementsContext.tsx index 679ef0a067..742dadb221 100644 --- a/packages/webui/src/client/ui/RundownView/SelectedElementsContext.tsx +++ b/packages/webui/src/client/ui/RundownView/SelectedElementsContext.tsx @@ -221,7 +221,7 @@ export function useSelectedElements( const computation = Tracker.nonreactive(() => Tracker.autorun(() => { const piece = Pieces.findOne(selectedElement?.elementId) - const part = UIParts.findOne({ _id: piece ? piece.startPartId : selectedElement?.elementId }) + const part = UIParts.findOne({ _id: piece?.startPartId ?? selectedElement?.elementId }) const segment = Segments.findOne({ _id: part ? part.segmentId : selectedElement?.elementId }) setPiece(piece) diff --git a/packages/webui/src/client/ui/Settings/Studio/Generic.tsx b/packages/webui/src/client/ui/Settings/Studio/Generic.tsx index db9f903e2d..edf23a9b91 100644 --- a/packages/webui/src/client/ui/Settings/Studio/Generic.tsx +++ b/packages/webui/src/client/ui/Settings/Studio/Generic.tsx @@ -461,6 +461,23 @@ function StudioSettings({ studio }: { studio: DBStudio }): JSX.Element { > {(value, handleUpdate) => } + + + {(value, handleUpdate) => ( + + )} + ) }