Buckets:
ktongue/docker_container / simsite /frontend /node_modules /hls.js /src /controller /interstitials-schedule.ts
| import { findFragmentByPTS } from './fragment-finders'; | |
| import { | |
| ALIGNED_END_THRESHOLD_SECONDS, | |
| type BaseData, | |
| InterstitialEvent, | |
| type InterstitialId, | |
| TimelineOccupancy, | |
| } from '../loader/interstitial-event'; | |
| import { Logger } from '../utils/logger'; | |
| import type { DateRange } from '../loader/date-range'; | |
| import type { MediaSelection } from '../types/media-playlist'; | |
| import type { ILogger } from '../utils/logger'; | |
| const ABUTTING_THRESHOLD_SECONDS = 0.033; | |
| export type InterstitialScheduleEventItem = { | |
| event: InterstitialEvent; | |
| start: number; | |
| end: number; | |
| playout: { | |
| start: number; | |
| end: number; | |
| }; | |
| integrated: { | |
| start: number; | |
| end: number; | |
| }; | |
| }; | |
| export type InterstitialSchedulePrimaryItem = { | |
| nextEvent: InterstitialEvent | null; | |
| previousEvent: InterstitialEvent | null; | |
| event?: undefined; | |
| start: number; | |
| end: number; | |
| playout: { | |
| start: number; | |
| end: number; | |
| }; | |
| integrated: { | |
| start: number; | |
| end: number; | |
| }; | |
| }; | |
| export type InterstitialScheduleItem = | |
| | InterstitialScheduleEventItem | |
| | InterstitialSchedulePrimaryItem; | |
| export type InterstitialScheduleDurations = { | |
| primary: number; | |
| playout: number; | |
| integrated: number; | |
| }; | |
| export type TimelineType = 'primary' | 'playout' | 'integrated'; | |
| type ScheduleUpdateCallback = ( | |
| removed: InterstitialEvent[], | |
| previousItems: InterstitialScheduleItem[] | null, | |
| ) => void; | |
| export class InterstitialsSchedule extends Logger { | |
| private onScheduleUpdate: ScheduleUpdateCallback; | |
| private eventMap: Record<string, InterstitialEvent | undefined> = {}; | |
| public events: InterstitialEvent[] | null = null; | |
| public items: InterstitialScheduleItem[] | null = null; | |
| public durations: InterstitialScheduleDurations = { | |
| primary: 0, | |
| playout: 0, | |
| integrated: 0, | |
| }; | |
| constructor(onScheduleUpdate: ScheduleUpdateCallback, logger: ILogger) { | |
| super('interstitials-sched', logger); | |
| this.onScheduleUpdate = onScheduleUpdate; | |
| } | |
| public destroy() { | |
| this.reset(); | |
| // @ts-ignore | |
| this.onScheduleUpdate = null; | |
| } | |
| public reset() { | |
| this.eventMap = {}; | |
| this.setDurations(0, 0, 0); | |
| if (this.events) { | |
| this.events.forEach((interstitial) => interstitial.reset()); | |
| } | |
| this.events = this.items = null; | |
| } | |
| public resetErrorsInRange(start: number, end: number): number { | |
| if (this.events) { | |
| return this.events.reduce((count, interstitial) => { | |
| if ( | |
| start <= interstitial.startOffset && | |
| end > interstitial.startOffset | |
| ) { | |
| delete interstitial.error; | |
| return count + 1; | |
| } | |
| return count; | |
| }, 0); | |
| } | |
| return 0; | |
| } | |
| get duration(): number { | |
| const items = this.items; | |
| return items ? items[items.length - 1].end : 0; | |
| } | |
| get length(): number { | |
| return this.items ? this.items.length : 0; | |
| } | |
| public getEvent( | |
| identifier: InterstitialId | undefined, | |
| ): InterstitialEvent | null { | |
| return identifier ? this.eventMap[identifier] || null : null; | |
| } | |
| public hasEvent(identifier: InterstitialId): boolean { | |
| return identifier in this.eventMap; | |
| } | |
| public findItemIndex(item: InterstitialScheduleItem, time?: number): number { | |
| if (item.event) { | |
| // Find Event Item | |
| return this.findEventIndex(item.event.identifier); | |
| } | |
| // Find Primary Item | |
| let index = -1; | |
| if (item.nextEvent) { | |
| index = this.findEventIndex(item.nextEvent.identifier) - 1; | |
| } else if (item.previousEvent) { | |
| index = this.findEventIndex(item.previousEvent.identifier) + 1; | |
| } | |
| const items = this.items; | |
| if (items) { | |
| if (!items[index]) { | |
| if (time === undefined) { | |
| time = item.start; | |
| } | |
| index = this.findItemIndexAtTime(time); | |
| } | |
| // Only return index of a Primary Item | |
| while (index >= 0 && items[index]?.event) { | |
| // If index found is an interstitial it is not a valid result as it should have been matched up top | |
| // decrement until result is negative (not found) or a primary segment | |
| index--; | |
| } | |
| } | |
| return index; | |
| } | |
| public findItemIndexAtTime( | |
| timelinePos: number, | |
| timelineType?: TimelineType, | |
| ): number { | |
| const items = this.items; | |
| if (items) { | |
| for (let i = 0; i < items.length; i++) { | |
| let timeRange: { start: number; end: number } = items[i]; | |
| if (timelineType && timelineType !== 'primary') { | |
| timeRange = timeRange[timelineType]; | |
| } | |
| if ( | |
| timelinePos === timeRange.start || | |
| (timelinePos > timeRange.start && timelinePos < timeRange.end) | |
| ) { | |
| return i; | |
| } | |
| } | |
| } | |
| return -1; | |
| } | |
| public findJumpRestrictedIndex(startIndex: number, endIndex: number): number { | |
| const items = this.items; | |
| if (items) { | |
| for (let i = startIndex; i <= endIndex; i++) { | |
| if (!items[i]) { | |
| break; | |
| } | |
| const event = items[i].event; | |
| if (event?.restrictions.jump && !event.appendInPlace) { | |
| return i; | |
| } | |
| } | |
| } | |
| return -1; | |
| } | |
| public findEventIndex(identifier: InterstitialId): number { | |
| const items = this.items; | |
| if (items) { | |
| for (let i = items.length; i--; ) { | |
| if (items[i].event?.identifier === identifier) { | |
| return i; | |
| } | |
| } | |
| } | |
| return -1; | |
| } | |
| public findAssetIndex(event: InterstitialEvent, timelinePos: number): number { | |
| const assetList = event.assetList; | |
| const length = assetList.length; | |
| if (length > 1) { | |
| for (let i = 0; i < length; i++) { | |
| const asset = assetList[i]; | |
| if (!asset.error) { | |
| const timelineStart = asset.timelineStart; | |
| if ( | |
| timelinePos === timelineStart || | |
| (timelinePos > timelineStart && | |
| (timelinePos < timelineStart + (asset.duration || 0) || | |
| i === length - 1)) | |
| ) { | |
| return i; | |
| } | |
| } | |
| } | |
| } | |
| return 0; | |
| } | |
| public get assetIdAtEnd(): string | null { | |
| const interstitialAtEnd = this.items?.[this.length - 1]?.event; | |
| if (interstitialAtEnd) { | |
| const assetList = interstitialAtEnd.assetList; | |
| const assetAtEnd = assetList[assetList.length - 1]; | |
| if (assetAtEnd) { | |
| return assetAtEnd.identifier; | |
| } | |
| } | |
| return null; | |
| } | |
| public parseInterstitialDateRanges( | |
| mediaSelection: MediaSelection, | |
| enableAppendInPlace: boolean, | |
| ) { | |
| const details = mediaSelection.main.details!; | |
| const { dateRanges } = details; | |
| const previousInterstitialEvents = this.events; | |
| const interstitialEvents = this.parseDateRanges( | |
| dateRanges, | |
| { | |
| url: details.url, | |
| }, | |
| enableAppendInPlace, | |
| ); | |
| const ids = Object.keys(dateRanges); | |
| const removedInterstitials = previousInterstitialEvents | |
| ? previousInterstitialEvents.filter( | |
| (event) => !ids.includes(event.identifier), | |
| ) | |
| : []; | |
| if (interstitialEvents.length) { | |
| // pre-rolls, post-rolls, and events with the same start time are played in playlist tag order | |
| // all other events are ordered by start time | |
| interstitialEvents.sort((a, b) => { | |
| const aPre = a.cue.pre; | |
| const aPost = a.cue.post; | |
| const bPre = b.cue.pre; | |
| const bPost = b.cue.post; | |
| if (aPre && !bPre) { | |
| return -1; | |
| } | |
| if (bPre && !aPre) { | |
| return 1; | |
| } | |
| if (aPost && !bPost) { | |
| return 1; | |
| } | |
| if (bPost && !aPost) { | |
| return -1; | |
| } | |
| if (!aPre && !bPre && !aPost && !bPost) { | |
| const startA = a.startTime; | |
| const startB = b.startTime; | |
| if (startA !== startB) { | |
| return startA - startB; | |
| } | |
| } | |
| return a.dateRange.tagOrder - b.dateRange.tagOrder; | |
| }); | |
| } | |
| this.events = interstitialEvents; | |
| // Clear removed DateRanges from buffered list (kills playback of active Interstitials) | |
| removedInterstitials.forEach((interstitial) => { | |
| this.removeEvent(interstitial); | |
| }); | |
| this.updateSchedule(mediaSelection, removedInterstitials); | |
| } | |
| public updateSchedule( | |
| mediaSelection: MediaSelection, | |
| removedInterstitials: InterstitialEvent[] = [], | |
| forceUpdate: boolean = false, | |
| ) { | |
| const events = this.events || []; | |
| if (events.length || removedInterstitials.length || this.length < 2) { | |
| const currentItems = this.items; | |
| const updatedItems = this.parseSchedule(events, mediaSelection); | |
| const updated = | |
| forceUpdate || | |
| removedInterstitials.length || | |
| currentItems?.length !== updatedItems.length || | |
| updatedItems.some((item, i) => { | |
| return ( | |
| Math.abs(item.playout.start - currentItems[i].playout.start) > | |
| 0.005 || | |
| Math.abs(item.playout.end - currentItems[i].playout.end) > 0.005 | |
| ); | |
| }); | |
| if (updated) { | |
| this.items = updatedItems; | |
| // call interstitials-controller onScheduleUpdated() | |
| this.onScheduleUpdate(removedInterstitials, currentItems); | |
| } | |
| } | |
| } | |
| private parseDateRanges( | |
| dateRanges: Record<string, DateRange | undefined>, | |
| baseData: BaseData, | |
| enableAppendInPlace: boolean, | |
| ): InterstitialEvent[] { | |
| const interstitialEvents: InterstitialEvent[] = []; | |
| const ids = Object.keys(dateRanges); | |
| for (let i = 0; i < ids.length; i++) { | |
| const id = ids[i]; | |
| const dateRange = dateRanges[id]!; | |
| if (dateRange.isInterstitial) { | |
| let interstitial = this.eventMap[id]; | |
| if (interstitial) { | |
| // Update InterstitialEvent already parsed and mapped | |
| // This retains already loaded duration and loaded asset list info | |
| interstitial.setDateRange(dateRange); | |
| } else { | |
| interstitial = new InterstitialEvent(dateRange, baseData); | |
| this.eventMap[id] = interstitial; | |
| if (enableAppendInPlace === false) { | |
| interstitial.appendInPlace = enableAppendInPlace; | |
| } | |
| } | |
| interstitialEvents.push(interstitial); | |
| } | |
| } | |
| return interstitialEvents; | |
| } | |
| private parseSchedule( | |
| interstitialEvents: InterstitialEvent[], | |
| mediaSelection: MediaSelection, | |
| ): InterstitialScheduleItem[] { | |
| const schedule: InterstitialScheduleItem[] = []; | |
| const details = mediaSelection.main.details!; | |
| const primaryDuration = details.live ? Infinity : details.edge; | |
| let playoutDuration = 0; | |
| // Filter events that have errored from the schedule (Primary fallback) | |
| interstitialEvents = interstitialEvents.filter( | |
| (event) => !event.error && !(event.cue.once && event.hasPlayed), | |
| ); | |
| if (interstitialEvents.length) { | |
| // Update Schedule | |
| this.resolveOffsets(interstitialEvents, mediaSelection); | |
| // Populate Schedule with Interstitial Event and Primary Segment Items | |
| let primaryPosition = 0; | |
| let integratedTime = 0; | |
| interstitialEvents.forEach((interstitial, i) => { | |
| const preroll = interstitial.cue.pre; | |
| const postroll = interstitial.cue.post; | |
| const previousEvent = | |
| (interstitialEvents[i - 1] as InterstitialEvent | undefined) || null; | |
| const appendInPlace = interstitial.appendInPlace; | |
| const eventStart = postroll | |
| ? primaryDuration | |
| : interstitial.startOffset; | |
| const interstitialDuration = interstitial.duration; | |
| const timelineDuration = | |
| interstitial.timelineOccupancy === TimelineOccupancy.Range | |
| ? interstitialDuration | |
| : 0; | |
| const resumptionOffset = interstitial.resumptionOffset; | |
| const inSameStartTimeSequence = previousEvent?.startTime === eventStart; | |
| const start = eventStart + interstitial.cumulativeDuration; | |
| let end = appendInPlace | |
| ? start + interstitialDuration | |
| : eventStart + resumptionOffset; | |
| if (preroll || (!postroll && eventStart <= 0)) { | |
| // preroll or in-progress midroll | |
| const integratedStart = integratedTime; | |
| integratedTime += timelineDuration; | |
| interstitial.timelineStart = start; | |
| const playoutStart = playoutDuration; | |
| playoutDuration += interstitialDuration; | |
| schedule.push({ | |
| event: interstitial, | |
| start, | |
| end, | |
| playout: { | |
| start: playoutStart, | |
| end: playoutDuration, | |
| }, | |
| integrated: { | |
| start: integratedStart, | |
| end: integratedTime, | |
| }, | |
| }); | |
| } else if (eventStart <= primaryDuration) { | |
| if (!inSameStartTimeSequence) { | |
| const segmentDuration = eventStart - primaryPosition; | |
| // Do not schedule a primary segment if interstitials are abutting by less than ABUTTING_THRESHOLD_SECONDS | |
| if (segmentDuration > ABUTTING_THRESHOLD_SECONDS) { | |
| // primary segment | |
| const timelineStart = primaryPosition; | |
| const integratedStart = integratedTime; | |
| integratedTime += segmentDuration; | |
| const playoutStart = playoutDuration; | |
| playoutDuration += segmentDuration; | |
| const primarySegment = { | |
| previousEvent: interstitialEvents[i - 1] || null, | |
| nextEvent: interstitial, | |
| start: timelineStart, | |
| end: timelineStart + segmentDuration, | |
| playout: { | |
| start: playoutStart, | |
| end: playoutDuration, | |
| }, | |
| integrated: { | |
| start: integratedStart, | |
| end: integratedTime, | |
| }, | |
| }; | |
| schedule.push(primarySegment); | |
| } else if (segmentDuration > 0 && previousEvent) { | |
| // Add previous event `resumeTime` (based on duration or resumeOffset) so that it ends aligned with this one | |
| previousEvent.cumulativeDuration += segmentDuration; | |
| schedule[schedule.length - 1].end = eventStart; | |
| } | |
| } | |
| // midroll / postroll | |
| if (postroll) { | |
| end = start; | |
| } | |
| interstitial.timelineStart = start; | |
| const integratedStart = integratedTime; | |
| integratedTime += timelineDuration; | |
| const playoutStart = playoutDuration; | |
| playoutDuration += interstitialDuration; | |
| schedule.push({ | |
| event: interstitial, | |
| start, | |
| end, | |
| playout: { | |
| start: playoutStart, | |
| end: playoutDuration, | |
| }, | |
| integrated: { | |
| start: integratedStart, | |
| end: integratedTime, | |
| }, | |
| }); | |
| } else { | |
| // Interstitial starts after end of primary VOD - not included in schedule | |
| return; | |
| } | |
| const resumeTime = interstitial.resumeTime; | |
| if (postroll || resumeTime > primaryDuration) { | |
| primaryPosition = primaryDuration; | |
| } else { | |
| primaryPosition = resumeTime; | |
| } | |
| }); | |
| if (primaryPosition < primaryDuration) { | |
| // last primary segment | |
| const timelineStart = primaryPosition; | |
| const integratedStart = integratedTime; | |
| const segmentDuration = primaryDuration - primaryPosition; | |
| integratedTime += segmentDuration; | |
| const playoutStart = playoutDuration; | |
| playoutDuration += segmentDuration; | |
| schedule.push({ | |
| previousEvent: schedule[schedule.length - 1]?.event || null, | |
| nextEvent: null, | |
| start: primaryPosition, | |
| end: timelineStart + segmentDuration, | |
| playout: { | |
| start: playoutStart, | |
| end: playoutDuration, | |
| }, | |
| integrated: { | |
| start: integratedStart, | |
| end: integratedTime, | |
| }, | |
| }); | |
| } | |
| this.setDurations(primaryDuration, playoutDuration, integratedTime); | |
| } else { | |
| // no interstials - schedule is one primary segment | |
| const start = 0; | |
| schedule.push({ | |
| previousEvent: null, | |
| nextEvent: null, | |
| start, | |
| end: primaryDuration, | |
| playout: { | |
| start, | |
| end: primaryDuration, | |
| }, | |
| integrated: { | |
| start, | |
| end: primaryDuration, | |
| }, | |
| }); | |
| this.setDurations(primaryDuration, primaryDuration, primaryDuration); | |
| } | |
| return schedule; | |
| } | |
| private setDurations(primary: number, playout: number, integrated: number) { | |
| this.durations = { | |
| primary, | |
| playout, | |
| integrated, | |
| }; | |
| } | |
| private resolveOffsets( | |
| interstitialEvents: InterstitialEvent[], | |
| mediaSelection: MediaSelection, | |
| ) { | |
| const details = mediaSelection.main.details!; | |
| const primaryDuration = details.live ? Infinity : details.edge; | |
| // First resolve cumulative resumption offsets for Interstitials that start at the same DateTime | |
| let cumulativeDuration = 0; | |
| let lastScheduledStart = -1; | |
| interstitialEvents.forEach((interstitial, i) => { | |
| const preroll = interstitial.cue.pre; | |
| const postroll = interstitial.cue.post; | |
| const eventStart = preroll | |
| ? 0 | |
| : postroll | |
| ? primaryDuration | |
| : interstitial.startTime; | |
| this.updateAssetDurations(interstitial); | |
| // X-RESUME-OFFSET values of interstitials scheduled at the same time are cumulative | |
| const inSameStartTimeSequence = lastScheduledStart === eventStart; | |
| if (inSameStartTimeSequence) { | |
| interstitial.cumulativeDuration = cumulativeDuration; | |
| } else { | |
| cumulativeDuration = 0; | |
| lastScheduledStart = eventStart; | |
| } | |
| if (!postroll && interstitial.snapOptions.in) { | |
| // FIXME: Include audio playlist in snapping | |
| interstitial.resumeAnchor = | |
| findFragmentByPTS( | |
| null, | |
| details.fragments, | |
| interstitial.startOffset + interstitial.resumptionOffset, | |
| 0, | |
| 0, | |
| ) || undefined; | |
| } | |
| // Check if primary fragments align with resumption offset and disable appendInPlace if they do not | |
| if (interstitial.appendInPlace && !interstitial.appendInPlaceStarted) { | |
| const alignedSegmentStart = this.primaryCanResumeInPlaceAt( | |
| interstitial, | |
| mediaSelection, | |
| ); | |
| if (!alignedSegmentStart) { | |
| interstitial.appendInPlace = false; | |
| } | |
| } | |
| if (!interstitial.appendInPlace && i + 1 < interstitialEvents.length) { | |
| // abutting Interstitials must use the same MediaSource strategy, this applies to all whether or not they are back to back: | |
| const timeBetween = | |
| interstitialEvents[i + 1].startTime - | |
| interstitialEvents[i].resumeTime; | |
| if (timeBetween < ABUTTING_THRESHOLD_SECONDS) { | |
| interstitialEvents[i + 1].appendInPlace = false; | |
| if (interstitialEvents[i + 1].appendInPlace) { | |
| this.warn( | |
| `Could not change append strategy for abutting event ${interstitial}`, | |
| ); | |
| } | |
| } | |
| } | |
| // Update cumulativeDuration for next abutting interstitial with the same start date | |
| const resumeOffset = Number.isFinite(interstitial.resumeOffset) | |
| ? interstitial.resumeOffset | |
| : interstitial.duration; | |
| cumulativeDuration += resumeOffset; | |
| }); | |
| } | |
| private primaryCanResumeInPlaceAt( | |
| interstitial: InterstitialEvent, | |
| mediaSelection: MediaSelection, | |
| ): boolean { | |
| const resumeTime = interstitial.resumeTime; | |
| const resumesInPlaceAt = | |
| interstitial.startTime + interstitial.resumptionOffset; | |
| if ( | |
| Math.abs(resumeTime - resumesInPlaceAt) > ALIGNED_END_THRESHOLD_SECONDS | |
| ) { | |
| this.log( | |
| `"${interstitial.identifier}" resumption ${resumeTime} not aligned with estimated timeline end ${resumesInPlaceAt}`, | |
| ); | |
| return false; | |
| } | |
| const playlists = Object.keys(mediaSelection); | |
| return !playlists.some((playlistType) => { | |
| const details = mediaSelection[playlistType].details; | |
| const playlistEnd = details.edge; | |
| if (resumeTime >= playlistEnd) { | |
| // Live playback - resumption segments are not yet available | |
| this.log( | |
| `"${interstitial.identifier}" resumption ${resumeTime} past ${playlistType} playlist end ${playlistEnd}`, | |
| ); | |
| // Assume alignment is possible (or reset can take place) | |
| return false; | |
| } | |
| const startFragment = findFragmentByPTS( | |
| null, | |
| details.fragments, | |
| resumeTime, | |
| ); | |
| if (!startFragment) { | |
| this.log( | |
| `"${interstitial.identifier}" resumption ${resumeTime} does not align with any fragments in ${playlistType} playlist (${details.fragStart}-${details.fragmentEnd})`, | |
| ); | |
| return true; | |
| } | |
| const allowance = playlistType === 'audio' ? 0.175 : 0; | |
| const alignedWithSegment = | |
| Math.abs(startFragment.start - resumeTime) < | |
| ALIGNED_END_THRESHOLD_SECONDS + allowance || | |
| Math.abs(startFragment.end - resumeTime) < | |
| ALIGNED_END_THRESHOLD_SECONDS + allowance; | |
| if (!alignedWithSegment) { | |
| this.log( | |
| `"${interstitial.identifier}" resumption ${resumeTime} not aligned with ${playlistType} fragment bounds (${startFragment.start}-${startFragment.end} sn: ${startFragment.sn} cc: ${startFragment.cc})`, | |
| ); | |
| return true; | |
| } | |
| return false; | |
| }); | |
| } | |
| private updateAssetDurations(interstitial: InterstitialEvent) { | |
| if (!interstitial.assetListLoaded) { | |
| return; | |
| } | |
| const eventStart = interstitial.timelineStart; | |
| let sumDuration = 0; | |
| let hasUnknownDuration = false; | |
| let hasErrors = false; | |
| for (let i = 0; i < interstitial.assetList.length; i++) { | |
| const asset = interstitial.assetList[i]; | |
| const timelineStart = eventStart + sumDuration; | |
| asset.startOffset = sumDuration; | |
| asset.timelineStart = timelineStart; | |
| hasUnknownDuration ||= asset.duration === null; | |
| hasErrors ||= !!asset.error; | |
| const duration = asset.error ? 0 : (asset.duration as number) || 0; | |
| sumDuration += duration; | |
| } | |
| // Use the sum of known durations when it is greater than the stated duration | |
| if (hasUnknownDuration && !hasErrors) { | |
| interstitial.duration = Math.max(sumDuration, interstitial.duration); | |
| } else { | |
| interstitial.duration = sumDuration; | |
| } | |
| } | |
| private removeEvent(interstitial: InterstitialEvent) { | |
| interstitial.reset(); | |
| delete this.eventMap[interstitial.identifier]; | |
| } | |
| } | |
| export function segmentToString(segment: InterstitialScheduleItem): string { | |
| return `[${segment.event ? '"' + segment.event.identifier + '"' : 'primary'}: ${segment.start.toFixed(2)}-${segment.end.toFixed(2)}]`; | |
| } | |
Xet Storage Details
- Size:
- 23 kB
- Xet hash:
- bc91bbd6676602ccfa59938738688dfb351eb5f4a9c710c1b620596ac8499eee
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.