download
raw
23 kB
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.