diff --git a/meteor/server/api/userActions.ts b/meteor/server/api/userActions.ts index 83eae98dd4f..ef467b48c60 100644 --- a/meteor/server/api/userActions.ts +++ b/meteor/server/api/userActions.ts @@ -49,6 +49,7 @@ import { IngestJobs } from '@sofie-automation/corelib/dist/worker/ingest' import { UserPermissions } from '@sofie-automation/meteor-lib/dist/userPermissions' import { assertConnectionHasOneOfPermissions } from '../security/auth' import { checkAccessToRundown } from '../security/check' +import { protectString, unprotectString } from '@sofie-automation/corelib/dist/protectedString' const PERMISSIONS_FOR_PLAYOUT_USERACTION: Array = ['studio'] const PERMISSIONS_FOR_BUCKET_MODIFICATION: Array = ['studio'] @@ -121,8 +122,9 @@ class ServerUserActionAPI userEvent: string, eventTime: Time, rundownPlaylistId: RundownPlaylistId, - nextPartId: PartId, - timeOffset: number | null + nextPartOrInstanceId: PartId | PartInstanceId, + timeOffset: number | null, + isInstance: boolean | null ) { return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( this, @@ -131,12 +133,15 @@ class ServerUserActionAPI rundownPlaylistId, () => { check(rundownPlaylistId, String) - check(nextPartId, String) + check(nextPartOrInstanceId, String) }, StudioJobs.SetNextPart, { playlistId: rundownPlaylistId, - nextPartId, + nextPartId: isInstance ? undefined : protectString(unprotectString(nextPartOrInstanceId)), + nextPartInstanceId: isInstance + ? protectString(unprotectString(nextPartOrInstanceId)) + : undefined, setManually: true, nextTimeOffset: timeOffset ?? undefined, } diff --git a/meteor/yarn.lock b/meteor/yarn.lock index 54859735c72..a985764363f 100644 --- a/meteor/yarn.lock +++ b/meteor/yarn.lock @@ -1411,7 +1411,7 @@ __metadata: dependencies: "@mos-connection/model": "npm:^5.0.0-alpha.0" kairos-lib: "npm:^0.2.3" - timeline-state-resolver-types: "npm:10.0.0-nightly-release53-20251217-143607-df590aa96.0" + timeline-state-resolver-types: "npm:10.0.0-nightly-release53-20260123-151128-04b075e87.0" tslib: "npm:^2.8.1" type-fest: "npm:^4.41.0" languageName: node @@ -9831,12 +9831,12 @@ __metadata: languageName: node linkType: hard -"timeline-state-resolver-types@npm:10.0.0-nightly-release53-20251217-143607-df590aa96.0": - version: 10.0.0-nightly-release53-20251217-143607-df590aa96.0 - resolution: "timeline-state-resolver-types@npm:10.0.0-nightly-release53-20251217-143607-df590aa96.0" +"timeline-state-resolver-types@npm:10.0.0-nightly-release53-20260123-151128-04b075e87.0": + version: 10.0.0-nightly-release53-20260123-151128-04b075e87.0 + resolution: "timeline-state-resolver-types@npm:10.0.0-nightly-release53-20260123-151128-04b075e87.0" dependencies: tslib: "npm:^2.8.1" - checksum: 10/6f17030e9f10568757b3ebaae40a54ce5220ce5c2f37d9b5d8a5d286704e4779c80fbfddae500e1952a6efdf6e76b67bc18d0151211c84eb194ae3b52f78c7bf + checksum: 10/7322e958a35bc9deaa332c201414248b097c5894b556e481c5a97d51e292cb61c017d3d373fe1ae3bf19383965535dbfe104d8c38ca7c109eadbffc781a08ed9 languageName: node linkType: hard diff --git a/packages/corelib/src/worker/studio.ts b/packages/corelib/src/worker/studio.ts index 6f8dda2b17b..43d9ddb4b19 100644 --- a/packages/corelib/src/worker/studio.ts +++ b/packages/corelib/src/worker/studio.ts @@ -254,7 +254,8 @@ export interface ActivateRundownPlaylistProps extends RundownPlayoutPropsBase { } export type DeactivateRundownPlaylistProps = RundownPlayoutPropsBase export interface SetNextPartProps extends RundownPlayoutPropsBase { - nextPartId: PartId + nextPartId?: PartId + nextPartInstanceId?: PartInstanceId setManually?: boolean nextTimeOffset?: number } diff --git a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer.test.ts b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer.test.ts deleted file mode 100644 index 643d95b66e8..00000000000 --- a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { findLookaheadForLayer } from '../findForLayer.js' -import { PartAndPieces, PartInstanceAndPieceInstances } from '../util.js' -import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' -import { getRandomString } from '@sofie-automation/corelib/dist/lib' -import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' -import { setupDefaultJobEnvironment } from '../../../__mocks__/context.js' - -jest.mock('../findObjects') -import { findLookaheadObjectsForPart } from '../findObjects.js' -import { ReadonlyDeep } from 'type-fest' -type TfindLookaheadObjectsForPart = jest.MockedFunction -const findLookaheadObjectsForPartMock = findLookaheadObjectsForPart as TfindLookaheadObjectsForPart -findLookaheadObjectsForPartMock.mockImplementation(() => []) // Default mock - -describe('findLookaheadForLayer', () => { - const context = setupDefaultJobEnvironment() - - test('no data', () => { - const res = findLookaheadForLayer(context, null, [], undefined, [], 'abc', 1, 1) - expect(res.timed).toHaveLength(0) - expect(res.future).toHaveLength(0) - }) - - function expectInstancesToMatch( - index: number, - layer: string, - partInstanceInfo: PartInstanceAndPieceInstances, - previousPart: PartInstanceAndPieceInstances | undefined - ): void { - expect(findLookaheadObjectsForPartMock).toHaveBeenNthCalledWith( - index, - context, - null, - layer, - previousPart?.part.part, - { - part: partInstanceInfo.part.part, - usesInTransition: false, - pieces: partInstanceInfo.allPieces, - }, - partInstanceInfo.part._id - ) - } - - function createFakePiece(id: string): PieceInstance { - return { - _id: id, - piece: { - enable: { - start: 0, - }, - }, - } as any - } - - test('partInstances', () => { - const layer = getRandomString() - - const partInstancesInfo: PartInstanceAndPieceInstances[] = [ - { - part: { _id: '1', part: '1p' }, - allPieces: [createFakePiece('1'), createFakePiece('2'), createFakePiece('3')], - onTimeline: true, - nowInPart: 2000, - calculatedTimings: { inTransitionStart: null }, - }, - { - part: { _id: '2', part: '2p' }, - allPieces: [createFakePiece('4'), createFakePiece('5'), createFakePiece('6')], - onTimeline: true, - nowInPart: 1000, - calculatedTimings: { inTransitionStart: null }, - }, - { - part: { _id: '3', part: '3p' }, - allPieces: [createFakePiece('7'), createFakePiece('8'), createFakePiece('9')], - onTimeline: false, - nowInPart: 0, - calculatedTimings: { inTransitionStart: null }, - }, - ] as any - - findLookaheadObjectsForPartMock - .mockReset() - .mockReturnValue([]) - .mockReturnValueOnce(['t0', 't1'] as any) - .mockReturnValueOnce(['t2', 't3'] as any) - .mockReturnValueOnce(['t4', 't5'] as any) - - // Run it - const res = findLookaheadForLayer(context, null, partInstancesInfo, undefined, [], layer, 1, 1) - expect(res.timed).toEqual(['t0', 't1', 't2', 't3']) - expect(res.future).toEqual(['t4', 't5']) - - // Check the mock was called correctly - expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(3) - expectInstancesToMatch(1, layer, partInstancesInfo[0], undefined) - expectInstancesToMatch(2, layer, partInstancesInfo[1], partInstancesInfo[0]) - expectInstancesToMatch(3, layer, partInstancesInfo[2], partInstancesInfo[1]) - - // Check a previous part gets propogated - const previousPartInfo: PartInstanceAndPieceInstances = { - part: { _id: '5', part: '5p' }, - pieces: [createFakePiece('10'), createFakePiece('11'), createFakePiece('12')], - onTimeline: true, - } as any - findLookaheadObjectsForPartMock.mockReset().mockReturnValue([]) - findLookaheadForLayer(context, null, partInstancesInfo, previousPartInfo, [], layer, 1, 1) - expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(3) - expectInstancesToMatch(1, layer, partInstancesInfo[0], previousPartInfo) - - // Max search distance of 0 should ignore any not on the timeline - findLookaheadObjectsForPartMock - .mockReset() - .mockReturnValue([]) - .mockReturnValueOnce(['t0', 't1'] as any) - .mockReturnValueOnce(['t2', 't3'] as any) - .mockReturnValueOnce(['t4', 't5'] as any) - - const res2 = findLookaheadForLayer(context, null, partInstancesInfo, undefined, [], layer, 1, 0) - expect(res2.timed).toEqual(['t0', 't1', 't2', 't3']) - expect(res2.future).toHaveLength(0) - expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(2) - expectInstancesToMatch(1, layer, partInstancesInfo[0], undefined) - expectInstancesToMatch(2, layer, partInstancesInfo[1], partInstancesInfo[0]) - }) - - function expectPartToMatch( - index: number, - layer: string, - partInfo: PartAndPieces, - previousPart: ReadonlyDeep | undefined - ): void { - expect(findLookaheadObjectsForPartMock).toHaveBeenNthCalledWith( - index, - context, - null, - layer, - previousPart, - partInfo, - null - ) - } - - test('parts', () => { - const layer = getRandomString() - - const orderedParts: PartAndPieces[] = [ - { _id: 'p1' }, - { _id: 'p2', invalid: true }, - { _id: 'p3' }, - { _id: 'p4' }, - { _id: 'p5' }, - ].map((p) => ({ - part: p as any, - usesInTransition: true, - pieces: [{ _id: p._id + '_p1' } as any], - })) - - findLookaheadObjectsForPartMock - .mockReset() - .mockReturnValue([]) - .mockReturnValueOnce(['t0', 't1'] as any) - .mockReturnValueOnce(['t2', 't3'] as any) - .mockReturnValueOnce(['t4', 't5'] as any) - .mockReturnValueOnce(['t6', 't7'] as any) - .mockReturnValueOnce(['t8', 't9'] as any) - - // Cant search far enough - const res = findLookaheadForLayer(context, null, [], undefined, orderedParts, layer, 1, 1) - expect(res.timed).toHaveLength(0) - expect(res.future).toHaveLength(0) - expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(0) - - // Find the target of 1 - const res2 = findLookaheadForLayer(context, null, [], undefined, orderedParts, layer, 1, 4) - expect(res2.timed).toHaveLength(0) - expect(res2.future).toEqual(['t0', 't1']) - expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(1) - expectPartToMatch(1, layer, orderedParts[0], undefined) - - // Find the target of 0 - findLookaheadObjectsForPartMock.mockReset().mockReturnValue([]) - const res3 = findLookaheadForLayer(context, null, [], undefined, orderedParts, layer, 0, 4) - expect(res3.timed).toHaveLength(0) - expect(res3.future).toHaveLength(0) - expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(0) - - // Search max distance - findLookaheadObjectsForPartMock - .mockReset() - .mockReturnValue([]) - .mockReturnValueOnce(['t0', 't1'] as any) - .mockReturnValueOnce(['t2', 't3'] as any) - .mockReturnValueOnce(['t4', 't5'] as any) - .mockReturnValueOnce(['t6', 't7'] as any) - .mockReturnValueOnce(['t8', 't9'] as any) - - const res4 = findLookaheadForLayer(context, null, [], undefined, orderedParts, layer, 100, 5) - expect(res4.timed).toHaveLength(0) - expect(res4.future).toEqual(['t0', 't1', 't2', 't3', 't4', 't5']) - expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(3) - expectPartToMatch(1, layer, orderedParts[0], undefined) - expectPartToMatch(2, layer, orderedParts[2], orderedParts[0].part) - expectPartToMatch(3, layer, orderedParts[3], orderedParts[2].part) - }) -}) diff --git a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/basicBehavior.test.ts b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/basicBehavior.test.ts new file mode 100644 index 00000000000..9bcdef84a2a --- /dev/null +++ b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/basicBehavior.test.ts @@ -0,0 +1,34 @@ +jest.mock('../../findObjects') +import { context, TfindLookaheadObjectsForPart } from './helpers/mockSetup.js' +import { findLookaheadForLayer } from '../../findForLayer.js' +import { expectInstancesToMatch } from '../utils.js' +import { findForLayerTestConstants } from './constants.js' +import { findLookaheadObjectsForPart } from '../../findObjects.js' + +const findLookaheadObjectsForPartMockBase = findLookaheadObjectsForPart as TfindLookaheadObjectsForPart +const findLookaheadObjectsForPartMock = findLookaheadObjectsForPartMockBase.mockImplementation(() => []) // Default mock + +beforeEach(() => { + findLookaheadObjectsForPartMock.mockReset() +}) + +const current = findForLayerTestConstants.current +const nextFuture = findForLayerTestConstants.nextFuture +const layer = findForLayerTestConstants.layer + +describe('findLookaheadForLayer – basic behavior', () => { + test('no parts', () => { + const res = findLookaheadForLayer(context, {}, [], 'abc', 1, 1) + + expect(res.timed).toHaveLength(0) + expect(res.future).toHaveLength(0) + }) + test('if the previous part is unset', () => { + findLookaheadObjectsForPartMock.mockReturnValue([]) + + findLookaheadForLayer(context, { previous: undefined, current, next: nextFuture }, [], layer, 1, 1) + + expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(2) + expectInstancesToMatch(findLookaheadObjectsForPartMock, 1, layer, current, undefined) + }) +}) diff --git a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/constants.ts b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/constants.ts new file mode 100644 index 00000000000..ca9a2bc7a8f --- /dev/null +++ b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/constants.ts @@ -0,0 +1,40 @@ +import { getRandomString } from '@sofie-automation/corelib/dist/lib' +import { PartInstanceAndPieceInstances, PartAndPieces } from '../../util.js' +import { createFakePiece } from '../utils.js' + +const layer: string = getRandomString() + +export const findForLayerTestConstants = { + previous: { + part: { _id: 'pPrev', part: 'prev' }, + allPieces: [createFakePiece('1'), createFakePiece('2'), createFakePiece('3')], + onTimeline: true, + nowInPart: 2000, + } as any as PartInstanceAndPieceInstances, + current: { + part: { _id: 'pCur', part: 'cur' }, + allPieces: [createFakePiece('4'), createFakePiece('5'), createFakePiece('6')], + onTimeline: true, + nowInPart: 1000, + } as any as PartInstanceAndPieceInstances, + nextTimed: { + part: { _id: 'pNextTimed', part: 'nextT' }, + allPieces: [createFakePiece('7'), createFakePiece('8'), createFakePiece('9')], + onTimeline: true, + } as any as PartInstanceAndPieceInstances, + nextFuture: { + part: { _id: 'pNextFuture', part: 'nextF' }, + allPieces: [createFakePiece('10'), createFakePiece('11'), createFakePiece('12')], + onTimeline: false, + } as any as PartInstanceAndPieceInstances, + + orderedParts: [{ _id: 'p1' }, { _id: 'p2', invalid: true }, { _id: 'p3' }, { _id: 'p4' }, { _id: 'p5' }].map( + (p) => ({ + part: p as any, + usesInTransition: true, + pieces: [{ _id: p._id + '_p1' } as any], + }) + ) as PartAndPieces[], + + layer, +} diff --git a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/helpers/mockSetup.ts b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/helpers/mockSetup.ts new file mode 100644 index 00000000000..40b1d4e423b --- /dev/null +++ b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/helpers/mockSetup.ts @@ -0,0 +1,6 @@ +import { setupDefaultJobEnvironment } from '../../../../../__mocks__/context.js' +import { findLookaheadObjectsForPart } from '../../../../../playout/lookahead/findObjects.js' + +export type TfindLookaheadObjectsForPart = jest.MockedFunction + +export const context = setupDefaultJobEnvironment() diff --git a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/orderedParts.test.ts b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/orderedParts.test.ts new file mode 100644 index 00000000000..c5b987bf7c6 --- /dev/null +++ b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/orderedParts.test.ts @@ -0,0 +1,76 @@ +import { findLookaheadForLayer } from '../../findForLayer.js' +import { setupDefaultJobEnvironment } from '../../../../__mocks__/context.js' + +jest.mock('../../findObjects') +import { findForLayerTestConstants } from './constants.js' +import { expectPartToMatch } from '../utils.js' +import { findLookaheadObjectsForPart } from '../../findObjects.js' +import { TfindLookaheadObjectsForPart } from './helpers/mockSetup.js' + +const findLookaheadObjectsForPartMockBase = findLookaheadObjectsForPart as TfindLookaheadObjectsForPart +const findLookaheadObjectsForPartMock = findLookaheadObjectsForPartMockBase.mockImplementation(() => []) // Default mock + +beforeEach(() => { + findLookaheadObjectsForPartMock.mockReset() +}) + +const orderedParts = findForLayerTestConstants.orderedParts +const layer = findForLayerTestConstants.layer + +describe('findLookaheadForLayer - orderedParts', () => { + beforeEach(() => { + findLookaheadObjectsForPartMock.mockReset() + }) + + const context = setupDefaultJobEnvironment() + + test('finds lookahead for target index 1', () => { + findLookaheadObjectsForPartMock + .mockReturnValue([]) + .mockReturnValueOnce(['t0', 't1'] as any) + .mockReturnValueOnce(['t2', 't3'] as any) + .mockReturnValueOnce(['t4', 't5'] as any) + .mockReturnValueOnce(['t6', 't7'] as any) + .mockReturnValueOnce(['t8', 't9'] as any) + + const res2 = findLookaheadForLayer(context, {}, orderedParts, layer, 1, 4, null) + + expect(res2.timed).toHaveLength(0) + expect(res2.future).toEqual(['t0', 't1']) + expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(1) + + expectPartToMatch(findLookaheadObjectsForPartMock, 1, layer, orderedParts[0], undefined) + }) + + test('returns nothing when target index is 0', () => { + findLookaheadObjectsForPartMock.mockReturnValue([]) + + const res3 = findLookaheadForLayer(context, {}, orderedParts, layer, 0, 4, null) + + expect(res3.timed).toHaveLength(0) + expect(res3.future).toHaveLength(0) + expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(0) + }) + + test('searches across maximum search distance', () => { + findLookaheadObjectsForPartMock + .mockReturnValue([]) + .mockReturnValueOnce(['t0', 't1'] as any) + .mockReturnValueOnce(['t2', 't3'] as any) + .mockReturnValueOnce(['t4', 't5'] as any) + .mockReturnValueOnce(['t6', 't7'] as any) + .mockReturnValueOnce(['t8', 't9'] as any) + + const res4 = findLookaheadForLayer(context, {}, orderedParts, layer, 100, 5, null) + + expect(res4.timed).toHaveLength(0) + expect(res4.future).toEqual(['t0', 't1', 't2', 't3', 't4', 't5']) + + // Called for parts: [0], [2], [3] + expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(3) + + expectPartToMatch(findLookaheadObjectsForPartMock, 1, layer, orderedParts[0], undefined) + expectPartToMatch(findLookaheadObjectsForPartMock, 2, layer, orderedParts[2], orderedParts[0].part) + expectPartToMatch(findLookaheadObjectsForPartMock, 3, layer, orderedParts[3], orderedParts[2].part) + }) +}) diff --git a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/searchDistance.test.ts b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/searchDistance.test.ts new file mode 100644 index 00000000000..8a5490561c7 --- /dev/null +++ b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/searchDistance.test.ts @@ -0,0 +1,58 @@ +jest.mock('../../findObjects') +import { context, TfindLookaheadObjectsForPart } from './helpers/mockSetup.js' +import { findLookaheadForLayer } from '../../findForLayer.js' +import { expectInstancesToMatch } from '../utils.js' +import { findForLayerTestConstants } from './constants.js' +import { findLookaheadObjectsForPart } from '../../findObjects.js' + +const findLookaheadObjectsForPartMockBase = findLookaheadObjectsForPart as TfindLookaheadObjectsForPart +const findLookaheadObjectsForPartMock = findLookaheadObjectsForPartMockBase.mockImplementation(() => []) // Default mock + +beforeEach(() => { + findLookaheadObjectsForPartMock.mockReset() +}) + +const previous = findForLayerTestConstants.previous +const current = findForLayerTestConstants.current +const nextFuture = findForLayerTestConstants.nextFuture +const orderedParts = findForLayerTestConstants.orderedParts +const layer = findForLayerTestConstants.layer + +describe('findLookaheadForLayer – search distance', () => { + test('searchDistance = 0 ignores future parts', () => { + findLookaheadObjectsForPartMock.mockReturnValueOnce(['cur0', 'cur1'] as any) + + const res = findLookaheadForLayer( + context, + { previous, current, next: nextFuture }, + orderedParts, + layer, + 1, + 0, + null + ) + + expect(res.timed).toEqual(['cur0', 'cur1']) + expect(res.future).toHaveLength(0) + + expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(2) + expectInstancesToMatch(findLookaheadObjectsForPartMock, 1, layer, current, previous) + expectInstancesToMatch(findLookaheadObjectsForPartMock, 2, layer, nextFuture, current) + }) + + test('returns nothing when maxSearchDistance is too small', () => { + findLookaheadObjectsForPartMock + .mockReturnValue([]) + .mockReturnValueOnce(['t0', 't1'] as any) + .mockReturnValueOnce(['t2', 't3'] as any) + .mockReturnValueOnce(['t4', 't5'] as any) + .mockReturnValueOnce(['t6', 't7'] as any) + .mockReturnValueOnce(['t8', 't9'] as any) + + const res = findLookaheadForLayer(context, {}, orderedParts, layer, 1, 1, null) + + expect(res.timed).toHaveLength(0) + expect(res.future).toHaveLength(0) + expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(0) + }) +}) diff --git a/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/timing.test.ts b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/timing.test.ts new file mode 100644 index 00000000000..572c6bd8596 --- /dev/null +++ b/packages/job-worker/src/playout/lookahead/__tests__/findForLayer/timing.test.ts @@ -0,0 +1,51 @@ +jest.mock('../../findObjects') +import { context, TfindLookaheadObjectsForPart } from './helpers/mockSetup.js' +import { findLookaheadForLayer } from '../../findForLayer.js' +import { expectInstancesToMatch } from '../utils.js' +import { findForLayerTestConstants } from './constants.js' +import { findLookaheadObjectsForPart } from '../../findObjects.js' + +const findLookaheadObjectsForPartMockBase = findLookaheadObjectsForPart as TfindLookaheadObjectsForPart +const findLookaheadObjectsForPartMock = findLookaheadObjectsForPartMockBase.mockImplementation(() => []) // Default mock + +beforeEach(() => { + findLookaheadObjectsForPartMock.mockReset() +}) + +const previous = findForLayerTestConstants.previous +const current = findForLayerTestConstants.current +const nextTimed = findForLayerTestConstants.nextTimed +const nextFuture = findForLayerTestConstants.nextFuture +const layer = findForLayerTestConstants.layer + +describe('findLookaheadForLayer – timing', () => { + test('current part with timed next part (all goes into timed)', () => { + findLookaheadObjectsForPartMock + .mockReturnValueOnce(['cur0', 'cur1'] as any) + .mockReturnValueOnce(['nT0', 'nT1'] as any) + + const res = findLookaheadForLayer(context, { previous, current, next: nextTimed }, [], layer, 1, 1, null) + + expect(res.timed).toEqual(['cur0', 'cur1', 'nT0', 'nT1']) // should have all pieces + expect(res.future).toHaveLength(0) // should be empty + + expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(2) + expectInstancesToMatch(findLookaheadObjectsForPartMock, 1, layer, current, previous) + expectInstancesToMatch(findLookaheadObjectsForPartMock, 2, layer, nextTimed, current) + }) + + test('current part with un-timed next part (next goes into future)', () => { + findLookaheadObjectsForPartMock + .mockReturnValueOnce(['cur0', 'cur1'] as any) + .mockReturnValueOnce(['nF0', 'nF1'] as any) + + const res = findLookaheadForLayer(context, { previous, current, next: nextFuture }, [], layer, 1, 1, null) + + expect(res.timed).toEqual(['cur0', 'cur1']) // Should only contain the current part's pieces + expect(res.future).toEqual(['nF0', 'nF1']) // Should only contain the future pieces + + expect(findLookaheadObjectsForPartMock).toHaveBeenCalledTimes(2) + expectInstancesToMatch(findLookaheadObjectsForPartMock, 1, layer, current, previous) + expectInstancesToMatch(findLookaheadObjectsForPartMock, 2, layer, nextFuture, current) + }) +}) 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 2e633843d9c..7a7186f7672 100644 --- a/packages/job-worker/src/playout/lookahead/__tests__/lookahead.test.ts +++ b/packages/job-worker/src/playout/lookahead/__tests__/lookahead.test.ts @@ -15,12 +15,12 @@ import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/Rund jest.mock('../findForLayer') type TfindLookaheadForLayer = jest.MockedFunction -import { findLookaheadForLayer } from '../findForLayer.js' +import { findLookaheadForLayer, PartInstanceAndPieceInstancesInfos } from '../findForLayer.js' const findLookaheadForLayerMock = findLookaheadForLayer as TfindLookaheadForLayer jest.mock('../util') type TgetOrderedPartsAfterPlayhead = jest.MockedFunction -import { getOrderedPartsAfterPlayhead, PartAndPieces, PartInstanceAndPieceInstances } from '../util.js' +import { getOrderedPartsAfterPlayhead, PartAndPieces } from '../util.js' import { LookaheadTimelineObject } from '../findObjects.js' import { wrapDefaultObject } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' import { createPartCurrentTimes } from '@sofie-automation/corelib/dist/playout/processAndPrune' @@ -125,9 +125,8 @@ describe('Lookahead', () => { async function expectLookaheadForLayerMock( playlistId0: RundownPlaylistId, - partInstances: PartInstanceAndPieceInstances[], - previous: PartInstanceAndPieceInstances | undefined, - orderedPartsFollowingPlayhead: PartAndPieces[] + partInstancesInfo: PartInstanceAndPieceInstancesInfos, + orderedPartInfos: Array ) { const playlist = (await context.mockCollections.RundownPlaylists.findOne(playlistId0)) as DBRundownPlaylist expect(playlist).toBeTruthy() @@ -136,24 +135,22 @@ describe('Lookahead', () => { expect(findLookaheadForLayerMock).toHaveBeenNthCalledWith( 1, context, - playlist.currentPartInfo?.partInstanceId ?? null, - partInstances, - previous, - orderedPartsFollowingPlayhead, + partInstancesInfo, + orderedPartInfos, 'PRELOAD', 1, - LOOKAHEAD_DEFAULT_SEARCH_DISTANCE + LOOKAHEAD_DEFAULT_SEARCH_DISTANCE, + undefined ) expect(findLookaheadForLayerMock).toHaveBeenNthCalledWith( 2, context, - playlist.currentPartInfo?.partInstanceId ?? null, - partInstances, - previous, - orderedPartsFollowingPlayhead, + partInstancesInfo, + orderedPartInfos, 'WHEN_CLEAR', 1, - LOOKAHEAD_DEFAULT_SEARCH_DISTANCE + LOOKAHEAD_DEFAULT_SEARCH_DISTANCE, + undefined ) findLookaheadForLayerMock.mockClear() } @@ -171,7 +168,7 @@ describe('Lookahead', () => { expect(getOrderedPartsAfterPlayheadMock).toHaveBeenCalledTimes(1) expect(getOrderedPartsAfterPlayheadMock).toHaveBeenCalledWith(context, expect.anything(), 10) // default distance - await expectLookaheadForLayerMock(playlistId, [], undefined, fakeParts) + await expectLookaheadForLayerMock(playlistId, {}, fakeParts) }) function fakeResultObj(id: string, pieceId: string, layer: string): LookaheadTimelineObject { @@ -190,22 +187,42 @@ describe('Lookahead', () => { getOrderedPartsAfterPlayheadMock.mockReturnValueOnce(fakeParts.map((p) => p.part)) findLookaheadForLayerMock - .mockImplementationOnce((_context, _id, _parts, _prev, _parts2, layer) => ({ - timed: [fakeResultObj('obj0', 'piece0', layer), fakeResultObj('obj1', 'piece1', layer)], - future: [ - fakeResultObj('obj2', 'piece0', layer), - fakeResultObj('obj3', 'piece0', layer), - fakeResultObj('obj4', 'piece0', layer), - ], - })) - .mockImplementationOnce((_context, _id, _parts, _prev, _parts2, layer) => ({ - timed: [fakeResultObj('obj5', 'piece1', layer), fakeResultObj('obj6', 'piece0', layer)], - future: [ - fakeResultObj('obj7', 'piece1', layer), - fakeResultObj('obj8', 'piece1', layer), - fakeResultObj('obj9', 'piece0', layer), - ], - })) + .mockImplementationOnce( + ( + _context, + _partInstancesInfo, + _orderedPartInfos, + layer, + _lookaheadTargetFutureObjects, + _lookaheadMaxSearchDistance, + _nextTimeOffset + ) => ({ + timed: [fakeResultObj('obj0', 'piece0', layer), fakeResultObj('obj1', 'piece1', layer)], + future: [ + fakeResultObj('obj2', 'piece0', layer), + fakeResultObj('obj3', 'piece0', layer), + fakeResultObj('obj4', 'piece0', layer), + ], + }) + ) + .mockImplementationOnce( + ( + _context, + _partInstancesInfo, + _orderedPartInfos, + layer, + _lookaheadTargetFutureObjects, + _lookaheadMaxSearchDistance, + _nextTimeOffset + ) => ({ + timed: [fakeResultObj('obj5', 'piece1', layer), fakeResultObj('obj6', 'piece0', layer)], + future: [ + fakeResultObj('obj7', 'piece1', layer), + fakeResultObj('obj8', 'piece1', layer), + fakeResultObj('obj9', 'piece0', layer), + ], + }) + ) const res = await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => getLookeaheadObjects(context, playoutModel, partInstancesInfo) @@ -214,7 +231,7 @@ describe('Lookahead', () => { expect(getOrderedPartsAfterPlayheadMock).toHaveBeenCalledTimes(1) expect(getOrderedPartsAfterPlayheadMock).toHaveBeenCalledWith(context, expect.anything(), 10) // default distance - await expectLookaheadForLayerMock(playlistId, [], undefined, fakeParts) + await expectLookaheadForLayerMock(playlistId, {}, fakeParts) }) test('Different max distances', async () => { @@ -290,7 +307,7 @@ describe('Lookahead', () => { await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => getLookeaheadObjects(context, playoutModel, partInstancesInfo) ) - await expectLookaheadForLayerMock(playlistId, [], expectedPrevious, fakeParts) + await expectLookaheadForLayerMock(playlistId, { previous: expectedPrevious }, fakeParts) // Add a current partInstancesInfo.current = { @@ -310,7 +327,11 @@ describe('Lookahead', () => { await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => getLookeaheadObjects(context, playoutModel, partInstancesInfo) ) - await expectLookaheadForLayerMock(playlistId, [expectedCurrent], expectedPrevious, fakeParts) + await expectLookaheadForLayerMock( + playlistId, + { current: expectedCurrent, previous: expectedPrevious }, + fakeParts + ) // Add a next partInstancesInfo.next = { @@ -330,7 +351,11 @@ describe('Lookahead', () => { await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => getLookeaheadObjects(context, playoutModel, partInstancesInfo) ) - await expectLookaheadForLayerMock(playlistId, [expectedCurrent, expectedNext], expectedPrevious, fakeParts) + await expectLookaheadForLayerMock( + playlistId, + { current: expectedCurrent, next: expectedNext, previous: expectedPrevious }, + fakeParts + ) // current has autonext ;(partInstancesInfo.current.partInstance.part as DBPart).autoNext = true @@ -338,7 +363,11 @@ describe('Lookahead', () => { await runJobWithPlayoutModel(context, { playlistId }, null, async (playoutModel) => getLookeaheadObjects(context, playoutModel, partInstancesInfo) ) - await expectLookaheadForLayerMock(playlistId, [expectedCurrent, expectedNext], expectedPrevious, fakeParts) + await expectLookaheadForLayerMock( + playlistId, + { current: expectedCurrent, next: expectedNext, previous: expectedPrevious }, + fakeParts + ) }) // eslint-disable-next-line jest/no-commented-out-tests diff --git a/packages/job-worker/src/playout/lookahead/__tests__/lookaheadOffset/constants.ts b/packages/job-worker/src/playout/lookahead/__tests__/lookaheadOffset/constants.ts new file mode 100644 index 00000000000..c57f48221cc --- /dev/null +++ b/packages/job-worker/src/playout/lookahead/__tests__/lookaheadOffset/constants.ts @@ -0,0 +1,349 @@ +import { IBlueprintPieceType, TSR, LookaheadMode } from '@sofie-automation/blueprints-integration' +import { Piece, PieceTimelineObjectsBlob } from '@sofie-automation/corelib/dist/dataModel/Piece' +import { literal } from '@sofie-automation/corelib/dist/lib' +import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { PlayoutModel } from '../../../model/PlayoutModel.js' +import { JobContext } from '../../../../jobs/index.js' + +export function makePiece({ + partId, + layer, + start = 0, + duration, + nameSuffix = '', + objsBeforeOffset = 0, + objsAfterOffset = 0, + objsWhile = false, +}: { + partId: string + layer: string + start?: number + duration?: number + nameSuffix?: string + objsBeforeOffset?: number + objsAfterOffset?: number + objsWhile?: boolean +}): Piece { + return literal>({ + _id: protectString(`piece_${partId}_${nameSuffix}_${layer}`), + startRundownId: protectString('r1'), + startPartId: protectString(partId), + enable: { start, duration }, + outputLayerId: layer, + pieceType: IBlueprintPieceType.Normal, + timelineObjectsString: generateFakeObectsString( + `piece_${partId}_${nameSuffix}_${layer}`, + layer, + objsBeforeOffset, + objsAfterOffset, + objsWhile + ), + }) as Piece +} +export function generateFakeObectsString( + pieceId: string, + layer: string, + beforeStart: number, + afterStart: number, + enableWhile: boolean = false +): PieceTimelineObjectsBlob { + return protectString( + JSON.stringify([ + // At piece start + { + id: `${pieceId}_objPieceStart_${layer}`, + layer, + enable: !enableWhile ? { start: 0 } : { while: 1 }, + content: { + deviceType: TSR.DeviceType.CASPARCG, + type: TSR.TimelineContentTypeCasparCg.MEDIA, + file: 'AMB', + }, + }, + //beforeOffsetObj except if it's piece starts later than the offset. + { + id: `${pieceId}_obj_beforeOffset_${layer}`, + layer, + enable: !enableWhile ? { start: beforeStart } : { while: beforeStart }, + content: { + deviceType: TSR.DeviceType.CASPARCG, + type: TSR.TimelineContentTypeCasparCg.MEDIA, + file: 'AMB', + }, + }, + //afterOffsetObj except if it's piece starts later than the offset. + { + id: `${pieceId}_obj_afterOffset_${layer}`, + layer, + enable: !enableWhile ? { start: afterStart } : { while: afterStart }, + content: { + deviceType: TSR.DeviceType.CASPARCG, + type: TSR.TimelineContentTypeCasparCg.MEDIA, + file: 'AMB', + }, + }, + ]) + ) +} + +export const partDuration = 3000 +export const lookaheadOffsetTestConstants = { + multiLayerPart: { + partTimes: { nowInPart: 0 }, + partInstance: { + _id: protectString('pLookahead_ml_instance'), + part: { + _id: protectString('pLookahead_ml'), + _rank: 0, + }, + playlistActivationId: 'pA1', + }, + pieces: [ + // piece1 — At Part Start - lookaheadOffset should equal nextTimeOffset + // We generate three objects, one at the piece's start, one 700 ms after the piece's start, one 1700 ms after the piece's start. + // We need to check if all offsets are calculated correctly. (1000, 300 and no offset) + makePiece({ + partId: 'pLookahead_ml', + layer: 'layer1', + duration: partDuration, + nameSuffix: 'partStart', + objsBeforeOffset: 700, + objsAfterOffset: 1700, + }), + + // piece2 — Before Offset — lookaheadOffset should equal nextTimeOffset - the piece's start - the timeline object's start. + // We generate three objects, one at the piece's start, one 200 ms after the piece's start, one 1200 ms after the piece's start. + // We need to check if all offsets are calculated correctly. (500, 300 and no offset) + makePiece({ + partId: 'pLookahead_ml', + layer: 'layer2', + start: 500, + duration: partDuration - 500, + nameSuffix: 'partBeforeOffset', + objsBeforeOffset: 200, + objsAfterOffset: 1200, + }), + + // piece3 — After Offset — no lookahead offset + // We generate three objects, one at the piece's start, one 200 ms after the piece's start, one 400 ms after the piece's start. + // We need to check if all offsets are calculated correctly. + // for reference no offset should be calculated for all of it's objects. + makePiece({ + partId: 'pLookahead_ml', + layer: 'layer3', + start: 1500, + duration: partDuration - 1500, + nameSuffix: 'partAfterOffset', + objsBeforeOffset: 200, + objsAfterOffset: 400, + }), + ], + calculatedTimings: undefined, + regenerateTimelineAt: undefined, + }, + multiLayerPartWhile: { + partTimes: { nowInPart: 0 }, + partInstance: { + _id: protectString('pLookahead_ml_while_instance'), + part: { + _id: protectString('pLookahead_ml_while'), + _rank: 0, + }, + playlistActivationId: 'pA1', + }, + pieces: [ + // piece1 — At Part Start - lookaheadOffset should equal nextTimeOffset + // We generate three objects, one at the piece's start, one 700 ms after the piece's start, one 1700 ms after the piece's start. + // We need to check if all offsets are calculated correctly. (1000, 300 and no offset) + makePiece({ + partId: 'pLookahead_ml_while', + layer: 'layer1', + duration: partDuration, + nameSuffix: 'partStart', + objsBeforeOffset: 700, + objsAfterOffset: 1700, + objsWhile: true, + }), + + // piece2 — Before Offset — lookaheadOffset should equal nextTimeOffset - the piece's start - the timeline object's start. + // We generate three objects, one at the piece's start, one 200 ms after the piece's start, one 1200 ms after the piece's start. + // We need to check if all offsets are calculated correctly. (500, 300 and no offset) + makePiece({ + partId: 'pLookahead_ml_while', + layer: 'layer2', + start: 500, + duration: partDuration - 500, + nameSuffix: 'partBeforeOffset', + objsBeforeOffset: 200, + objsAfterOffset: 1200, + objsWhile: true, + }), + + // piece3 — After Offset — no lookahead offset + // We generate three objects, one at the piece's start, one 200 ms after the piece's start, one 400 ms after the piece's start. + // We need to check if all offsets are calculated correctly. + // for reference no offset should be calculated for all of it's objects. + makePiece({ + partId: 'pLookahead_ml_while', + layer: 'layer3', + start: 1500, + duration: partDuration - 1500, + nameSuffix: 'partAfterOffset', + objsBeforeOffset: 200, + objsAfterOffset: 400, + objsWhile: true, + }), + ], + calculatedTimings: undefined, + regenerateTimelineAt: undefined, + }, + singleLayerPart: { + partTimes: { nowInPart: 0 }, + partInstance: { + _id: protectString('pLookahead_sl_instance'), + part: { + _id: protectString('pLookahead_sl'), + _rank: 0, + }, + playlistActivationId: 'pA1', + }, + pieces: [ + // piece1 — At Part Start - should be ignored + // We generate three objects, one at the piece's start, one 700 ms after the piece's start, one 1700 ms after the piece's start. + // If the piece is not ignored (which shouldn't happen, it would mean that the logic is wrong) + // for reference the calculated offset values should be 1000, 300 and no offset + makePiece({ + partId: 'pLookahead_sl', + layer: 'layer1', + duration: partDuration, + nameSuffix: 'partStart', + objsBeforeOffset: 700, + objsAfterOffset: 1700, + }), + + // piece2 — Before Offset — lookaheadOffset should equal nextTimeOffset - the piece's start - the timeline object's start. + /// We generate three objects, one at the piece's start, one 200 ms after the piece's start, one 1200 ms after the piece's start. + // We need to check if all offsets are calculated correctly. (500, 300 and no offset) + makePiece({ + partId: 'pLookahead_sl', + layer: 'layer1', + start: 500, + duration: partDuration - 500, + nameSuffix: 'partBeforeOffset', + objsBeforeOffset: 200, + objsAfterOffset: 1200, + }), + + // piece3 — After Offset — should be ignored + // We generate three objects, one at the piece's start, one 200 ms after the piece's start, one 400 ms after the piece's start. + // If the piece is not ignored (which shouldn't happen, it would mean that the logic is wrong) + // for reference no offset should be calculated for all of it's objects. + makePiece({ + partId: 'pLookahead_sl', + layer: 'layer1', + start: 1500, + duration: partDuration - 1500, + nameSuffix: 'partAfterOffset', + objsBeforeOffset: 200, + objsAfterOffset: 400, + }), + ], + calculatedTimings: undefined, + regenerateTimelineAt: undefined, + }, + singleLayerPartWhile: { + partTimes: { nowInPart: 0 }, + partInstance: { + _id: protectString('pLookahead_sl_while_instance'), + part: { + _id: protectString('pLookahead_sl_while'), + _rank: 0, + }, + playlistActivationId: 'pA1', + }, + pieces: [ + // piece1 — At Part Start - should be ignored + // We generate three objects, one at the piece's start, one 700 ms after the piece's start, one 1700 ms after the piece's start. + // If the piece is not ignored (which shouldn't happen, it would mean that the logic is wrong) + // for reference the calculated offset values should be 1000, 300 and no offset + makePiece({ + partId: 'pLookahead_sl_while', + layer: 'layer1', + duration: partDuration, + nameSuffix: 'partStart', + objsBeforeOffset: 700, + objsAfterOffset: 1700, + objsWhile: true, + }), + + // piece2 — Before Offset — lookaheadOffset should equal nextTimeOffset - the piece's start - the timeline object's start. + // We generate three objects, one at the piece's start, one 200 ms after the piece's start, one 1200 ms after the piece's start. + // We need to check if all offsets are calculated correctly. (500, 300 and no offset) + makePiece({ + partId: 'pLookahead_sl_while', + layer: 'layer1', + start: 500, + duration: partDuration - 500, + nameSuffix: 'partBeforeOffset', + objsBeforeOffset: 200, + objsAfterOffset: 1200, + objsWhile: true, + }), + + // piece3 — After Offset — should be ignored + // We generate three objects, one at the piece's start, one 200 ms after the piece's start, one 400 ms after the piece's start. + // If the piece is not ignored (which shouldn't happen, it would mean that the logic is wrong) + // for reference no offset should be calculated for all of it's objects. + makePiece({ + partId: 'pLookahead_sl_while', + layer: 'layer1', + start: 1500, + duration: partDuration - 1500, + nameSuffix: 'partAfterOffset', + objsBeforeOffset: 200, + objsAfterOffset: 400, + objsWhile: true, + }), + ], + calculatedTimings: undefined, + regenerateTimelineAt: undefined, + }, + nextTimeOffset: 1000, +} +export const baseContext = { + startSpan: jest.fn(() => ({ end: jest.fn() })), + studio: { + mappings: { + layer1: { + device: 'casparcg', + layer: 10, + lookahead: LookaheadMode.PRELOAD, + lookaheadDepth: 2, + }, + layer2: { + device: 'casparcg', + layer: 10, + lookahead: LookaheadMode.PRELOAD, + lookaheadDepth: 2, + }, + layer3: { + device: 'casparcg', + layer: 10, + lookahead: LookaheadMode.PRELOAD, + lookaheadDepth: 2, + }, + }, + }, + directCollections: { + Pieces: { + findFetch: jest.fn(), + }, + }, +} as unknown as JobContext + +export const basePlayoutModel = { + getRundownIds: () => [protectString('r1')], + playlist: { + nextTimeOffset: 0, + }, +} as unknown as PlayoutModel diff --git a/packages/job-worker/src/playout/lookahead/__tests__/lookaheadOffset/lookaheadOffset.test.ts b/packages/job-worker/src/playout/lookahead/__tests__/lookaheadOffset/lookaheadOffset.test.ts new file mode 100644 index 00000000000..a28cb31e7ff --- /dev/null +++ b/packages/job-worker/src/playout/lookahead/__tests__/lookaheadOffset/lookaheadOffset.test.ts @@ -0,0 +1,328 @@ +jest.mock('../../../../playout/lookahead/index.js', () => { + const actual = jest.requireActual('../../../../playout/lookahead/index.js') + return { + ...actual, + findLargestLookaheadDistance: jest.fn(() => 0), + getLookeaheadObjects: actual.getLookeaheadObjects, + } +}) +jest.mock('../../../../playout/lookahead/util.js') +import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { TSR } from '@sofie-automation/blueprints-integration' +import { JobContext } from '../../../../jobs/index.js' +import { findLargestLookaheadDistance, getLookeaheadObjects } from '../../index.js' +import { getOrderedPartsAfterPlayhead } from '../../util.js' +import { PlayoutModel } from '../../../model/PlayoutModel.js' +import { SelectedPartInstancesTimelineInfo } from '../../../timeline/generate.js' +import { wrapPieceToInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' +import { baseContext, basePlayoutModel, makePiece, lookaheadOffsetTestConstants } from './constants.js' + +const findLargestLookaheadDistanceMock = jest.mocked(findLargestLookaheadDistance).mockImplementation(() => 0) +const getOrderedPartsAfterPlayheadMock = jest.mocked(getOrderedPartsAfterPlayhead).mockImplementation(() => []) + +describe('lookahead offset integration', () => { + let context: JobContext + let playoutModel: PlayoutModel + + beforeEach(() => { + jest.resetAllMocks() + + context = baseContext + playoutModel = basePlayoutModel + }) + + test('returns empty array when no lookahead mappings are defined', async () => { + getOrderedPartsAfterPlayheadMock.mockReturnValue([ + { + _id: protectString('p1'), + classesForNext: [], + } as any, + ]) + const findFetchMock = jest.fn().mockResolvedValue([makePiece({ partId: 'p1', layer: 'layer1' })]) + context = { + ...context, + studio: { + ...context.studio, + mappings: {}, + }, + directCollections: { + ...context.directCollections, + Pieces: { + ...context.directCollections.Pieces, + findFetch: findFetchMock, + }, + }, + } as JobContext + + const res = await getLookeaheadObjects(context, playoutModel, {} as SelectedPartInstancesTimelineInfo) + + expect(res).toEqual([]) + }) + test('respects lookaheadMaxSearchDistance', async () => { + getOrderedPartsAfterPlayheadMock.mockReturnValue([ + { _id: protectString('p1'), classesForNext: [] } as any, + { _id: protectString('p2'), classesForNext: [] } as any, + { _id: protectString('p3'), classesForNext: [] } as any, + { _id: protectString('p4'), classesForNext: [] } as any, + ]) + + const findFetchMock = jest + .fn() + .mockResolvedValue([ + makePiece({ partId: 'p1', layer: 'layer1' }), + makePiece({ partId: 'p2', layer: 'layer1' }), + makePiece({ partId: 'p3', layer: 'layer1' }), + makePiece({ partId: 'p4', layer: 'layer1' }), + ]) + + context = { + ...context, + studio: { + ...context.studio, + mappings: { + ...context.studio.mappings, + layer1: { + ...context.studio.mappings['layer1'], + lookaheadMaxSearchDistance: 3, + }, + }, + }, + directCollections: { + ...context.directCollections, + Pieces: { + ...context.directCollections.Pieces, + findFetch: findFetchMock, + }, + }, + } as JobContext + + const res = await getLookeaheadObjects(context, playoutModel, { + current: undefined, + next: undefined, + previous: undefined, + } as SelectedPartInstancesTimelineInfo) + + expect(res).toHaveLength(2) + const obj0 = res[0] + const obj1 = res[1] + + expect(obj0.layer).toBe('layer1_lookahead') + expect(obj0.objectType).toBe('rundown') + expect(obj0.pieceInstanceId).toContain('p1') + expect(obj0.partInstanceId).toContain('p1') + expect(obj0.content).toMatchObject({ + deviceType: TSR.DeviceType.CASPARCG, + type: TSR.TimelineContentTypeCasparCg.MEDIA, + file: 'AMB', + }) + expect(obj1.layer).toBe('layer1_lookahead') + expect(obj1.objectType).toBe('rundown') + expect(obj1.pieceInstanceId).toContain('p2') + expect(obj1.partInstanceId).toContain('p2') + expect(obj1.content).toMatchObject({ + deviceType: TSR.DeviceType.CASPARCG, + type: TSR.TimelineContentTypeCasparCg.MEDIA, + file: 'AMB', + }) + }) + test('applies nextTimeOffset to lookahead objects in future part', async () => { + playoutModel = { + ...playoutModel, + playlist: { + ...playoutModel.playlist, + nextTimeOffset: 5000, + }, + } as PlayoutModel + findLargestLookaheadDistanceMock.mockReturnValue(1) + getOrderedPartsAfterPlayheadMock.mockReturnValue([ + { _id: protectString('p1'), classesForNext: [] } as any, + { _id: protectString('p2'), classesForNext: [] } as any, + ]) + + context.directCollections.Pieces.findFetch = jest + .fn() + .mockResolvedValue([ + makePiece({ partId: 'p1', layer: 'layer1' }), + makePiece({ partId: 'p2', layer: 'layer1' }), + ]) + + const res = await getLookeaheadObjects(context, playoutModel, {} as SelectedPartInstancesTimelineInfo) + + expect(res).toHaveLength(1) + expect(res[0].lookaheadOffset).toBe(5000) + }) + test('applies nextTimeOffset to lookahead objects in nextPart with no offset on next part', async () => { + playoutModel = { + ...playoutModel, + playlist: { + ...playoutModel.playlist, + nextTimeOffset: 5000, + }, + } as PlayoutModel + getOrderedPartsAfterPlayheadMock.mockReturnValue([{ _id: protectString('p1'), classesForNext: [] } as any]) + + context.directCollections.Pieces.findFetch = jest + .fn() + .mockResolvedValue([ + makePiece({ partId: 'pNext', layer: 'layer1', start: 0 }), + makePiece({ partId: 'p1', layer: 'layer2', start: 0 }), + ]) + + const res = await getLookeaheadObjects(context, playoutModel, { + next: { + partTimes: { nowInPart: 0 }, + partInstance: { + _id: protectString('pNextInstance'), + part: { + _id: protectString('pNext'), + _rank: 0, + }, + playlistActivationId: 'pA1', + }, + pieceInstances: [ + wrapPieceToInstance( + makePiece({ partId: 'pNext', layer: 'layer1', start: 0 }) as any, + 'pA1' as any, + 'pNextInstance' as any + ), + ], + calculatedTimings: undefined, + regenerateTimelineAt: undefined, + }, + } as any) + + expect(res).toHaveLength(2) + expect(res[0].lookaheadOffset).toBe(5000) + expect(res[1].lookaheadOffset).toBe(undefined) + }) + test('Multi layer part produces lookahead objects for all layers with the correct offsets', async () => { + playoutModel = { + ...playoutModel, + playlist: { + ...playoutModel.playlist, + nextTimeOffset: 1000, + }, + } as PlayoutModel + getOrderedPartsAfterPlayheadMock.mockReturnValue([ + { ...lookaheadOffsetTestConstants.multiLayerPart, classesForNext: [] } as any, + ]) + + context.directCollections.Pieces.findFetch = jest + .fn() + .mockResolvedValue(lookaheadOffsetTestConstants.multiLayerPart.pieces) + + const res = await getLookeaheadObjects(context, playoutModel, { + next: { + ...lookaheadOffsetTestConstants.multiLayerPart, + pieceInstances: lookaheadOffsetTestConstants.multiLayerPart.pieces.map((piece) => + wrapPieceToInstance( + piece as any, + 'pA1' as any, + lookaheadOffsetTestConstants.multiLayerPart.partInstance._id + ) + ), + }, + } as any) + + expect(res).toHaveLength(3) + expect(res.map((o) => o.layer).sort()).toEqual([`layer1_lookahead`, 'layer2_lookahead', 'layer3_lookahead']) + expect(res.map((o) => o.lookaheadOffset).sort()).toEqual([1000, 500]) + }) + test('Multi layer part produces lookahead objects with while enable values for all layers with the correct offsets', async () => { + playoutModel = { + ...playoutModel, + playlist: { + ...playoutModel.playlist, + nextTimeOffset: 1000, + }, + } as PlayoutModel + getOrderedPartsAfterPlayheadMock.mockReturnValue([ + { ...lookaheadOffsetTestConstants.multiLayerPartWhile, classesForNext: [] } as any, + ]) + + context.directCollections.Pieces.findFetch = jest + .fn() + .mockResolvedValue(lookaheadOffsetTestConstants.multiLayerPartWhile.pieces) + + const res = await getLookeaheadObjects(context, playoutModel, { + next: { + ...lookaheadOffsetTestConstants.multiLayerPartWhile, + pieceInstances: lookaheadOffsetTestConstants.multiLayerPartWhile.pieces.map((piece) => + wrapPieceToInstance( + piece as any, + 'pA1' as any, + lookaheadOffsetTestConstants.multiLayerPartWhile.partInstance._id + ) + ), + }, + } as any) + + expect(res).toHaveLength(3) + expect(res.map((o) => o.layer).sort()).toEqual([`layer1_lookahead`, 'layer2_lookahead', 'layer3_lookahead']) + expect(res.map((o) => o.lookaheadOffset).sort()).toEqual([1000, 500]) + }) + test('Single layer part produces lookahead objects with the correct offsets', async () => { + playoutModel = { + ...playoutModel, + playlist: { + ...playoutModel.playlist, + nextTimeOffset: 1000, + }, + } as PlayoutModel + getOrderedPartsAfterPlayheadMock.mockReturnValue([ + { ...lookaheadOffsetTestConstants.singleLayerPart, classesForNext: [] } as any, + ]) + + context.directCollections.Pieces.findFetch = jest + .fn() + .mockResolvedValue(lookaheadOffsetTestConstants.singleLayerPart.pieces) + + const res = await getLookeaheadObjects(context, playoutModel, { + next: { + ...lookaheadOffsetTestConstants.singleLayerPart, + pieceInstances: lookaheadOffsetTestConstants.singleLayerPart.pieces.map((piece) => + wrapPieceToInstance( + piece as any, + 'pA1' as any, + lookaheadOffsetTestConstants.singleLayerPart.partInstance._id + ) + ), + }, + } as any) + expect(res).toHaveLength(2) + expect(res.map((o) => o.layer)).toEqual(['layer1_lookahead', 'layer1_lookahead']) + expect(res.map((o) => o.lookaheadOffset)).toEqual([500, undefined]) + }) + test('Single layer part produces lookahead objects with while enable values with the correct offsets', async () => { + playoutModel = { + ...playoutModel, + playlist: { + ...playoutModel.playlist, + nextTimeOffset: 1000, + }, + } as PlayoutModel + getOrderedPartsAfterPlayheadMock.mockReturnValue([ + { ...lookaheadOffsetTestConstants.singleLayerPartWhile, classesForNext: [] } as any, + ]) + + context.directCollections.Pieces.findFetch = jest + .fn() + .mockResolvedValue(lookaheadOffsetTestConstants.singleLayerPartWhile.pieces) + + const res = await getLookeaheadObjects(context, playoutModel, { + next: { + ...lookaheadOffsetTestConstants.singleLayerPartWhile, + pieceInstances: lookaheadOffsetTestConstants.singleLayerPartWhile.pieces.map((piece) => + wrapPieceToInstance( + piece as any, + 'pA1' as any, + lookaheadOffsetTestConstants.singleLayerPartWhile.partInstance._id + ) + ), + }, + } as any) + expect(res).toHaveLength(2) + expect(res.map((o) => o.layer)).toEqual(['layer1_lookahead', 'layer1_lookahead']) + expect(res.map((o) => o.lookaheadOffset)).toEqual([500, undefined]) + }) +}) diff --git a/packages/job-worker/src/playout/lookahead/__tests__/utils.ts b/packages/job-worker/src/playout/lookahead/__tests__/utils.ts new file mode 100644 index 00000000000..215dc55d114 --- /dev/null +++ b/packages/job-worker/src/playout/lookahead/__tests__/utils.ts @@ -0,0 +1,74 @@ +import { PartInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { PieceInstance, PieceInstancePiece } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' +import { ReadonlyDeep } from 'type-fest' +import { PartInstanceAndPieceInstances, PartAndPieces } from '../util.js' +import { findForLayerTestConstants } from './findForLayer/constants.js' +import { context, TfindLookaheadObjectsForPart } from './findForLayer/helpers/mockSetup.js' + +export function expectInstancesToMatch( + findLookaheadObjectsForPartMock: TfindLookaheadObjectsForPart, + index: number, + layer: string, + partInstanceInfo: PartInstanceAndPieceInstances, + previousPart: PartInstanceAndPieceInstances | undefined +): void { + expect(findLookaheadObjectsForPartMock).toHaveBeenNthCalledWith( + index, + context, + findForLayerTestConstants.current.part._id, + layer, + previousPart?.part.part, + { + part: partInstanceInfo.part.part, + usesInTransition: false, + pieces: partInstanceInfo.allPieces, + }, + partInstanceInfo?.part._id + ) +} + +export function createFakePiece(id: string, pieceProps?: Partial): PieceInstance { + return { + _id: id, + piece: { + ...(pieceProps ?? {}), + enable: { + start: 0, + ...(pieceProps ? pieceProps.enable : {}), + }, + }, + } as any +} + +export function expectPartToMatch( + findLookaheadObjectsForPartMock: TfindLookaheadObjectsForPart, + index: number, + layer: string, + partInfo: PartAndPieces, + previousPart: ReadonlyDeep | undefined, + currentPartInstanceId: PartInstanceId | null = null, + nextTimeOffset?: number +): void { + if (nextTimeOffset) + expect(findLookaheadObjectsForPartMock).toHaveBeenNthCalledWith( + index, + context, + currentPartInstanceId, + layer, + previousPart, + partInfo, + null, + nextTimeOffset + ) + else + expect(findLookaheadObjectsForPartMock).toHaveBeenNthCalledWith( + index, + context, + currentPartInstanceId, + layer, + previousPart, + partInfo, + null + ) +} diff --git a/packages/job-worker/src/playout/lookahead/findForLayer.ts b/packages/job-worker/src/playout/lookahead/findForLayer.ts index e09297c0776..2ffabc2a17f 100644 --- a/packages/job-worker/src/playout/lookahead/findForLayer.ts +++ b/packages/job-worker/src/playout/lookahead/findForLayer.ts @@ -11,17 +11,23 @@ export interface LookaheadResult { future: Array } +export interface PartInstanceAndPieceInstancesInfos { + previous?: PartInstanceAndPieceInstances + current?: PartInstanceAndPieceInstances + next?: PartInstanceAndPieceInstances +} + export function findLookaheadForLayer( context: JobContext, - currentPartInstanceId: PartInstanceId | null, - partInstancesInfo: PartInstanceAndPieceInstances[], - previousPartInstanceInfo: PartInstanceAndPieceInstances | undefined, + partInstancesInfo: PartInstanceAndPieceInstancesInfos, orderedPartInfos: Array, layer: string, lookaheadTargetFutureObjects: number, - lookaheadMaxSearchDistance: number + lookaheadMaxSearchDistance: number, + nextTimeOffset?: number | null ): LookaheadResult { const span = context.startSpan(`findLookaheadForlayer.${layer}`) + const currentPartId = partInstancesInfo.current?.part._id ?? null const res: LookaheadResult = { timed: [], future: [], @@ -29,36 +35,45 @@ export function findLookaheadForLayer( // Track the previous info for checking how the timeline will be built let previousPart: ReadonlyDeep | undefined - if (previousPartInstanceInfo) { - previousPart = previousPartInstanceInfo.part.part + if (partInstancesInfo.previous?.part.part) { + previousPart = partInstancesInfo.previous.part.part } // Generate timed/future objects for the partInstances - for (const partInstanceInfo of partInstancesInfo) { - if (!partInstanceInfo.onTimeline && lookaheadMaxSearchDistance <= 0) break + if (partInstancesInfo.current) { + const { objs: currentObjs, partInfo: currentPartInfo } = generatePartInstanceLookaheads( + context, + partInstancesInfo.current, + partInstancesInfo.current.part._id, + layer, + previousPart + ) - const partInfo: PartAndPieces = { - part: partInstanceInfo.part.part, - usesInTransition: partInstanceInfo.calculatedTimings.inTransitionStart !== null, - pieces: sortPieceInstancesByStart(partInstanceInfo.allPieces, partInstanceInfo.nowInPart), + if (partInstancesInfo.current.onTimeline) { + res.timed.push(...currentObjs) + } else { + res.future.push(...currentObjs) } + previousPart = currentPartInfo.part + } - const objs = findLookaheadObjectsForPart( + // for Lookaheads in the next part we need to take the nextTimeOffset into account. + if (partInstancesInfo.next) { + const { objs: nextObjs, partInfo: nextPartInfo } = generatePartInstanceLookaheads( context, - currentPartInstanceId, + partInstancesInfo.next, + currentPartId, layer, previousPart, - partInfo, - partInstanceInfo.part._id + nextTimeOffset ) - if (partInstanceInfo.onTimeline) { - res.timed.push(...objs) - } else { - res.future.push(...objs) + if (partInstancesInfo.next?.onTimeline) { + res.timed.push(...nextObjs) + } else if (lookaheadMaxSearchDistance >= 1 && lookaheadTargetFutureObjects > 0) { + res.future.push(...nextObjs) } - - previousPart = partInfo.part + previousPart = nextPartInfo.part } if (lookaheadMaxSearchDistance > 1 && lookaheadTargetFutureObjects > 0) { @@ -69,14 +84,18 @@ export function findLookaheadForLayer( } if (partInfo.pieces.length > 0 && isPartPlayable(partInfo.part)) { - const objs = findLookaheadObjectsForPart( - context, - currentPartInstanceId, - layer, - previousPart, - partInfo, - null - ) + const objs = + nextTimeOffset && !partInstancesInfo.next // apply the lookahead offset to the first future if an offset is set. + ? findLookaheadObjectsForPart( + context, + currentPartId, + layer, + previousPart, + partInfo, + null, + nextTimeOffset + ) + : findLookaheadObjectsForPart(context, currentPartId, layer, previousPart, partInfo, null) res.future.push(...objs) previousPart = partInfo.part } @@ -86,3 +105,43 @@ export function findLookaheadForLayer( if (span) span.end() return res } +function generatePartInstanceLookaheads( + context: JobContext, + partInstanceInfo: PartInstanceAndPieceInstances, + currentPartInstanceId: PartInstanceId | null, + layer: string, + previousPart: ReadonlyDeep | undefined, + nextTimeOffset?: number | null +): { objs: LookaheadTimelineObject[]; partInfo: PartAndPieces } { + const partInfo: PartAndPieces = { + part: partInstanceInfo.part.part, + usesInTransition: partInstanceInfo.calculatedTimings?.inTransitionStart ? true : false, + pieces: sortPieceInstancesByStart(partInstanceInfo.allPieces, partInstanceInfo.nowInPart), + } + if (nextTimeOffset) { + return { + objs: findLookaheadObjectsForPart( + context, + currentPartInstanceId, + layer, + previousPart, + partInfo, + partInstanceInfo.part._id, + nextTimeOffset + ), + partInfo, + } + } else { + return { + objs: findLookaheadObjectsForPart( + context, + currentPartInstanceId, + layer, + previousPart, + partInfo, + partInstanceInfo.part._id + ), + partInfo, + } + } +} diff --git a/packages/job-worker/src/playout/lookahead/findObjects.ts b/packages/job-worker/src/playout/lookahead/findObjects.ts index d96035a74f5..aa7def73da1 100644 --- a/packages/job-worker/src/playout/lookahead/findObjects.ts +++ b/packages/job-worker/src/playout/lookahead/findObjects.ts @@ -13,8 +13,9 @@ import { JobContext } from '../../jobs/index.js' import { PartAndPieces, PieceInstanceWithObjectMap } from './util.js' import { deserializePieceTimelineObjectsBlob } from '@sofie-automation/corelib/dist/dataModel/Piece' import { ReadonlyDeep, SetRequired } from 'type-fest' +import { computeLookaheadObject } from './lookaheadOffset.js' -function getBestPieceInstanceId(piece: ReadonlyDeep): string { +export function getBestPieceInstanceId(piece: ReadonlyDeep): string { if (!piece.isTemporary || piece.partInstanceId) { return unprotectString(piece._id) } @@ -90,9 +91,10 @@ export function findLookaheadObjectsForPart( _context: JobContext, currentPartInstanceId: PartInstanceId | null, layer: string, - previousPart: ReadonlyDeep | undefined, + partBefore: ReadonlyDeep | undefined, partInfo: PartAndPieces, - partInstanceId: PartInstanceId | null + partInstanceId: PartInstanceId | null, + nextTimeOffset?: number ): Array { // Sanity check, if no part to search, then abort if (!partInfo || partInfo.pieces.length === 0) { @@ -104,17 +106,11 @@ export function findLookaheadObjectsForPart( if (shouldIgnorePiece(partInfo, rawPiece)) continue const obj = getObjectMapForPiece(rawPiece).get(layer) - if (obj) { - allObjs.push( - literal({ - metaData: undefined, - ...obj, - objectType: TimelineObjType.RUNDOWN, - pieceInstanceId: getBestPieceInstanceId(rawPiece), - infinitePieceInstanceId: rawPiece.infinite?.infiniteInstanceId, - partInstanceId: partInstanceId ?? protectString(unprotectString(partInfo.part._id)), - }) - ) + + // we only consider lookahead objects for lookahead and calculate the lookaheadOffset for each object. + const computedLookaheadObj = computeLookaheadObject(obj, rawPiece, partInfo, partInstanceId, nextTimeOffset) + if (computedLookaheadObj) { + allObjs.push(computedLookaheadObj) } } @@ -124,8 +120,8 @@ export function findLookaheadObjectsForPart( } let classesFromPreviousPart: readonly string[] = [] - if (previousPart && currentPartInstanceId && partInstanceId) { - classesFromPreviousPart = previousPart.classesForNext || [] + if (partBefore && currentPartInstanceId && partInstanceId) { + classesFromPreviousPart = partBefore.classesForNext || [] } const transitionPiece = partInfo.usesInTransition @@ -147,26 +143,25 @@ export function findLookaheadObjectsForPart( const hasTransitionObj = transitionPiece && getObjectMapForPiece(transitionPiece).get(layer) const res: Array = [] - partInfo.pieces.forEach((piece) => { - if (shouldIgnorePiece(partInfo, piece)) return + allObjs.map((obj) => { + const piece = partInfo.pieces.find((piece) => unprotectString(piece._id) === obj.pieceInstanceId) + if (!piece) return // If there is a transition and this piece is abs0, it is assumed to be the primary piece and so does not need lookahead if ( hasTransitionObj && + obj.pieceInstanceId && piece.piece.pieceType === IBlueprintPieceType.Normal && piece.piece.enable.start === 0 ) { return } - // Note: This is assuming that there is only one use of a layer in each piece. - const obj = getObjectMapForPiece(piece).get(layer) if (obj) { const patchedContent = tryActivateKeyframesForObject(obj, !!transitionPiece, classesFromPreviousPart) res.push( literal({ - metaData: undefined, ...obj, objectType: TimelineObjType.RUNDOWN, pieceInstanceId: getBestPieceInstanceId(piece), @@ -177,7 +172,6 @@ export function findLookaheadObjectsForPart( ) } }) - return res } } diff --git a/packages/job-worker/src/playout/lookahead/index.ts b/packages/job-worker/src/playout/lookahead/index.ts index 64ac5a23372..85cf6459588 100644 --- a/packages/job-worker/src/playout/lookahead/index.ts +++ b/packages/job-worker/src/playout/lookahead/index.ts @@ -1,5 +1,5 @@ import { getOrderedPartsAfterPlayhead, PartAndPieces, PartInstanceAndPieceInstances } from './util.js' -import { findLookaheadForLayer, LookaheadResult } from './findForLayer.js' +import { findLookaheadForLayer, LookaheadResult, PartInstanceAndPieceInstancesInfos } from './findForLayer.js' import { PlayoutModel } from '../model/PlayoutModel.js' import { sortPieceInstancesByStart } from '../pieces.js' import { MappingExt } from '@sofie-automation/corelib/dist/dataModel/Studio' @@ -24,6 +24,7 @@ import { LookaheadTimelineObject } from './findObjects.js' import { hasPieceInstanceDefinitelyEnded } from '../timeline/lib.js' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { ReadonlyDeep } from 'type-fest' +import { filterPieceInstancesForNextPartWithOffset } from './lookaheadOffset.js' const LOOKAHEAD_OBJ_PRIORITY = 0.1 @@ -35,7 +36,7 @@ function parseSearchDistance(rawVal: number | undefined): number { } } -function findLargestLookaheadDistance(mappings: Array<[string, MappingExt]>): number { +export function findLargestLookaheadDistance(mappings: Array<[string, MappingExt]>): number { const values = mappings.map(([_id, m]) => parseSearchDistance(m.lookaheadMaxSearchDistance)) return _.max(values) } @@ -104,8 +105,24 @@ export async function getLookeaheadObjects( }, }) - const partInstancesInfo: PartInstanceAndPieceInstances[] = _.compact([ - partInstancesInfo0.current + // Track the previous info for checking how the timeline will be built + let previousPartInfo: PartInstanceAndPieceInstances | undefined + if (partInstancesInfo0.previous) { + previousPartInfo = removeInfiniteContinuations( + { + part: partInstancesInfo0.previous.partInstance, + onTimeline: true, + nowInPart: partInstancesInfo0.previous.partTimes.nowInPart, + allPieces: getPrunedEndedPieceInstances(partInstancesInfo0.previous), + calculatedTimings: partInstancesInfo0.previous.calculatedTimings, + }, + false + ) + } + + const partInstancesInfo: PartInstanceAndPieceInstancesInfos = { + previous: previousPartInfo, + current: partInstancesInfo0.current ? removeInfiniteContinuations( { part: partInstancesInfo0.current.partInstance, @@ -117,33 +134,21 @@ export async function getLookeaheadObjects( true ) : undefined, - partInstancesInfo0.next + next: partInstancesInfo0.next ? removeInfiniteContinuations( { part: partInstancesInfo0.next.partInstance, onTimeline: !!partInstancesInfo0.current?.partInstance?.part?.autoNext, //TODO -QL nowInPart: partInstancesInfo0.next.partTimes.nowInPart, - allPieces: partInstancesInfo0.next.pieceInstances, + allPieces: filterPieceInstancesForNextPartWithOffset( + partInstancesInfo0.next.pieceInstances, + playoutModel.playlist.nextTimeOffset + ), 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.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. @@ -187,16 +192,14 @@ export async function getLookeaheadObjects( parseSearchDistance(mapping.lookaheadMaxSearchDistance), futurePartCount ) - const lookaheadObjs = findLookaheadForLayer( context, - playoutModel.playlist.currentPartInfo?.partInstanceId ?? null, partInstancesInfo, - previousPartInfo, orderedPartInfos, layerId, lookaheadTargetObjects, - lookaheadMaxSearchDistance + lookaheadMaxSearchDistance, + playoutModel.playlist.nextTimeOffset ) timelineObjs.push(...processResult(lookaheadObjs, mapping.lookahead)) diff --git a/packages/job-worker/src/playout/lookahead/lookaheadOffset.ts b/packages/job-worker/src/playout/lookahead/lookaheadOffset.ts new file mode 100644 index 00000000000..ac67ca9b313 --- /dev/null +++ b/packages/job-worker/src/playout/lookahead/lookaheadOffset.ts @@ -0,0 +1,208 @@ +import { PartInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { TimelineObjType } from '@sofie-automation/corelib/dist/dataModel/Timeline' +import { literal } from '@sofie-automation/corelib/dist/lib' +import { protectString, unprotectString } from '@sofie-automation/corelib/dist/protectedString' +import { getBestPieceInstanceId, LookaheadTimelineObject } from './findObjects.js' +import { PartAndPieces, PieceInstanceWithObjectMap } from './util.js' +import { TimelineEnable } from 'superfly-timeline' +import { TimelineObjectCoreExt } from '@sofie-automation/blueprints-integration' +import { PieceInstanceWithTimings } from '@sofie-automation/corelib/dist/playout/processAndPrune' + +/** + * Computes a full {@link LookaheadTimelineObject} for a given piece/object pair, + * including the correct `lookaheadOffset` based on explicit numeric `start` or `while` expressions. + * + * This function: + * - Ignores objects whose `enable` is an array (unsupported for lookahead) + * - Extracts a usable numeric start reference from both the object and its parent piece + * - Supports lookahead semantics where `enable.while >= 1` acts like an implicit start value + * - Returns `undefined` when lookahead cannot be computed safely + * + * @param obj - The timeline object associated with the piece and layer. If `undefined`, + * no lookahead object is created. + * @param rawPiece - The piece instance containing the object map and its own enable + * expression, which determines the base start time for lookahead. + * @param partInfo - Metadata about the part the piece belongs to, required for + * associating the lookahead object with the correct `partInstanceId`. + * @param partInstanceId - The currently active or next part instance ID. If `null`, + * the function falls back to the part ID from `partInfo`. + * @param nextTimeOffset - An optional offset of the in point of the next part + * used to calculate the lookahead offset. If omitted, no + * lookahead offset is generated. + * + * @returns A fully constructed {@link LookaheadTimelineObject} ready to be pushed + * into the lookahead timeline, or `undefined` when no valid lookahead + * calculation is possible. + */ +export function computeLookaheadObject( + obj: TimelineObjectCoreExt | undefined, + rawPiece: PieceInstanceWithObjectMap, + partInfo: PartAndPieces, + partInstanceId: PartInstanceId | null, + nextTimeOffset?: number +): LookaheadTimelineObject | undefined { + if (!obj) return undefined + + const enable = obj.enable + + if (Array.isArray(enable)) return undefined + + const objStart = getStartValueFromEnable(enable) + const pieceStart = getStartValueFromEnable(rawPiece.piece.enable) + + // We make sure to only consider objects for lookahead that have an explicit numeric start/while value. (while = 1 and 0 is considered boolean) + if (pieceStart === undefined) return undefined + + let lookaheadOffset: number | undefined + // Only calculate lookaheadOffset if needed + if (nextTimeOffset) { + lookaheadOffset = computeLookaheadOffset(nextTimeOffset, pieceStart, objStart) + } + + return literal({ + metaData: undefined, + ...obj, + objectType: TimelineObjType.RUNDOWN, + pieceInstanceId: getBestPieceInstanceId(rawPiece), + infinitePieceInstanceId: rawPiece.infinite?.infiniteInstanceId, + partInstanceId: partInstanceId ?? protectString(unprotectString(partInfo.part._id)), + ...(lookaheadOffset !== undefined ? { lookaheadOffset } : {}), + }) +} + +/** + * Computes a lookahead offset for an object based on the piece's start time + * and the object's start time, relative to the next part's start time. + * + * @param nextTimeOffset - The upcoming part's start time (or similar time anchor). + * If undefined, no lookahead offset is produced. + * @param pieceStart - The start time of the piece this object belongs to. + * @param objStart - The explicit start time of the object (relative to the piece's start time). + * + * @returns A positive lookahead offset, or `undefined` if lookahead cannot be + * determined or would be non-positive. + */ +function computeLookaheadOffset( + nextTimeOffset: number | undefined, + pieceStart: number, + objStart?: number +): number | undefined { + if (nextTimeOffset === undefined || objStart === undefined) return undefined + + const offset = nextTimeOffset - pieceStart - objStart + return offset > 0 ? offset : undefined +} + +/** + * Extracts a numeric start reference from a {@link TimelineEnable} object + * + * The function handles two mutually exclusive cases: + * + * **1. `start` mode (`{ start: number }`)** + * - If `enable.start` is a numeric value, it is returned as `start`. + * - If `enable.start` is the string `"now"`, it is treated as `0`. + * + * **2. `while` mode (`{ while: number }`)** + * - If `enable.while` is numeric and greater than 1, it's value is returned as is. + * - If `enable.while` is numeric and equal to 1 it's treated as `0`. + * + * If no usable numeric `start` or `while` expression exists, the function returns `undefined`. + * + * @param enable - The timeline object's enable expression to extract start info from. + * @returns the relative start value of the object or undefined if there is no explicit value. + */ +function getStartValueFromEnable(enable: TimelineEnable): number | undefined { + // Case: start is a number + if (typeof enable.start === 'number') { + return enable.start + } + + // Case: start is "now" + if (enable.start === 'now') { + return 0 + } + + // Case: while is numeric + if (typeof enable.while === 'number') { + // while > 1 we treat it as a start value + if (enable.while > 1) { + return enable.while + } + // while === 1 we treat it as a `0` start value + else if (enable.while === 1) { + return 0 + } + } + + // No usable numeric expressions + return undefined +} + +/** + * Filters piece instances for the "next" part when a `nextTimeOffset` is defined. + * + * This function ensures that we only take into account each layer's relevant piece before + * or at the `nextTimeOffset`, while also preserving all pieces starting after the offset. + * + * This is needed to ignore pieces that start before the offset, but then are replaced by another piece at the offset. + * Without ignoring them the lookahead logic would treat the next part as if it was queued from it's start. + * + * **Filtering rules:** + * - If `nextTimeOffset` is not set (0, null, undefined), the original list is returned. + * - Pieces are grouped based on their `outputLayerId`. + * - For each layer: + * - We only keep pieces with the **latest start time** where `start/while <= nextTimeOffset` + * - All pieces *after* `nextTimeOffset` are kept for future lookaheads. + * + * The result is a flattened list of the selected pieces across all layers. + * + * @param {PieceInstanceWithTimings[]} pieces + * The list of piece instances to filter. + * + * @param {number | null | undefined} nextTimeOffset + * The time offset (in part time) that defines relevance. + * Pieces are compared based on their enable.start value. + * + * @returns {PieceInstanceWithTimings[]} + * A filtered list of pieces containing only the relevant pieces per layer. + */ +export function filterPieceInstancesForNextPartWithOffset( + pieces: PieceInstanceWithTimings[], + nextTimeOffset: number | null | undefined +): PieceInstanceWithTimings[] { + if (!nextTimeOffset) return pieces + // Group pieces by layer + const layers = new Map() + for (const p of pieces) { + const layer = p.piece.outputLayerId || '__noLayer__' + if (!layers.has(layer)) layers.set(layer, []) + layers.get(layer)?.push(p) + } + + const result: PieceInstanceWithTimings[] = [] + + for (const layerPieces of layers.values()) { + const beforeOrAt: PieceInstanceWithTimings[] = [] + const after: PieceInstanceWithTimings[] = [] + + for (const piece of layerPieces) { + const pieceStart = getStartValueFromEnable(piece.piece.enable) + + if (pieceStart !== undefined) { + if (pieceStart <= nextTimeOffset) beforeOrAt.push(piece) + else after.push(piece) + } + } + + // Pick the relevant piece before/at nextTimeOffset + if (beforeOrAt.length > 0) { + const best = beforeOrAt.reduce((a, b) => (a.piece.enable.start > b.piece.enable.start ? a : b)) + result.push(best) + } + + // Keep all pieces after nextTimeOffset for future lookaheads. + result.push(...after) + } + + return result +} diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts index 86e2d089150..f8e13be11b9 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts @@ -799,7 +799,8 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou const storedPartInstance = this.allPartInstances.get(partInstance.partInstance._id) if (!storedPartInstance) throw new Error(`PartInstance being set as next was not constructed correctly`) // Make sure we were given the exact same object - if (storedPartInstance !== partInstance) throw new Error(`PartInstance being set as next is not current`) + if (storedPartInstance.partInstance._id !== partInstance.partInstance._id) + throw new Error(`PartInstance being set as next is not current`) } if (partInstance) { diff --git a/packages/job-worker/src/playout/setNext.ts b/packages/job-worker/src/playout/setNext.ts index 8739e289a33..2078af3d290 100644 --- a/packages/job-worker/src/playout/setNext.ts +++ b/packages/job-worker/src/playout/setNext.ts @@ -33,6 +33,9 @@ import { import { NoteSeverity } from '@sofie-automation/blueprints-integration' import { convertNoteToNotification } from '../notifications/util.js' import { PersistentPlayoutStateStore } from '../blueprints/context/services/PersistantStateStore.js' +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' +import { PlayoutPartInstanceModelImpl } from './model/implementation/PlayoutPartInstanceModelImpl.js' +import { QuickLoopService } from './model/services/QuickLoopService.js' /** * Set or clear the nexted part, from a given PartInstance, or SelectNextPartResult @@ -572,14 +575,14 @@ function findFirstPlayablePartOrThrow(segment: PlayoutSegmentModel): ReadonlyDee * Set the nexted part, from a given DBPart * @param context Context for the running job * @param playoutModel The playout model of the playlist - * @param nextPart The Part to set as next + * @param nextPartOrInstance The Part to set as next * @param setManually Whether this was manually chosen by the user * @param nextTimeOffset The offset into the Part to start playback */ export async function setNextPartFromPart( context: JobContext, playoutModel: PlayoutModel, - nextPart: ReadonlyDeep, + nextPartOrInstance: ReadonlyDeep | ReadonlyDeep, setManually: boolean, nextTimeOffset?: number ): Promise { @@ -589,9 +592,37 @@ export async function setNextPartFromPart( throw UserError.create(UserErrorMessage.DuringHold) } - const consumesQueuedSegmentId = doesPartConsumeQueuedSegmentId(playoutModel, nextPart) + let consumesQueuedSegmentId: boolean | undefined - await setNextPart(context, playoutModel, { part: nextPart, consumesQueuedSegmentId }, setManually, nextTimeOffset) + if (!('part' in nextPartOrInstance)) { + consumesQueuedSegmentId = doesPartConsumeQueuedSegmentId(playoutModel, nextPartOrInstance) + + await setNextPart( + context, + playoutModel, + { + part: nextPartOrInstance, + consumesQueuedSegmentId, + }, + setManually, + nextTimeOffset + ) + } else { + await setNextPart( + context, + playoutModel, + new PlayoutPartInstanceModelImpl( + nextPartOrInstance as DBPartInstance, + await context.directCollections.PieceInstances.findFetch({ + partInstanceId: nextPartOrInstance._id, + }), + false, + new QuickLoopService(context, playoutModel) + ), + setManually, + nextTimeOffset + ) + } } function doesPartConsumeQueuedSegmentId(playoutModel: PlayoutModel, nextPart: ReadonlyDeep) { diff --git a/packages/job-worker/src/playout/setNextJobs.ts b/packages/job-worker/src/playout/setNextJobs.ts index a9ffc2c1f88..f9ce6988de8 100644 --- a/packages/job-worker/src/playout/setNextJobs.ts +++ b/packages/job-worker/src/playout/setNextJobs.ts @@ -1,5 +1,5 @@ import { PartId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part' +import { DBPart, isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part' import { RundownHoldState } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error' import { @@ -16,6 +16,7 @@ import { selectNewPartWithOffsets } from './moveNextPart.js' import { updateTimeline } from './timeline/generate.js' import { PlayoutSegmentModel } from './model/PlayoutSegmentModel.js' import { ReadonlyDeep } from 'type-fest' +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' /** * Set the next Part to a specified id @@ -26,19 +27,55 @@ export async function handleSetNextPart(context: JobContext, data: SetNextPartPr data, async (playoutModel) => { const playlist = playoutModel.playlist - if (!playlist.activationId) throw UserError.create(UserErrorMessage.InactiveRundown, undefined, 412) if (playlist.holdState && playlist.holdState !== RundownHoldState.COMPLETE) { throw UserError.create(UserErrorMessage.DuringHold, undefined, 412) } }, async (playoutModel) => { - // Ensure the part is playable and found - const nextPart = playoutModel.findPart(data.nextPartId) - if (!nextPart) throw UserError.create(UserErrorMessage.PartNotFound, undefined, 404) - if (!isPartPlayable(nextPart)) throw UserError.create(UserErrorMessage.PartNotPlayable, undefined, 412) + const playlist = playoutModel.playlist + + let nextPartOrInstance: ReadonlyDeep | DBPartInstance | undefined + let nextPartId: PartId | undefined + + if (data.nextPartInstanceId) { + // Fetch the part instance + const nextPartInstance = await context.directCollections.PartInstances.findOne({ + _id: data.nextPartInstanceId, + }) + if (!nextPartInstance) throw UserError.create(UserErrorMessage.PartNotFound, undefined, 404) + + // Determine if we need the part itself or can use the instance (We can't reuse the currently playing instance) + if ( + !playlist.nextPartInfo?.partInstanceId || + !playlist.currentPartInfo?.partInstanceId || + playlist.currentPartInfo?.partInstanceId === data.nextPartInstanceId + ) { + nextPartId = nextPartInstance.part._id + } else { + nextPartOrInstance = nextPartInstance + } + } else if (data.nextPartId) { + nextPartId = data.nextPartId + } - await setNextPartFromPart(context, playoutModel, nextPart, data.setManually ?? false, data.nextTimeOffset) + // If we have a nextPartId, resolve the actual part + if (nextPartId) { + const nextPart = playoutModel.findPart(nextPartId) + if (!nextPart) throw UserError.create(UserErrorMessage.PartNotFound, undefined, 404) + if (!isPartPlayable(nextPart)) throw UserError.create(UserErrorMessage.PartNotPlayable, undefined, 412) + nextPartOrInstance = nextPart + } + + if (nextPartOrInstance) { + await setNextPartFromPart( + context, + playoutModel, + nextPartOrInstance, + data.setManually ?? false, + data.nextTimeOffset + ) + } await updateTimeline(context, playoutModel) } diff --git a/packages/meteor-lib/src/api/userActions.ts b/packages/meteor-lib/src/api/userActions.ts index 345589da062..372778267f9 100644 --- a/packages/meteor-lib/src/api/userActions.ts +++ b/packages/meteor-lib/src/api/userActions.ts @@ -39,8 +39,9 @@ export interface NewUserActionAPI { userEvent: string, eventTime: Time, rundownPlaylistId: RundownPlaylistId, - partId: PartId, - timeOffset?: number + partOrInstanceId: PartId | PartInstanceId, + timeOffset?: number, + isInstance?: boolean ): Promise> setNextSegment( userEvent: string, diff --git a/packages/playout-gateway/package.json b/packages/playout-gateway/package.json index 74ef628f243..61b56a3acc4 100644 --- a/packages/playout-gateway/package.json +++ b/packages/playout-gateway/package.json @@ -56,7 +56,7 @@ "@sofie-automation/shared-lib": "26.3.0-1", "debug": "^4.4.3", "influx": "^5.12.0", - "timeline-state-resolver": "10.0.0-nightly-release53-20251217-143607-df590aa96.0", + "timeline-state-resolver": "10.0.0-nightly-release53-20260123-151128-04b075e87.0", "tslib": "^2.8.1", "underscore": "^1.13.7", "winston": "^3.19.0" diff --git a/packages/shared-lib/package.json b/packages/shared-lib/package.json index 7c6e641df74..8e23330ff2c 100644 --- a/packages/shared-lib/package.json +++ b/packages/shared-lib/package.json @@ -38,7 +38,7 @@ "dependencies": { "@mos-connection/model": "^5.0.0-alpha.0", "kairos-lib": "^0.2.3", - "timeline-state-resolver-types": "10.0.0-nightly-release53-20251217-143607-df590aa96.0", + "timeline-state-resolver-types": "10.0.0-nightly-release53-20260123-151128-04b075e87.0", "tslib": "^2.8.1", "type-fest": "^4.41.0" }, diff --git a/packages/webui/src/client/ui/RundownView.tsx b/packages/webui/src/client/ui/RundownView.tsx index d795959a66c..a679007484d 100644 --- a/packages/webui/src/client/ui/RundownView.tsx +++ b/packages/webui/src/client/ui/RundownView.tsx @@ -112,6 +112,7 @@ import { RundownViewContextProviders } from './RundownView/RundownViewContextPro import { AnimatePresence } from 'motion/react' import { UserError } from '@sofie-automation/corelib/dist/error' import { DragContextProvider } from './RundownView/DragContextProvider.js' +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance.js' const HIDE_NOTIFICATIONS_AFTER_MOUNT: number | undefined = 5000 @@ -751,7 +752,7 @@ const RundownViewContent = translateWithTracker { + private onSetNext = (part: DBPartInstance | DBPart | undefined, e: any, offset?: number, take?: boolean) => { const { t } = this.props if (this.props.userPermissions.studio && part && part._id && this.props.playlist) { const playlistId = this.props.playlist._id @@ -759,7 +760,7 @@ const RundownViewContent = translateWithTracker MeteorCall.userAction.setNext(e, ts, playlistId, part._id, offset), + (e, ts) => MeteorCall.userAction.setNext(e, ts, playlistId, part._id, offset, 'part' in part), (err) => { this.setState({ manualSetAsNext: true, diff --git a/packages/webui/src/client/ui/SegmentTimeline/Parts/FlattenedSourceLayers.tsx b/packages/webui/src/client/ui/SegmentTimeline/Parts/FlattenedSourceLayers.tsx index 0c000353d48..0cc49b48829 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/Parts/FlattenedSourceLayers.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/Parts/FlattenedSourceLayers.tsx @@ -19,7 +19,7 @@ export function FlattenedSourceLayers(props: Readonly onMouseDown(e), + onMouseDownCapture: (e) => onMouseDown(e), role: 'log', 'aria-live': 'assertive', 'aria-label': props.outputLayer.name, diff --git a/packages/webui/src/client/ui/SegmentTimeline/SegmentContextMenu.tsx b/packages/webui/src/client/ui/SegmentTimeline/SegmentContextMenu.tsx index 2576326f5d6..6fc5a7bb6c7 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/SegmentContextMenu.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/SegmentContextMenu.tsx @@ -12,16 +12,17 @@ import { Translated } from '../../lib/ReactMeteorData/ReactMeteorData.js' import { RundownUtils } from '../../lib/rundown.js' import { IContextMenuContext } from '../RundownView.js' import { PartUi, SegmentUi } from './SegmentTimelineContainer.js' -import { SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PartInstanceId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' import { UserEditOperationMenuItems } from '../UserEditOperations/RenderUserEditOperations.js' import { CoreUserEditingDefinition } from '@sofie-automation/corelib/dist/dataModel/UserEditingDefinitions' import * as RundownResolver from '../../lib/RundownResolver.js' import { SelectedElement } from '../RundownView/SelectedElementsContext.js' import { PieceExtended } from '../../lib/RundownResolver.js' +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance.js' interface IProps { - onSetNext: (part: DBPart | undefined, e: any, offset?: number, take?: boolean) => void + onSetNext: (partInstance: DBPartInstance | DBPart | undefined, e: any, offset?: number, take?: boolean) => void onSetNextSegment: (segmentId: SegmentId, e: any) => void onQueueNextSegment: (segmentId: SegmentId | null, e: any) => void onSetQuickLoopStart: (marker: QuickLoopMarker | null, e: any) => void @@ -70,6 +71,12 @@ export const SegmentContextMenu = withTranslation()( part?.instance._id !== this.props.playlist.nextPartInfo?.partInstanceId && part?.instance._id !== this.props.playlist.previousPartInfo?.partInstanceId + const isPartOrphaned: boolean | undefined = part ? part.instance.orphaned !== undefined : undefined + + const isPartNext: boolean | undefined = part + ? this.props.playlist.nextPartInfo?.partInstanceId === part.instance._id + : undefined + const canSetAsNext = !!this.props.playlist?.activationId return segment?.orphaned !== SegmentOrphanedReason.ADLIB_TESTING ? ( @@ -121,125 +128,152 @@ export const SegmentContextMenu = withTranslation()( )} )} - {part && !part.instance.part.invalid && timecode !== null && ( - <> - this.props.onSetNext(part.instance.part, e)} - disabled={!!part.instance.orphaned || !canSetAsNext} - > - Next') }}> - {startsAt !== null && - '\u00a0(' + RundownUtils.formatTimeToShortTime(Math.floor(startsAt / 1000) * 1000) + ')'} - - {startsAt !== null && part && this.props.enablePlayFromAnywhere ? ( - <> - {/* this.onSetAsNextFromHere(part.instance.part, e)} - disabled={isCurrentPart || !!part.instance.orphaned || !canSetAsNext} - > - Next Here') }}> ( - {RundownUtils.formatTimeToShortTime(Math.floor((startsAt + timecode) / 1000) * 1000)}) - */} - this.onPlayFromHere(part.instance.part, e)} - disabled={!!part.instance.orphaned || !canSetAsNext} - > - {t('Play from Here')} ( - {RundownUtils.formatTimeToShortTime(Math.floor((startsAt + timecode) / 1000) * 1000)}) - - - ) : null} - {this.props.enableQuickLoop && !RundownResolver.isLoopLocked(this.props.playlist) && ( - <> - {RundownResolver.isQuickLoopStart(part.partId, this.props.playlist) ? ( - this.props.onSetQuickLoopStart(null, e)}> - {t('Clear QuickLoop Start')} - - ) : ( + {part && + isPartNext !== undefined && + isPartOrphaned !== undefined && + !part.instance.part.invalid && + timecode !== null && ( + <> + this.props.onSetNext(part.instance.part, e)} + disabled={!!part.instance.orphaned || !canSetAsNext} + > + Next`), + }} + > + + {startsAt !== null && part && this.props.enablePlayFromAnywhere ? ( + <> - this.props.onSetQuickLoopStart( - { type: QuickLoopMarkerType.PART, id: part.instance.part._id }, + this.onSetAsNextFromHere( + part.instance, + this.props.playlist?.nextPartInfo?.partInstanceId ?? null, + this.props.playlist?.currentPartInfo?.partInstanceId ?? null, e ) } - disabled={!!part.instance.orphaned || !canSetAsNext} + disabled={this.getIsPlayFromHereDisabled()} > - {t('Set as QuickLoop Start')} - - )} - {RundownResolver.isQuickLoopEnd(part.partId, this.props.playlist) ? ( - this.props.onSetQuickLoopEnd(null, e)}> - {t('Clear QuickLoop End')} + Next` + ), + }} + > - ) : ( - this.props.onSetQuickLoopEnd( - { type: QuickLoopMarkerType.PART, id: part.instance.part._id }, + this.onPlayFromHere( + part.instance, + this.props.playlist?.nextPartInfo?.partInstanceId ?? null, e ) } - disabled={!!part.instance.orphaned || !canSetAsNext} + disabled={this.getIsPlayFromHereDisabled(true)} > - {t('Set as QuickLoop End')} + + {t( + `Play part from ${RundownUtils.formatTimeToShortTime(Math.floor(timecode / 1000) * 1000)}` + )} + - )} - - )} - - + + ) : null} + {this.props.enableQuickLoop && !RundownResolver.isLoopLocked(this.props.playlist) && ( + <> + {RundownResolver.isQuickLoopStart(part.partId, this.props.playlist) ? ( + this.props.onSetQuickLoopStart(null, e)}> + {t('Clear QuickLoop Start')} + + ) : ( + + this.props.onSetQuickLoopStart( + { type: QuickLoopMarkerType.PART, id: part.instance.part._id }, + e + ) + } + disabled={!!part.instance.orphaned || !canSetAsNext} + > + {t('Set as QuickLoop Start')} + + )} + {RundownResolver.isQuickLoopEnd(part.partId, this.props.playlist) ? ( + this.props.onSetQuickLoopEnd(null, e)}> + {t('Clear QuickLoop End')} + + ) : ( + + this.props.onSetQuickLoopEnd( + { type: QuickLoopMarkerType.PART, id: part.instance.part._id }, + e + ) + } + disabled={!!part.instance.orphaned || !canSetAsNext} + > + {t('Set as QuickLoop End')} + + )} + + )} - {piece && piece.instance.piece.userEditOperations && ( - )} - {this.props.enableUserEdits && ( - <> -
- this.props.onEditProps({ type: 'segment', elementId: part.instance.segmentId })} - > - {t('Edit Segment Properties')} - - this.props.onEditProps({ type: 'part', elementId: part.instance.part._id })} - > - {t('Edit Part Properties')} - - {piece && piece.instance.piece.userEditProperties && ( + {piece && piece.instance.piece.userEditOperations && ( + + )} + + {this.props.enableUserEdits && ( + <> +
this.props.onEditProps({ type: 'piece', elementId: piece.instance.piece._id })} + onClick={() => this.props.onEditProps({ type: 'segment', elementId: part.instance.segmentId })} > - {t('Edit Piece Properties')} + {t('Edit Segment Properties')} - )} - - )} - - )} + this.props.onEditProps({ type: 'part', elementId: part.instance.part._id })} + > + {t('Edit Part Properties')} + + {piece && piece.instance.piece.userEditProperties && ( + this.props.onEditProps({ type: 'piece', elementId: piece.instance.piece._id })} + > + {t('Edit Piece Properties')} + + )} + + )} + + )} ) : null @@ -268,15 +302,55 @@ export const SegmentContextMenu = withTranslation()( return null } } + private getIsPlayFromHereDisabled(take: boolean = false): boolean { + const offset = this.getTimePosition() ?? 0 + const playlist = this.props.playlist + const partInstance = this.getPartFromContext()?.instance + const isSelectedTimeWithinBounds = + (partInstance?.part.expectedDuration ?? + partInstance?.part.displayDuration ?? + partInstance?.part.expectedDurationWithTransition ?? + 0) < offset - // private onSetAsNextFromHere = (part: DBPart, e) => { - // const offset = this.getTimePosition() - // this.props.onSetNext(part, e, offset || 0) - // } + if (playlist && playlist?.activationId && (!take || !!partInstance?.orphaned)) { + if (!partInstance) return true + else { + return ( + (isSelectedTimeWithinBounds && partInstance._id === playlist.currentPartInfo?.partInstanceId) || + (!!partInstance.orphaned && partInstance._id === playlist.currentPartInfo?.partInstanceId) + ) + } + } + return false + } + + private onSetAsNextFromHere = ( + partInstance: DBPartInstance, + nextPartInstanceId: PartInstanceId | null, + currentPartInstanceId: PartInstanceId | null, + e: React.MouseEvent | React.TouchEvent + ) => { + const partInstanceAvailableForPlayout = partInstance.timings?.take !== undefined + const isCurrentPartInstance = partInstance._id === currentPartInstanceId + const isNextInstance = partInstance._id === nextPartInstanceId + const offset = this.getTimePosition() + this.props.onSetNext( + (partInstanceAvailableForPlayout && !isCurrentPartInstance) || isNextInstance + ? partInstance + : partInstance.part, + e, + offset || 0 + ) + } - private onPlayFromHere = (part: DBPart, e: React.MouseEvent | React.TouchEvent) => { + private onPlayFromHere = ( + partInstance: DBPartInstance, + nextPartInstanceId: PartInstanceId | null, + e: React.MouseEvent | React.TouchEvent + ) => { + const isNextInstance = partInstance._id === nextPartInstanceId const offset = this.getTimePosition() - this.props.onSetNext(part, e, offset || 0, true) + this.props.onSetNext(isNextInstance ? partInstance : partInstance.part, e, offset || 0, true) } private getPartStartsAt = (): number | null => { diff --git a/packages/yarn.lock b/packages/yarn.lock index 76fe59939d2..12a75e64f5e 100644 --- a/packages/yarn.lock +++ b/packages/yarn.lock @@ -7262,7 +7262,7 @@ __metadata: dependencies: "@mos-connection/model": "npm:^5.0.0-alpha.0" kairos-lib: "npm:^0.2.3" - timeline-state-resolver-types: "npm:10.0.0-nightly-release53-20251217-143607-df590aa96.0" + timeline-state-resolver-types: "npm:10.0.0-nightly-release53-20260123-151128-04b075e87.0" tslib: "npm:^2.8.1" type-fest: "npm:^4.41.0" languageName: unknown @@ -10368,16 +10368,16 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"atem-state@npm:1.2.0": - version: 1.2.0 - resolution: "atem-state@npm:1.2.0" +"atem-state@npm:1.2.1": + version: 1.2.1 + resolution: "atem-state@npm:1.2.1" dependencies: deepmerge: "npm:^4.3.1" tslib: "npm:^2.6.2" type-fest: "npm:^3.13.1" peerDependencies: - atem-connection: 3.4 - checksum: 10/9eecbc871e7e1311d05ef2a40ac620480bfef9deb93ef81ca277bd6e34700c17a6ca0a4f27d1369669ef96990745fb58baa538de388149180dbfd2394e197e02 + atem-connection: 3.7 + checksum: 10/be74a217e6310a4cadb8883b8bfe76c3df0bebbea30194deef63995c777cec72cd9017383faec7f760ce7033398ed9817ad1a3576d20075cfab56f761ecf2611 languageName: node linkType: hard @@ -24015,7 +24015,7 @@ asn1@evs-broadcast/node-asn1: "@sofie-automation/shared-lib": "npm:26.3.0-1" debug: "npm:^4.4.3" influx: "npm:^5.12.0" - timeline-state-resolver: "npm:10.0.0-nightly-release53-20251217-143607-df590aa96.0" + timeline-state-resolver: "npm:10.0.0-nightly-release53-20260123-151128-04b075e87.0" tslib: "npm:^2.8.1" underscore: "npm:^1.13.7" winston: "npm:^3.19.0" @@ -28884,40 +28884,39 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"timeline-state-resolver-api@npm:10.0.0-nightly-release53-20251217-143607-df590aa96.0": - version: 10.0.0-nightly-release53-20251217-143607-df590aa96.0 - resolution: "timeline-state-resolver-api@npm:10.0.0-nightly-release53-20251217-143607-df590aa96.0" +"timeline-state-resolver-api@npm:10.0.0-nightly-release53-20260123-151128-04b075e87.0": + version: 10.0.0-nightly-release53-20260123-151128-04b075e87.0 + resolution: "timeline-state-resolver-api@npm:10.0.0-nightly-release53-20260123-151128-04b075e87.0" dependencies: tslib: "npm:^2.8.1" peerDependencies: - timeline-state-resolver-types: 10.0.0-nightly-release53-20251217-143607-df590aa96.0 - checksum: 10/fb174edd6694643c8ca16f851593ab09cd2ea4edbadaa70944f92bf5dad97a18aab1b5a454a353428589cb104f150f379eaba394ae57099843388c12b27c7683 + timeline-state-resolver-types: 10.0.0-nightly-release53-20260123-151128-04b075e87.0 + checksum: 10/87af202ff3aea6d5445070704d636bcdb15d65c28ba1203612d33a1d3c1f10b22c50cfcb03b78e2044d5471afc80429dd401fc808fda650c7c6f0efcfe8ce7ab languageName: node linkType: hard -"timeline-state-resolver-types@npm:10.0.0-nightly-release53-20251217-143607-df590aa96.0": - version: 10.0.0-nightly-release53-20251217-143607-df590aa96.0 - resolution: "timeline-state-resolver-types@npm:10.0.0-nightly-release53-20251217-143607-df590aa96.0" +"timeline-state-resolver-types@npm:10.0.0-nightly-release53-20260123-151128-04b075e87.0": + version: 10.0.0-nightly-release53-20260123-151128-04b075e87.0 + resolution: "timeline-state-resolver-types@npm:10.0.0-nightly-release53-20260123-151128-04b075e87.0" dependencies: tslib: "npm:^2.8.1" - checksum: 10/6f17030e9f10568757b3ebaae40a54ce5220ce5c2f37d9b5d8a5d286704e4779c80fbfddae500e1952a6efdf6e76b67bc18d0151211c84eb194ae3b52f78c7bf + checksum: 10/7322e958a35bc9deaa332c201414248b097c5894b556e481c5a97d51e292cb61c017d3d373fe1ae3bf19383965535dbfe104d8c38ca7c109eadbffc781a08ed9 languageName: node linkType: hard -"timeline-state-resolver@npm:10.0.0-nightly-release53-20251217-143607-df590aa96.0": - version: 10.0.0-nightly-release53-20251217-143607-df590aa96.0 - resolution: "timeline-state-resolver@npm:10.0.0-nightly-release53-20251217-143607-df590aa96.0" +"timeline-state-resolver@npm:10.0.0-nightly-release53-20260123-151128-04b075e87.0": + version: 10.0.0-nightly-release53-20260123-151128-04b075e87.0 + resolution: "timeline-state-resolver@npm:10.0.0-nightly-release53-20260123-151128-04b075e87.0" dependencies: "@tv2media/v-connection": "npm:^7.3.4" atem-connection: "npm:3.7.0" - atem-state: "npm:1.2.0" + atem-state: "npm:1.2.1" cacheable-lookup: "npm:^5.0.4" casparcg-connection: "npm:6.3.3" casparcg-state: "npm:3.0.4" debug: "npm:^4.4.3" deepmerge: "npm:^4.3.1" emberplus-connection: "npm:^0.3.1" - eventemitter3: "npm:^4.0.7" got: "npm:^11.8.6" hpagent: "npm:^1.2.0" hyperdeck-connection: "npm:2.0.1" @@ -28930,16 +28929,16 @@ asn1@evs-broadcast/node-asn1: sprintf-js: "npm:^1.1.3" superfly-timeline: "npm:^9.2.0" threadedclass: "npm:^1.3.0" - timeline-state-resolver-api: "npm:10.0.0-nightly-release53-20251217-143607-df590aa96.0" - timeline-state-resolver-types: "npm:10.0.0-nightly-release53-20251217-143607-df590aa96.0" + timeline-state-resolver-api: "npm:10.0.0-nightly-release53-20260123-151128-04b075e87.0" + timeline-state-resolver-types: "npm:10.0.0-nightly-release53-20260123-151128-04b075e87.0" tslib: "npm:^2.8.1" tv-automation-quantel-gateway-client: "npm:^3.1.7" type-fest: "npm:^3.13.1" underscore: "npm:^1.13.7" - utf-8-validate: "npm:^6.0.5" - ws: "npm:^8.18.3" + utf-8-validate: "npm:^6.0.6" + ws: "npm:^8.19.0" xml-js: "npm:^1.6.11" - checksum: 10/82b22c7945946005485c38ad8fcb94314bcba99aaf3aa549ec30326bb33548e46fe3ee5f6d48c06e6b593405458cbf1bbbda8a95793f57507f0158180e441686 + checksum: 10/49dfc3189aa1001b0ee3406acea13f6667b1982281664c4d503a237a07e4c6888b717d8092b82b29b5418a237555d1f7093ee92cfcb224faaf4c165877f75241 languageName: node linkType: hard @@ -30226,13 +30225,13 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"utf-8-validate@npm:^6.0.5": - version: 6.0.5 - resolution: "utf-8-validate@npm:6.0.5" +"utf-8-validate@npm:^6.0.6": + version: 6.0.6 + resolution: "utf-8-validate@npm:6.0.6" dependencies: node-gyp: "npm:latest" node-gyp-build: "npm:^4.3.0" - checksum: 10/8c96d342064d3f03d7acf616fe727e484825f4f5f7a455059122787306b2df1a4e23c2d27f16bf7ba21293f4ce6ab3e683b893fe7b4c74ac9d43b871c10001a0 + checksum: 10/c1fa53fe5f0e3b7bf990a8ee41d890b10218b087a4ad401519a1a6353a427172fedc29c9af36b81080ea27b311802ae37b0e857b82aaa976238904398870f465 languageName: node linkType: hard @@ -31276,7 +31275,7 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"ws@npm:^8.13.0, ws@npm:^8.18.0, ws@npm:^8.18.3, ws@npm:^8.19.0": +"ws@npm:^8.13.0, ws@npm:^8.18.0, ws@npm:^8.19.0": version: 8.19.0 resolution: "ws@npm:8.19.0" peerDependencies: