diff --git a/packages/job-worker/src/playout/lookahead/__tests__/findObjects.test.ts b/packages/job-worker/src/playout/lookahead/__tests__/findObjects.test.ts index e884b4ce2e..93c25ba6ba 100644 --- a/packages/job-worker/src/playout/lookahead/__tests__/findObjects.test.ts +++ b/packages/job-worker/src/playout/lookahead/__tests__/findObjects.test.ts @@ -1069,4 +1069,348 @@ describe('findLookaheadObjectsForPart', () => { ) expect(rehearsalInRehearsal).toHaveLength(1) }) + + describe('single piece with multiple objects on the same layer', () => { + const rundownId: RundownId = protectString('rundown0') + const layer0 = 'layer0' + const partInstanceId = protectString('partInstance0') + + test('all objects from a single piece on the same layer are returned', () => { + // A single piece that contributes two objects to the same layer should expose both + // objects in the lookahead result + const partInfo = { + part: definePart(rundownId), + usesInTransition: false, + pieces: literal([ + { + ...defaultPieceInstanceProps, + rundownId, + piece: { + ...defaultPieceInstanceProps.piece, + timelineObjectsString: serializePieceTimelineObjectsBlob([ + { + id: 'obj0', + enable: { start: 0 }, + layer: layer0, + content: { deviceType: TSR.DeviceType.ABSTRACT }, + priority: 0, + }, + { + id: 'obj1', + enable: { start: 100 }, + layer: layer0, + content: { deviceType: TSR.DeviceType.ABSTRACT }, + priority: 0, + }, + ]), + }, + }, + ]), + } + + const objects = findLookaheadObjectsForPart( + context, + null, + layer0, + undefined, + partInfo, + partInstanceId, + DEFAULT_PLAYOUT_STATE + ) + + expect(stripObjectProperties(objects)).toStrictEqual([ + { + id: 'obj0', + layer: layer0, + pieceInstanceId: 'piece0_instance', + infinitePieceInstanceId: undefined, + partInstanceId: partInstanceId, + }, + { + id: 'obj1', + layer: layer0, + pieceInstanceId: 'piece0_instance', + infinitePieceInstanceId: undefined, + partInstanceId: partInstanceId, + }, + ]) + }) + + test('all objects from a start=0 Normal piece are filtered when the transition has an object on this layer', () => { + // Note: this is sane, but maybe not 100% correct. There are times when it would be desirable to preserve the Normal piece's objects if they start in the part after the transition will have ended + const partInfo = { + part: definePart(rundownId), + usesInTransition: true, + pieces: literal([ + { + ...defaultPieceInstanceProps, + rundownId, + piece: { + ...defaultPieceInstanceProps.piece, + timelineObjectsString: serializePieceTimelineObjectsBlob([ + { + id: 'obj0', + enable: { start: 0 }, + layer: layer0, + content: { deviceType: TSR.DeviceType.ABSTRACT }, + priority: 0, + }, + { + id: 'obj1', + enable: { start: 100 }, + layer: layer0, + content: { deviceType: TSR.DeviceType.ABSTRACT }, + priority: 0, + }, + ]), + }, + }, + { + // Transition piece with an object on the same layer. + ...defaultPieceInstanceProps, + _id: protectString('piece1_instance'), + rundownId, + piece: { + ...defaultPieceInstanceProps.piece, + _id: protectString('piece1'), + pieceType: IBlueprintPieceType.InTransition, + timelineObjectsString: serializePieceTimelineObjectsBlob([ + { + id: 'trans0', + enable: { start: 0 }, + layer: layer0, + content: { deviceType: TSR.DeviceType.ABSTRACT }, + priority: 0, + }, + ]), + }, + }, + ]), + } + + const previousPart: DBPart = { disableNextInTransition: false, classesForNext: undefined } as any + const objects = findLookaheadObjectsForPart( + context, + partInstanceId, + layer0, + previousPart, + partInfo, + partInstanceId, + DEFAULT_PLAYOUT_STATE + ) + + // obj0 and obj1 are both filtered because their parent piece is Normal + start=0 and + // `hasTransitionObj` is truthy. Only the transition object should remain. + expect(stripObjectProperties(objects)).toStrictEqual([ + { + id: 'trans0', + layer: layer0, + pieceInstanceId: 'piece1_instance', + infinitePieceInstanceId: undefined, + partInstanceId: partInstanceId, + }, + ]) + }) + + test('all objects from a start=0 Normal piece are included when the transition has no object on this layer', () => { + const partInfo = { + part: definePart(rundownId), + usesInTransition: true, + pieces: literal([ + { + ...defaultPieceInstanceProps, + rundownId, + piece: { + ...defaultPieceInstanceProps.piece, + timelineObjectsString: serializePieceTimelineObjectsBlob([ + { + id: 'obj0', + enable: { start: 0 }, + layer: layer0, + content: { deviceType: TSR.DeviceType.ABSTRACT }, + priority: 0, + }, + { + id: 'obj1', + enable: { start: 100 }, + layer: layer0, + content: { deviceType: TSR.DeviceType.ABSTRACT }, + priority: 0, + }, + ]), + }, + }, + { + // Transition piece whose only object is on a *different* layer. + ...defaultPieceInstanceProps, + _id: protectString('piece1_instance'), + rundownId, + piece: { + ...defaultPieceInstanceProps.piece, + _id: protectString('piece1'), + pieceType: IBlueprintPieceType.InTransition, + timelineObjectsString: serializePieceTimelineObjectsBlob([ + { + id: 'trans0', + enable: { start: 0 }, + layer: 'other_layer', + content: { deviceType: TSR.DeviceType.ABSTRACT }, + priority: 0, + }, + ]), + }, + }, + ]), + } + + const previousPart: DBPart = { disableNextInTransition: false, classesForNext: undefined } as any + const objects = findLookaheadObjectsForPart( + context, + partInstanceId, + layer0, + previousPart, + partInfo, + partInstanceId, + DEFAULT_PLAYOUT_STATE + ) + + // `hasTransitionObj` is falsy for layer0, so the Normal piece's objects must not be + // skipped. Both obj0 and obj1 should appear. + expect(stripObjectProperties(objects)).toStrictEqual([ + { + id: 'obj0', + layer: layer0, + pieceInstanceId: 'piece0_instance', + infinitePieceInstanceId: undefined, + partInstanceId: partInstanceId, + }, + { + id: 'obj1', + layer: layer0, + pieceInstanceId: 'piece0_instance', + infinitePieceInstanceId: undefined, + partInstanceId: partInstanceId, + }, + ]) + }) + + test('multiple objects from a later-starting piece all appear alongside filtered start=0 objects', () => { + // Three pieces: one Normal at start=0 (should be filtered), one Normal at start=500 + // that contributes TWO objects (both should appear), and an InTransition piece with an + // object on the layer (should appear + triggers the start=0 filter). + const partInfo = { + part: definePart(rundownId), + usesInTransition: true, + // Sort so that InTransition comes first in the iteration order, matching production behaviour. + pieces: sortPieceInstancesByStart( + literal([ + { + ...defaultPieceInstanceProps, + rundownId, + piece: { + ...defaultPieceInstanceProps.piece, + enable: { start: 0 }, + timelineObjectsString: serializePieceTimelineObjectsBlob([ + { + id: 'obj0', + enable: { start: 0 }, + layer: layer0, + content: { deviceType: TSR.DeviceType.ABSTRACT }, + priority: 0, + }, + ]), + }, + }, + { + // Normal piece at start=500 — contributes TWO objects. + ...defaultPieceInstanceProps, + _id: protectString('piece1_instance'), + rundownId, + piece: { + ...defaultPieceInstanceProps.piece, + _id: protectString('piece1'), + enable: { start: 500 }, + timelineObjectsString: serializePieceTimelineObjectsBlob([ + { + id: 'obj1', + enable: { start: 0 }, + layer: layer0, + content: { deviceType: TSR.DeviceType.ABSTRACT }, + priority: 0, + }, + { + id: 'obj2', + enable: { start: 0 }, + layer: layer0, + content: { deviceType: TSR.DeviceType.ABSTRACT }, + priority: 0, + }, + ]), + }, + }, + { + // Transition piece. InTransition sorts before Normal pieces at the same start. + ...defaultPieceInstanceProps, + _id: protectString('piece2_instance'), + rundownId, + piece: { + ...defaultPieceInstanceProps.piece, + _id: protectString('piece2'), + pieceType: IBlueprintPieceType.InTransition, + enable: { start: 0 }, + timelineObjectsString: serializePieceTimelineObjectsBlob([ + { + id: 'trans0', + enable: { start: 0 }, + layer: layer0, + content: { deviceType: TSR.DeviceType.ABSTRACT }, + priority: 0, + }, + ]), + }, + }, + ]), + 0 + ), + } + + const previousPart: DBPart = { disableNextInTransition: false, classesForNext: undefined } as any + const objects = findLookaheadObjectsForPart( + context, + partInstanceId, + layer0, + previousPart, + partInfo, + partInstanceId, + DEFAULT_PLAYOUT_STATE + ) + + // obj0 (Normal, start=0) is filtered because `hasTransitionObj` is truthy. + // trans0 (InTransition) is always included. + // obj1 and obj2 (Normal, start=500) are not filtered since start != 0. + expect(stripObjectProperties(objects)).toStrictEqual([ + { + id: 'trans0', + layer: layer0, + pieceInstanceId: 'piece2_instance', + infinitePieceInstanceId: undefined, + partInstanceId: partInstanceId, + }, + { + id: 'obj1', + layer: layer0, + pieceInstanceId: 'piece1_instance', + infinitePieceInstanceId: undefined, + partInstanceId: partInstanceId, + }, + { + id: 'obj2', + layer: layer0, + pieceInstanceId: 'piece1_instance', + infinitePieceInstanceId: undefined, + partInstanceId: partInstanceId, + }, + ]) + }) + }) }) diff --git a/packages/job-worker/src/playout/lookahead/__tests__/lookaheadOffset/constants.ts b/packages/job-worker/src/playout/lookahead/__tests__/lookaheadOffset/constants.ts index c57f48221c..50b900aaf4 100644 --- a/packages/job-worker/src/playout/lookahead/__tests__/lookaheadOffset/constants.ts +++ b/packages/job-worker/src/playout/lookahead/__tests__/lookaheadOffset/constants.ts @@ -5,6 +5,46 @@ import { protectString } from '@sofie-automation/corelib/dist/protectedString' import { PlayoutModel } from '../../../model/PlayoutModel.js' import { JobContext } from '../../../../jobs/index.js' +/** + * Generates a piece with a single timeline object on the given layer. + * Use this instead of `makePiece` when a test only cares about search-distance + * or basic lookahead presence, and does not need to verify multi-object offset + * computation. Keeps each piece to exactly one object so that `lookaheadDepth` + * behaves as "one object per part" (the same as the pre-multi-object behaviour). + */ +export function makeSimplePiece({ + partId, + layer, + start = 0, +}: { + partId: string + layer: string + start?: number +}): Piece { + return literal>({ + _id: protectString(`piece_simple_${partId}_${layer}`), + startRundownId: protectString('r1'), + startPartId: protectString(partId), + enable: { start }, + outputLayerId: layer, + pieceType: IBlueprintPieceType.Normal, + timelineObjectsString: protectString( + JSON.stringify([ + { + id: `piece_simple_${partId}_${layer}_obj`, + layer, + enable: { start: 0 }, + content: { + deviceType: TSR.DeviceType.CASPARCG, + type: TSR.TimelineContentTypeCasparCg.MEDIA, + file: 'AMB', + }, + }, + ]) + ), + }) as Piece +} + export function makePiece({ partId, layer, diff --git a/packages/job-worker/src/playout/lookahead/__tests__/lookaheadOffset/lookaheadOffset.test.ts b/packages/job-worker/src/playout/lookahead/__tests__/lookaheadOffset/lookaheadOffset.test.ts index 7c969d1d61..67a4d4fc83 100644 --- a/packages/job-worker/src/playout/lookahead/__tests__/lookaheadOffset/lookaheadOffset.test.ts +++ b/packages/job-worker/src/playout/lookahead/__tests__/lookaheadOffset/lookaheadOffset.test.ts @@ -15,7 +15,7 @@ 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' +import { baseContext, basePlayoutModel, makePiece, makeSimplePiece, lookaheadOffsetTestConstants } from './constants.js' const findLargestLookaheadDistanceMock = jest.mocked(findLargestLookaheadDistance).mockImplementation(() => 0) const getOrderedPartsAfterPlayheadMock = jest.mocked(getOrderedPartsAfterPlayhead).mockImplementation(() => []) @@ -69,10 +69,10 @@ describe('lookahead offset integration', () => { 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' }), + makeSimplePiece({ partId: 'p1', layer: 'layer1' }), + makeSimplePiece({ partId: 'p2', layer: 'layer1' }), + makeSimplePiece({ partId: 'p3', layer: 'layer1' }), + makeSimplePiece({ partId: 'p4', layer: 'layer1' }), ]) context = { @@ -142,8 +142,8 @@ describe('lookahead offset integration', () => { context.directCollections.Pieces.findFetch = jest .fn() .mockResolvedValue([ - makePiece({ partId: 'p1', layer: 'layer1' }), - makePiece({ partId: 'p2', layer: 'layer1', start: 2000 }), + makeSimplePiece({ partId: 'p1', layer: 'layer1' }), + makeSimplePiece({ partId: 'p2', layer: 'layer1', start: 2000 }), ]) const res = await getLookeaheadObjects(context, playoutModel, {} as SelectedPartInstancesTimelineInfo) @@ -165,8 +165,8 @@ describe('lookahead offset integration', () => { context.directCollections.Pieces.findFetch = jest .fn() .mockResolvedValue([ - makePiece({ partId: 'pNext', layer: 'layer1', start: 0 }), - makePiece({ partId: 'p1', layer: 'layer2', start: 0 }), + makeSimplePiece({ partId: 'pNext', layer: 'layer1', start: 0 }), + makeSimplePiece({ partId: 'p1', layer: 'layer2', start: 0 }), ]) const res = await getLookeaheadObjects(context, playoutModel, { @@ -182,9 +182,9 @@ describe('lookahead offset integration', () => { }, pieceInstances: [ wrapPieceToInstance( - makePiece({ partId: 'pNext', layer: 'layer1', start: 0 }) as any, - 'pA1' as any, - 'pNextInstance' as any + makeSimplePiece({ partId: 'pNext', layer: 'layer1', start: 0 }), + protectString('pA1'), + protectString('pNextInstance') ), ], calculatedTimings: undefined, @@ -217,17 +217,45 @@ describe('lookahead offset integration', () => { ...lookaheadOffsetTestConstants.multiLayerPart, pieceInstances: lookaheadOffsetTestConstants.multiLayerPart.pieces.map((piece) => wrapPieceToInstance( - piece as any, - 'pA1' as any, + piece, + protectString('pA1'), 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]) + // With multi-object support each piece contributes all three of its timeline + // objects (pieceStart, beforeOffset, afterOffset) → 3 objects per layer × 3 layers = 9 + expect(res).toHaveLength(9) + expect(res.map((o) => o.layer).sort()).toEqual([ + 'layer1_lookahead', + 'layer1_lookahead', + 'layer1_lookahead', + 'layer2_lookahead', + 'layer2_lookahead', + 'layer2_lookahead', + 'layer3_lookahead', + 'layer3_lookahead', + 'layer3_lookahead', + ]) + // Layers are processed in insertion order (layer1, layer2, layer3). + // For layer1 (piece start=0, nextTimeOffset=1000): + // objPieceStart (start=0 → offset=1000), obj_beforeOffset (start=700 → offset=300), obj_afterOffset (start=1700 → undef) + // For layer2 (piece start=500): + // objPieceStart (start=0 → offset=500), obj_beforeOffset (start=200 → offset=300), obj_afterOffset (start=1200 → undef) + // For layer3 (piece start=1500 > nextTimeOffset): all offsets are undefined + expect(res.map((o) => o.lookaheadOffset)).toEqual([ + 1000, + 300, + undefined, + 500, + 300, + undefined, + undefined, + undefined, + undefined, + ]) }) test('Multi layer part produces lookahead objects with while enable values for all layers with the correct offsets', async () => { playoutModel = { @@ -250,17 +278,38 @@ describe('lookahead offset integration', () => { ...lookaheadOffsetTestConstants.multiLayerPartWhile, pieceInstances: lookaheadOffsetTestConstants.multiLayerPartWhile.pieces.map((piece) => wrapPieceToInstance( - piece as any, - 'pA1' as any, + piece, + protectString('pA1'), 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]) + // Same structure as the non-while variant — while enables produce identical offsets + expect(res).toHaveLength(9) + expect(res.map((o) => o.layer).sort()).toEqual([ + 'layer1_lookahead', + 'layer1_lookahead', + 'layer1_lookahead', + 'layer2_lookahead', + 'layer2_lookahead', + 'layer2_lookahead', + 'layer3_lookahead', + 'layer3_lookahead', + 'layer3_lookahead', + ]) + expect(res.map((o) => o.lookaheadOffset)).toEqual([ + 1000, + 300, + undefined, + 500, + 300, + undefined, + undefined, + undefined, + undefined, + ]) }) test('Single layer part produces lookahead objects with the correct offsets', async () => { playoutModel = { @@ -290,9 +339,14 @@ describe('lookahead offset integration', () => { ), }, } 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]) + // filterPieceInstancesForNextPartWithOffset keeps piece2 (start=500, best before nextTimeOffset=1000) + // and piece3 (start=1500, after nextTimeOffset). piece1 (start=0) is replaced by piece2. + // With multi-object support each kept piece contributes all three timeline objects → 3+3=6 + expect(res).toHaveLength(6) + expect(res.map((o) => o.layer)).toEqual(Array(6).fill('layer1_lookahead')) + // piece2 (start=500): objPieceStart (0→500), obj_beforeOffset (200→300), obj_afterOffset (1200→undef) + // piece3 (start=1500 > nextTimeOffset): all three objects have undefined offset + expect(res.map((o) => o.lookaheadOffset)).toEqual([500, 300, undefined, undefined, undefined, undefined]) }) test('Single layer part produces lookahead objects with while enable values with the correct offsets', async () => { playoutModel = { @@ -315,15 +369,16 @@ describe('lookahead offset integration', () => { ...lookaheadOffsetTestConstants.singleLayerPartWhile, pieceInstances: lookaheadOffsetTestConstants.singleLayerPartWhile.pieces.map((piece) => wrapPieceToInstance( - piece as any, - 'pA1' as any, + piece, + protectString('pA1'), 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]) + // Same structure as the non-while variant — while enables produce identical offsets + expect(res).toHaveLength(6) + expect(res.map((o) => o.layer)).toEqual(Array(6).fill('layer1_lookahead')) + expect(res.map((o) => o.lookaheadOffset)).toEqual([500, 300, undefined, undefined, undefined, undefined]) }) }) diff --git a/packages/job-worker/src/playout/lookahead/findObjects.ts b/packages/job-worker/src/playout/lookahead/findObjects.ts index a0704ebf9a..367233d0fa 100644 --- a/packages/job-worker/src/playout/lookahead/findObjects.ts +++ b/packages/job-worker/src/playout/lookahead/findObjects.ts @@ -64,9 +64,13 @@ function getObjectMapForPiece( for (const obj of objects) { if (!shouldIncludeObjectOnTimeline(playoutState, obj)) continue - // Note: This is assuming that there is only one use of a layer in each piece. - if (typeof obj.layer === 'string' && !piece.objectMap.has(obj.layer)) { - piece.objectMap.set(obj.layer, obj) + if (typeof obj.layer === 'string') { + const existing = piece.objectMap.get(obj.layer) + if (existing) { + existing.push(obj) + } else { + piece.objectMap.set(obj.layer, [obj]) + } } } } @@ -112,12 +116,14 @@ export function findLookaheadObjectsForPart( for (const rawPiece of partInfo.pieces) { if (shouldIgnorePiece(partInfo, rawPiece)) continue - const obj = getObjectMapForPiece(playoutState, rawPiece).get(layer) + const objs = getObjectMapForPiece(playoutState, rawPiece).get(layer) ?? [] // 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) + for (const obj of objs) { + const computedLookaheadObj = computeLookaheadObject(obj, rawPiece, partInfo, partInstanceId, nextTimeOffset) + if (computedLookaheadObj) { + allObjs.push(computedLookaheadObj) + } } } @@ -151,7 +157,7 @@ export function findLookaheadObjectsForPart( const res: Array = [] allObjs.map((obj) => { - const piece = partInfo.pieces.find((piece) => unprotectString(piece._id) === obj.pieceInstanceId) + const piece = partInfo.pieces.find((piece) => getBestPieceInstanceId(piece) === 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 diff --git a/packages/job-worker/src/playout/lookahead/util.ts b/packages/job-worker/src/playout/lookahead/util.ts index 72bb201dc6..e63b5f723c 100644 --- a/packages/job-worker/src/playout/lookahead/util.ts +++ b/packages/job-worker/src/playout/lookahead/util.ts @@ -18,7 +18,7 @@ export interface PartInstanceAndPieceInstances { } export interface PieceInstanceWithObjectMap extends ReadonlyDeep { /** Cache of objects built by findObjects. */ - objectMap?: Map> + objectMap?: Map[]> } export interface PartAndPieces { part: ReadonlyDeep