Skip to content

Commit 8c154af

Browse files
authored
Merge pull request Sofie-Automation#1112 from bbc/feat/quickLoop
feat: Implement QuickLoop (SOFIE-2878)
2 parents e962e5e + 1d87ef1 commit 8c154af

File tree

114 files changed

+3784
-929
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

114 files changed

+3784
-929
lines changed

meteor/__mocks__/defaultCollectionObjects.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ import {
2626
ShowStyleVariantId,
2727
StudioId,
2828
} from '@sofie-automation/corelib/dist/dataModel/Ids'
29-
import { DEFAULT_MINIMUM_TAKE_SPAN } from '@sofie-automation/shared-lib/dist/core/constants'
29+
import {
30+
DEFAULT_FALLBACK_PART_DURATION,
31+
DEFAULT_MINIMUM_TAKE_SPAN,
32+
} from '@sofie-automation/shared-lib/dist/core/constants'
3033

3134
export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioId): DBRundownPlaylist {
3235
return {
@@ -106,6 +109,7 @@ export function defaultStudio(_id: StudioId): DBStudio {
106109
frameRate: 25,
107110
mediaPreviewsUrl: '',
108111
minimumTakeSpan: DEFAULT_MINIMUM_TAKE_SPAN,
112+
fallbackPartDuration: DEFAULT_FALLBACK_PART_DURATION,
109113
},
110114
_rundownVersionHash: '',
111115
routeSets: {},

meteor/server/api/rest/v1/typeConversion.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,12 @@ import {
3333
import { DBShowStyleBase } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase'
3434
import { DBShowStyleVariant } from '@sofie-automation/corelib/dist/dataModel/ShowStyleVariant'
3535
import { Blueprints, ShowStyleBases, Studios } from '../../../collections'
36-
import { DEFAULT_MINIMUM_TAKE_SPAN } from '@sofie-automation/shared-lib/dist/core/constants'
36+
import {
37+
DEFAULT_MINIMUM_TAKE_SPAN,
38+
DEFAULT_FALLBACK_PART_DURATION,
39+
} from '@sofie-automation/shared-lib/dist/core/constants'
3740
import { Bucket } from '@sofie-automation/meteor-lib/dist/collections/Buckets'
41+
import { ForceQuickLoopAutoNext } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist'
3842

3943
/*
4044
This file contains functions that convert between the internal Sofie-Core types and types exposed to the external API.
@@ -314,6 +318,9 @@ export function studioSettingsFrom(apiStudioSettings: APIStudioSettings): IStudi
314318
allowRundownResetOnAir: apiStudioSettings.allowRundownResetOnAir,
315319
preserveOrphanedSegmentPositionInRundown: apiStudioSettings.preserveOrphanedSegmentPositionInRundown,
316320
minimumTakeSpan: apiStudioSettings.minimumTakeSpan ?? DEFAULT_MINIMUM_TAKE_SPAN,
321+
enableQuickLoop: apiStudioSettings.enableQuickLoop,
322+
forceQuickLoopAutoNext: forceQuickLoopAutoNextFrom(apiStudioSettings.forceQuickLoopAutoNext),
323+
fallbackPartDuration: apiStudioSettings.fallbackPartDuration ?? DEFAULT_FALLBACK_PART_DURATION,
317324
}
318325
}
319326

@@ -330,6 +337,42 @@ export function APIStudioSettingsFrom(settings: IStudioSettings): APIStudioSetti
330337
allowRundownResetOnAir: settings.allowRundownResetOnAir,
331338
preserveOrphanedSegmentPositionInRundown: settings.preserveOrphanedSegmentPositionInRundown,
332339
minimumTakeSpan: settings.minimumTakeSpan,
340+
enableQuickLoop: settings.enableQuickLoop,
341+
forceQuickLoopAutoNext: APIForceQuickLoopAutoNextFrom(settings.forceQuickLoopAutoNext),
342+
fallbackPartDuration: settings.fallbackPartDuration,
343+
}
344+
}
345+
346+
export function forceQuickLoopAutoNextFrom(
347+
forceQuickLoopAutoNext: APIStudioSettings['forceQuickLoopAutoNext']
348+
): ForceQuickLoopAutoNext | undefined {
349+
if (!forceQuickLoopAutoNext) return undefined
350+
switch (forceQuickLoopAutoNext) {
351+
case 'disabled':
352+
return ForceQuickLoopAutoNext.DISABLED
353+
case 'enabled_forcing_min_duration':
354+
return ForceQuickLoopAutoNext.ENABLED_FORCING_MIN_DURATION
355+
case 'enabled_when_valid_duration':
356+
return ForceQuickLoopAutoNext.ENABLED_WHEN_VALID_DURATION
357+
default:
358+
assertNever(forceQuickLoopAutoNext)
359+
return undefined
360+
}
361+
}
362+
363+
export function APIForceQuickLoopAutoNextFrom(
364+
forceQuickLoopAutoNext: ForceQuickLoopAutoNext | undefined
365+
): APIStudioSettings['forceQuickLoopAutoNext'] {
366+
if (!forceQuickLoopAutoNext) return undefined
367+
switch (forceQuickLoopAutoNext) {
368+
case ForceQuickLoopAutoNext.DISABLED:
369+
return 'disabled'
370+
case ForceQuickLoopAutoNext.ENABLED_FORCING_MIN_DURATION:
371+
return 'enabled_forcing_min_duration'
372+
case ForceQuickLoopAutoNext.ENABLED_WHEN_VALID_DURATION:
373+
return 'enabled_when_valid_duration'
374+
default:
375+
assertNever(forceQuickLoopAutoNext)
333376
}
334377
}
335378

meteor/server/api/userActions.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
import { IngestDataCache, Parts, Pieces, Rundowns } from '../collections'
5050
import { IngestCacheType } from '@sofie-automation/corelib/dist/dataModel/IngestDataCache'
5151
import { verifyHashedToken } from './singleUseTokens'
52+
import { QuickLoopMarker } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist'
5253
import { runIngestOperation } from './ingest/lib'
5354
import { IngestJobs } from '@sofie-automation/corelib/dist/worker/ingest'
5455

@@ -1222,6 +1223,52 @@ class ServerUserActionAPI
12221223
)
12231224
}
12241225

1226+
async setQuickLoopStart(
1227+
userEvent: string,
1228+
eventTime: number,
1229+
playlistId: RundownPlaylistId,
1230+
marker: QuickLoopMarker | null
1231+
): Promise<ClientAPI.ClientResponse<void>> {
1232+
return ServerClientAPI.runUserActionInLogForPlaylistOnWorker(
1233+
this,
1234+
userEvent,
1235+
eventTime,
1236+
playlistId,
1237+
() => {
1238+
check(playlistId, String)
1239+
},
1240+
StudioJobs.SetQuickLoopMarker,
1241+
{
1242+
playlistId,
1243+
marker,
1244+
type: 'start',
1245+
}
1246+
)
1247+
}
1248+
1249+
async setQuickLoopEnd(
1250+
userEvent: string,
1251+
eventTime: number,
1252+
playlistId: RundownPlaylistId,
1253+
marker: QuickLoopMarker | null
1254+
): Promise<ClientAPI.ClientResponse<void>> {
1255+
return ServerClientAPI.runUserActionInLogForPlaylistOnWorker(
1256+
this,
1257+
userEvent,
1258+
eventTime,
1259+
playlistId,
1260+
() => {
1261+
check(playlistId, String)
1262+
},
1263+
StudioJobs.SetQuickLoopMarker,
1264+
{
1265+
playlistId,
1266+
marker,
1267+
type: 'end',
1268+
}
1269+
)
1270+
}
1271+
12251272
async createAdlibTestingRundownForShowStyleVariant(
12261273
userEvent: string,
12271274
eventTime: number,

meteor/server/lib/rest/v1/studios.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,5 +182,8 @@ export interface APIStudioSettings {
182182
multiGatewayNowSafeLatency?: number
183183
allowRundownResetOnAir?: boolean
184184
preserveOrphanedSegmentPositionInRundown?: boolean
185+
enableQuickLoop?: boolean
186+
forceQuickLoopAutoNext?: 'disabled' | 'enabled_when_valid_duration' | 'enabled_forcing_min_duration'
185187
minimumTakeSpan?: number
188+
fallbackPartDuration?: number
186189
}

meteor/server/publications/_publications.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import './packageManager/playoutContext'
99
import './pieceContentStatusUI/bucket/publication'
1010
import './pieceContentStatusUI/rundown/publication'
1111
import './organization'
12+
import './partsUI/publication'
13+
import './partInstancesUI/publication'
1214
import './peripheralDevice'
1315
import './peripheralDeviceForDevice'
1416
import './rundown'
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part'
2+
import {
3+
DBRundownPlaylist,
4+
ForceQuickLoopAutoNext,
5+
QuickLoopMarker,
6+
QuickLoopMarkerType,
7+
} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist'
8+
import { MarkerPosition, compareMarkerPositions } from '@sofie-automation/corelib/dist/playout/playlist'
9+
import { ProtectedString, unprotectString } from '@sofie-automation/corelib/dist/protectedString'
10+
import { DEFAULT_FALLBACK_PART_DURATION } from '@sofie-automation/shared-lib/dist/core/constants'
11+
import { getCurrentTime } from '../../lib/lib'
12+
import { generateTranslation } from '@sofie-automation/meteor-lib/dist/lib'
13+
import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio'
14+
import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance'
15+
import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment'
16+
import { ReadonlyObjectDeep } from 'type-fest/source/readonly-deep'
17+
import { ReactiveCacheCollection } from './ReactiveCacheCollection'
18+
19+
export function findPartPosition(
20+
part: DBPart,
21+
segmentRanks: Record<string, number>,
22+
rundownRanks: Record<string, number>
23+
): MarkerPosition {
24+
return {
25+
rundownRank: rundownRanks[part.rundownId as unknown as string] ?? 0,
26+
segmentRank: segmentRanks[part.segmentId as unknown as string] ?? 0,
27+
partRank: part._rank,
28+
}
29+
}
30+
31+
export function stringsToIndexLookup(strings: string[]): Record<string, number> {
32+
return strings.reduce((result, str, index) => {
33+
result[str] = index
34+
return result
35+
}, {} as Record<string, number>)
36+
}
37+
38+
export function extractRanks(docs: { _id: ProtectedString<any>; _rank: number }[]): Record<string, number> {
39+
return docs.reduce((result, doc) => {
40+
result[doc._id as unknown as string] = doc._rank
41+
return result
42+
}, {} as Record<string, number>)
43+
}
44+
45+
export function modifyPartForQuickLoop(
46+
part: DBPart,
47+
segmentRanks: Record<string, number>,
48+
rundownRanks: Record<string, number>,
49+
playlist: Pick<DBRundownPlaylist, 'quickLoop'>,
50+
studio: Pick<DBStudio, 'settings'>,
51+
quickLoopStartPosition: MarkerPosition | undefined,
52+
quickLoopEndPosition: MarkerPosition | undefined,
53+
canSetAutoNext = () => true
54+
): void {
55+
const partPosition = findPartPosition(part, segmentRanks, rundownRanks)
56+
const isLoopDefined = quickLoopStartPosition && quickLoopEndPosition
57+
const isLoopingOverriden =
58+
isLoopDefined &&
59+
playlist.quickLoop?.forceAutoNext !== ForceQuickLoopAutoNext.DISABLED &&
60+
compareMarkerPositions(quickLoopStartPosition, partPosition) >= 0 &&
61+
compareMarkerPositions(partPosition, quickLoopEndPosition) >= 0
62+
63+
const fallbackPartDuration = studio.settings.fallbackPartDuration ?? DEFAULT_FALLBACK_PART_DURATION
64+
65+
if (isLoopingOverriden && (part.expectedDuration ?? 0) < fallbackPartDuration) {
66+
if (playlist.quickLoop?.forceAutoNext === ForceQuickLoopAutoNext.ENABLED_FORCING_MIN_DURATION) {
67+
part.expectedDuration = fallbackPartDuration
68+
part.expectedDurationWithTransition = fallbackPartDuration
69+
} else if (playlist.quickLoop?.forceAutoNext === ForceQuickLoopAutoNext.ENABLED_WHEN_VALID_DURATION) {
70+
part.invalid = true
71+
part.invalidReason = {
72+
message: generateTranslation('Part duration is 0.'),
73+
}
74+
}
75+
}
76+
if (!canSetAutoNext()) return
77+
part.autoNext = part.autoNext || (isLoopingOverriden && (part.expectedDuration ?? 0) > 0)
78+
}
79+
80+
export function modifyPartInstanceForQuickLoop(
81+
partInstance: Omit<DBPartInstance, 'part.privateData'>,
82+
segmentRanks: Record<string, number>,
83+
rundownRanks: Record<string, number>,
84+
playlist: Pick<DBRundownPlaylist, 'quickLoop'>,
85+
studio: Pick<DBStudio, 'settings'>,
86+
quickLoopStartPosition: MarkerPosition | undefined,
87+
quickLoopEndPosition: MarkerPosition | undefined
88+
): void {
89+
// note that the logic for when a part does not do autonext in quickloop should reflect the logic in the QuickLoopService in job worker
90+
const canAutoNext = () => {
91+
const start = partInstance.timings?.plannedStartedPlayback
92+
if (start !== undefined && partInstance.part.expectedDuration) {
93+
// date.now - start = playback duration, duration + offset gives position in part
94+
const playbackDuration = getCurrentTime() - start
95+
96+
// If there is an auto next planned soon or was in the past
97+
if (partInstance.part.expectedDuration - playbackDuration < 0) {
98+
return false
99+
}
100+
}
101+
102+
return true
103+
}
104+
105+
modifyPartForQuickLoop(
106+
partInstance.part,
107+
segmentRanks,
108+
rundownRanks,
109+
playlist,
110+
studio,
111+
quickLoopStartPosition,
112+
quickLoopEndPosition,
113+
canAutoNext // do not adjust the part instance if we have passed the time where we can still enable auto next
114+
)
115+
}
116+
117+
export function findMarkerPosition(
118+
marker: QuickLoopMarker,
119+
fallback: number,
120+
segmentCache: ReadonlyObjectDeep<ReactiveCacheCollection<Pick<DBSegment, '_id' | '_rank' | 'rundownId'>>>,
121+
partCache:
122+
| { parts: ReadonlyObjectDeep<ReactiveCacheCollection<Pick<DBPart, '_id' | '_rank' | 'segmentId'>>> }
123+
| { partInstances: ReadonlyObjectDeep<ReactiveCacheCollection<DBPartInstance>> },
124+
rundownRanks: Record<string, number>
125+
): MarkerPosition {
126+
const part =
127+
marker.type === QuickLoopMarkerType.PART
128+
? 'parts' in partCache
129+
? partCache.parts.findOne(marker.id)
130+
: partCache.partInstances.findOne({ 'part._id': marker.id })?.part
131+
: undefined
132+
const partRank = part?._rank ?? fallback
133+
134+
const segmentId = marker.type === QuickLoopMarkerType.SEGMENT ? marker.id : part?.segmentId
135+
const segment = segmentId && segmentCache.findOne(segmentId)
136+
const segmentRank = segment?._rank ?? fallback
137+
138+
const rundownId = marker.type === QuickLoopMarkerType.RUNDOWN ? marker.id : segment?.rundownId
139+
let rundownRank = rundownId ? rundownRanks[unprotectString(rundownId)] : fallback
140+
141+
if (marker.type === QuickLoopMarkerType.PLAYLIST) rundownRank = fallback
142+
143+
return {
144+
rundownRank: rundownRank,
145+
segmentRank: segmentRank,
146+
partRank: partRank,
147+
}
148+
}

0 commit comments

Comments
 (0)