Skip to content

Commit cde9e20

Browse files
committed
feat: Branding in the replay-based infinites
1 parent 6189939 commit cde9e20

2 files changed

Lines changed: 277 additions & 10 deletions

File tree

‎packages/corelib/src/playout/__tests__/infinites.test.ts‎

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,206 @@ describe('Infinites', () => {
624624
})
625625
})
626626

627+
describe('Branding', () => {
628+
const segmentLegacyPersist: IPieceLifespan = {
629+
scope: 'segment',
630+
presence: 'forward-scope',
631+
inShadow: 'legacy-persist',
632+
}
633+
const rundownLegacyPersist: IPieceLifespan = {
634+
scope: 'rundown',
635+
presence: 'forward-scope',
636+
inShadow: 'legacy-persist',
637+
}
638+
639+
function limitTo(piece: Piece, onlyValidForBranding: string[]): Piece {
640+
return { ...piece, onlyValidForBranding }
641+
}
642+
643+
describe('the playing PartInstance', () => {
644+
/** Play the PieceInstances in part0 with the Branding selected, and return the ids of those continued into part1 */
645+
function getContinuedIds(pieceInstances: PieceInstance[], brandingId: string | null = null): string[] {
646+
const playing = createPlaying('part0', pieceInstances)
647+
const branded = { ...playing, partInstance: { ...playing.partInstance, brandingId } }
648+
return getContinuedPieceIds(getParams('part1', [], branded)).sort()
649+
}
650+
651+
function createOnChange(id: string, start: number | 'now', onlyValidForBranding?: string[]): PieceInstance {
652+
const piece = createPiece(id, 'part0', 0, 'layer', rundownPlayhead)
653+
return createPieceInstance(onlyValidForBranding ? limitTo(piece, onlyValidForBranding) : piece, {
654+
piece: { enable: { start } },
655+
})
656+
}
657+
658+
function createAdlibbedOnEnd(id: string, start: number | 'now'): PieceInstance {
659+
return createPieceInstance(createPiece(id, 'part0', 0, 'layer', rundownLegacyPersist), {
660+
dynamicallyConvertedToInfinite: 1,
661+
piece: { enable: { start } },
662+
})
663+
}
664+
665+
test('the onChange piece starting last is the one continued', () => {
666+
expect(getContinuedIds([createOnChange('early', 1000), createOnChange('late', 2000)])).toEqual(['late'])
667+
})
668+
669+
test('the onChange piece starting last is chosen regardless of the order they are considered in', () => {
670+
expect(getContinuedIds([createOnChange('late', 2000), createOnChange('early', 1000)])).toEqual(['late'])
671+
})
672+
673+
test("an onChange piece starting 'now' starts later than any planned start", () => {
674+
expect(getContinuedIds([createOnChange('planned', 2000), createOnChange('now', 'now')])).toEqual([
675+
'now',
676+
])
677+
})
678+
679+
test('the adlibbed onEnd piece starting last is the one continued', () => {
680+
expect(
681+
getContinuedIds([createAdlibbedOnEnd('early', 1000), createAdlibbedOnEnd('late', 2000)])
682+
).toEqual(['late'])
683+
})
684+
685+
test("an adlibbed onEnd piece starting 'now' starts later than any planned start", () => {
686+
expect(
687+
getContinuedIds([createAdlibbedOnEnd('planned', 2000), createAdlibbedOnEnd('now', 'now')])
688+
).toEqual(['now'])
689+
})
690+
691+
test('a piece hidden by the Branding is not continued, as it is not playing', () => {
692+
// Without the Branding it would have stopped the other, as it starts later
693+
expect(
694+
getContinuedIds(
695+
[createOnChange('shown', 1000), createOnChange('hidden', 2000, ['brandingB'])],
696+
'brandingA'
697+
)
698+
).toEqual(['shown'])
699+
})
700+
701+
test('a piece used with the selected Branding is continued', () => {
702+
expect(getContinuedIds([createOnChange('limited', 1000, ['brandingA'])], 'brandingA')).toEqual([
703+
'limited',
704+
])
705+
})
706+
707+
test('a piece limited to a Branding is stopped by a later unlimited one', () => {
708+
const limited = createOnChange('limited', 1000, ['brandingA'])
709+
const later = createOnChange('later', 2000)
710+
711+
expect(getContinuedIds([limited, later], 'brandingA')).toEqual(['later'])
712+
expect(getContinuedIds([later, limited], 'brandingA')).toEqual(['later'])
713+
})
714+
715+
test('a piece limited to a Branding does not stop an earlier unlimited one', () => {
716+
const early = createOnChange('early', 1000)
717+
const limited = createOnChange('limited', 2000, ['brandingA'])
718+
719+
expect(getContinuedIds([early, limited], 'brandingA')).toEqual(['early', 'limited'])
720+
expect(getContinuedIds([limited, early], 'brandingA')).toEqual(['early', 'limited'])
721+
})
722+
723+
test('a piece limited to a Branding is kept when an unlimited one starts at the same time', () => {
724+
const limited = createOnChange('limited', 1000, ['brandingA'])
725+
const unlimited = createOnChange('unlimited', 1000)
726+
727+
expect(getContinuedIds([limited, unlimited], 'brandingA')).toEqual(['limited', 'unlimited'])
728+
expect(getContinuedIds([unlimited, limited], 'brandingA')).toEqual(['limited', 'unlimited'])
729+
})
730+
})
731+
732+
describe('planned pieces', () => {
733+
/** The ids of the planned Pieces continued into part2, the last Part in segment0 */
734+
function getKeptIds(pieces: Piece[]): string[] {
735+
return getContinuedPieceIds(getParams('part2', pieces)).sort()
736+
}
737+
738+
function createInfinite(id: string, partId: string, sourceLayerId = 'layer'): Piece {
739+
return createPiece(id, partId, 0, sourceLayerId, segmentLegacyPersist)
740+
}
741+
742+
test('without Branding, only the last starting infinite on a layer is kept', () => {
743+
expect(getKeptIds([createInfinite('early', 'part0'), createInfinite('late', 'part1')])).toEqual([
744+
'late',
745+
])
746+
})
747+
748+
test('an infinite limited to a Branding does not stop the one before it', () => {
749+
expect(
750+
getKeptIds([
751+
createInfinite('early', 'part0'),
752+
limitTo(createInfinite('late', 'part1'), ['brandingA']),
753+
])
754+
).toEqual(['early', 'late'])
755+
})
756+
757+
test('infinites limited to different Brandings are all kept', () => {
758+
expect(
759+
getKeptIds([
760+
limitTo(createInfinite('early', 'part0'), ['brandingB']),
761+
limitTo(createInfinite('late', 'part1'), ['brandingA']),
762+
])
763+
).toEqual(['early', 'late'])
764+
})
765+
766+
test('an infinite limited to a Branding is stopped by a later unlimited one', () => {
767+
expect(
768+
getKeptIds([
769+
limitTo(createInfinite('early', 'part0'), ['brandingA']),
770+
createInfinite('late', 'part1'),
771+
])
772+
).toEqual(['late'])
773+
})
774+
775+
test('an infinite limited to a Branding is stopped by a later unlimited one, in either order', () => {
776+
expect(
777+
getKeptIds([
778+
createInfinite('late', 'part1'),
779+
limitTo(createInfinite('early', 'part0'), ['brandingA']),
780+
])
781+
).toEqual(['late'])
782+
})
783+
784+
test('an infinite limited to a Branding is kept when it starts at the same time as an unlimited one', () => {
785+
// Neither is preferred, so this has to be left to be resolved during playout
786+
const limited = limitTo(createInfinite('limited', 'part1'), ['brandingA'])
787+
const unlimited = createInfinite('unlimited', 'part1')
788+
789+
expect(getKeptIds([limited, unlimited])).toEqual(['limited', 'unlimited'])
790+
expect(getKeptIds([unlimited, limited])).toEqual(['limited', 'unlimited'])
791+
})
792+
793+
test('a later unlimited infinite stops both the limited and the unlimited ones before it', () => {
794+
expect(
795+
getKeptIds([
796+
createInfinite('early', 'part0'),
797+
limitTo(createInfinite('limited', 'part0'), ['brandingA']),
798+
createInfinite('latest', 'part1'),
799+
])
800+
).toEqual(['latest'])
801+
})
802+
803+
test('an unlimited infinite is kept when everything after it is limited', () => {
804+
expect(
805+
getKeptIds([
806+
createInfinite('early', 'part0'),
807+
limitTo(createInfinite('late', 'part1'), ['brandingA']),
808+
limitTo(createInfinite('latest', 'part1'), ['brandingB']),
809+
])
810+
).toEqual(['early', 'late', 'latest'])
811+
})
812+
813+
test('an override does not affect the selection, as it cannot move the Piece between layers', () => {
814+
const late: Piece = { ...createInfinite('late', 'part1'), branding: { brandingA: { name: 'branded' } } }
815+
816+
expect(getKeptIds([createInfinite('early', 'part0'), late])).toEqual(['late'])
817+
})
818+
819+
test('infinites on separate layers never compete', () => {
820+
expect(
821+
getKeptIds([createInfinite('early', 'part0', 'layer0'), createInfinite('late', 'part1', 'layer1')])
822+
).toEqual(['early', 'late'])
823+
})
824+
})
825+
})
826+
627827
describe('queries', () => {
628828
const part = getPart('part1')
629829

‎packages/corelib/src/playout/infinites.ts‎

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import _ from 'underscore'
1919
import { MongoQuery } from '../mongo.js'
2020
import { DBSegment, SegmentOrphanedReason } from '../dataModel/Segment.js'
2121
import { isShadowPiece, PieceLifespan } from './pieceLifespan.js'
22+
import { isValidForBranding } from './branding.js'
2223

2324
/*
2425
* Infinites
@@ -215,11 +216,17 @@ function resolveContinuedInfinites(params: PieceInstancesForPartParams): {
215216
}
216217

217218
const replayPlayingPartInstance = (playing: PlayingPartInstance, isOnPath: boolean) => {
219+
// A piece hidden by the Branding is not playing, so it neither continues nor stops anything
220+
const pieceInstances = playing.pieceInstances.filter((p) =>
221+
isValidForBranding(p.piece, playing.partInstance.brandingId)
222+
)
218223
const layersWithShadow = new Set(
219-
playing.pieceInstances.filter((p) => isShadowPiece(p.piece)).map((p) => p.piece.sourceLayerId)
224+
pieceInstances.filter((p) => isShadowPiece(p.piece)).map((p) => p.piece.sourceLayerId)
220225
)
226+
// When the pieces started in this PartInstance started, to tell which of them start together
227+
const liveStarts = new Map<ReadonlyDeep<PieceInstance>, number | 'now'>()
221228

222-
for (const { instance, start } of getPieceInstancesInOrder(playing)) {
229+
for (const { instance, start } of getPieceInstancesInOrder({ ...playing, pieceInstances })) {
223230
const layer = getLayer(instance.piece.sourceLayerId)
224231
const lifespan = PieceLifespan.from(instance.piece.lifespan)
225232
const isOpenEnded = !instance.userDuration && typeof instance.piece.enable.duration !== 'number'
@@ -249,26 +256,41 @@ function resolveContinuedInfinites(params: PieceInstancesForPartParams): {
249256
continue
250257
}
251258

252-
layer.live = stopLiveInfinites(layer.live, lifespan, isOpenEnded, isShadow, !isUnplanned)
259+
const isLimited = isLimitedToBranding(instance.piece)
260+
layer.live = stopLiveInfinites(
261+
layer.live,
262+
lifespan,
263+
isOpenEnded,
264+
isShadow,
265+
isLimited,
266+
(p) => isLimitedToBranding(p.piece) && isSameStart(liveStarts.get(p), start),
267+
!isUnplanned
268+
)
253269

254270
// The planned Pieces of the playing Part are replayed with the plan, but an adlib changes the plan
255271
// Only the ones started before it though, the later planned pieces of this Part are newer
256272
if (isOnPath && isAdlib) {
273+
const isInPart = (p: ReadonlyDeep<Piece>) => p.startPartId === playing.partInstance.part._id
257274
const isLaterInPart = (p: ReadonlyDeep<Piece>) =>
258-
p.startPartId === playing.partInstance.part._id && compareStarts(p.enable.start, start) > 0
275+
isInPart(p) && compareStarts(p.enable.start, start) > 0
259276
layer.planned = [
260277
...stopShadowedInfinites(
261278
layer.planned.filter((p) => !isLaterInPart(p)),
262279
(p) => p.lifespan,
263280
lifespan,
264281
isOpenEnded,
265-
isShadow
282+
isShadow,
283+
isLimited,
284+
(p) => isLimitedToBranding(p) && isInPart(p) && isSameStart(p.enable.start, start)
266285
),
267286
...layer.planned.filter(isLaterInPart),
268287
]
269288
}
270289

271-
if (continuesFromHere) layer.live.push(instance)
290+
if (continuesFromHere) {
291+
layer.live.push(instance)
292+
liveStarts.set(instance, start)
293+
}
272294
}
273295
}
274296

@@ -293,9 +315,22 @@ function resolveContinuedInfinites(params: PieceInstancesForPartParams): {
293315

294316
const isOpenEnded = typeof piece.enable.duration !== 'number'
295317
const isShadow = isShadowPiece(piece)
296-
layer.planned = stopShadowedInfinites(layer.planned, (p) => p.lifespan, lifespan, isOpenEnded, isShadow)
318+
const isLimited = isLimitedToBranding(piece)
319+
layer.planned = stopShadowedInfinites(
320+
layer.planned,
321+
(p) => p.lifespan,
322+
lifespan,
323+
isOpenEnded,
324+
isShadow,
325+
isLimited,
326+
(p) =>
327+
isLimitedToBranding(p) &&
328+
p.startPartId === piece.startPartId &&
329+
isSameStart(p.enable.start, piece.enable.start)
330+
)
297331
if (hasReplayedPlaying && playheadTravelsThrough) {
298-
layer.live = stopLiveInfinites(layer.live, lifespan, isOpenEnded, isShadow, true)
332+
// The live infinites started in the playing Part, so they never start together with a later planned piece
333+
layer.live = stopLiveInfinites(layer.live, lifespan, isOpenEnded, isShadow, isLimited, () => false, true)
299334
}
300335

301336
// Only forward scoped infinites continue from the plan. One following the playhead becomes an infinite once the
@@ -336,18 +371,27 @@ function resolveContinuedInfinites(params: PieceInstancesForPartParams): {
336371
* A new piece on a layer stops the infinites that stop in shadow. When it persists in shadow, it also ends the ones of
337372
* its own scope if it has no end, or if they are `legacy-persist`. Otherwise they resume once the new piece ends.
338373
* A shadow ends none of them.
374+
*
375+
* A piece limited to some Brandings ends none of them either, as it is hidden while another Branding is selected. The
376+
* Branding can change after these PieceInstances have been created, so which one is shown is left to playout.
377+
* For the same reason a piece limited to some Brandings is kept when an unlimited one starts at the same time.
339378
*/
340379
function stopShadowedInfinites<T>(
341380
active: T[],
342381
getLifespan: (entry: T) => ReadonlyDeep<Piece['lifespan']>,
343382
newLifespan: PieceLifespan,
344383
newIsOpenEnded: boolean,
345-
newIsShadow: boolean
384+
newIsShadow: boolean,
385+
newIsLimitedToBranding: boolean,
386+
isLimitedAndStartingTogether: (entry: T) => boolean
346387
): T[] {
347388
// A shadow only obstructs the layer, every infinite keeps being continued under it
348389
if (newIsShadow) return active
390+
if (newIsLimitedToBranding) return active
349391

350392
return active.filter((entry) => {
393+
if (isLimitedAndStartingTogether(entry)) return true
394+
351395
const lifespan = PieceLifespan.from(getLifespan(entry))
352396
if (!lifespan.persistsInShadow) return false
353397

@@ -366,9 +410,19 @@ function stopLiveInfinites(
366410
newLifespan: PieceLifespan,
367411
newIsOpenEnded: boolean,
368412
newIsShadow: boolean,
413+
newIsLimitedToBranding: boolean,
414+
isLimitedAndStartingTogether: (entry: ReadonlyDeep<PieceInstance>) => boolean,
369415
newIsPlanned: boolean
370416
): ReadonlyDeep<PieceInstance>[] {
371-
const remaining = stopShadowedInfinites(live, (p) => p.piece.lifespan, newLifespan, newIsOpenEnded, newIsShadow)
417+
const remaining = stopShadowedInfinites(
418+
live,
419+
(p) => p.piece.lifespan,
420+
newLifespan,
421+
newIsOpenEnded,
422+
newIsShadow,
423+
newIsLimitedToBranding,
424+
isLimitedAndStartingTogether
425+
)
372426
if (!newIsPlanned) return remaining
373427

374428
return live.filter(
@@ -455,6 +509,19 @@ function compareStarts(a: number | 'now', b: number | 'now'): number {
455509
return a - b
456510
}
457511

512+
/**
513+
* Whether two pieces in the same Part start at the same time.
514+
* Pieces starting 'now' never do, as they are ordered by when they were started
515+
*/
516+
function isSameStart(a: number | 'now' | undefined, b: number | 'now'): boolean {
517+
return typeof a === 'number' && typeof b === 'number' && a === b
518+
}
519+
520+
/** Whether a piece is limited to some Brandings, so it is hidden while another one is selected */
521+
function isLimitedToBranding(piece: ReadonlyDeep<Pick<Piece, 'onlyValidForBranding'>>): boolean {
522+
return !!piece.onlyValidForBranding
523+
}
524+
458525
/** Whether an infinite continuing from the given segment/rundown reaches the target Part */
459526
function isInfiniteInScope(
460527
params: PieceInstancesForPartParams,

0 commit comments

Comments
 (0)