UNPKG

17.7 kBJavaScriptView Raw
1/**
2 * @file sync-controller.js
3 */
4
5import mp4probe from 'mux.js/lib/mp4/probe';
6import {inspect as tsprobe} from 'mux.js/lib/tools/ts-inspector.js';
7import {sumDurations} from './playlist';
8import videojs from 'video.js';
9
10export const syncPointStrategies = [
11 // Stategy "VOD": Handle the VOD-case where the sync-point is *always*
12 // the equivalence display-time 0 === segment-index 0
13 {
14 name: 'VOD',
15 run: (syncController, playlist, duration, currentTimeline, currentTime) => {
16 if (duration !== Infinity) {
17 let syncPoint = {
18 time: 0,
19 segmentIndex: 0
20 };
21
22 return syncPoint;
23 }
24 return null;
25 }
26 },
27 // Stategy "ProgramDateTime": We have a program-date-time tag in this playlist
28 {
29 name: 'ProgramDateTime',
30 run: (syncController, playlist, duration, currentTimeline, currentTime) => {
31 if (syncController.datetimeToDisplayTime && playlist.dateTimeObject) {
32 let playlistTime = playlist.dateTimeObject.getTime() / 1000;
33 let playlistStart = playlistTime + syncController.datetimeToDisplayTime;
34 let syncPoint = {
35 time: playlistStart,
36 segmentIndex: 0
37 };
38
39 return syncPoint;
40 }
41 return null;
42 }
43 },
44 // Stategy "Segment": We have a known time mapping for a timeline and a
45 // segment in the current timeline with timing data
46 {
47 name: 'Segment',
48 run: (syncController, playlist, duration, currentTimeline, currentTime) => {
49 let segments = playlist.segments || [];
50 let syncPoint = null;
51 let lastDistance = null;
52
53 currentTime = currentTime || 0;
54
55 for (let i = 0; i < segments.length; i++) {
56 let segment = segments[i];
57
58 if (segment.timeline === currentTimeline &&
59 typeof segment.start !== 'undefined') {
60 let distance = Math.abs(currentTime - segment.start);
61
62 // Once the distance begins to increase, we have passed
63 // currentTime and can stop looking for better candidates
64 if (lastDistance !== null && lastDistance < distance) {
65 break;
66 }
67
68 if (!syncPoint || lastDistance === null || lastDistance >= distance) {
69 lastDistance = distance;
70 syncPoint = {
71 time: segment.start,
72 segmentIndex: i
73 };
74 }
75
76 }
77 }
78 return syncPoint;
79 }
80 },
81 // Stategy "Discontinuity": We have a discontinuity with a known
82 // display-time
83 {
84 name: 'Discontinuity',
85 run: (syncController, playlist, duration, currentTimeline, currentTime) => {
86 let syncPoint = null;
87
88 currentTime = currentTime || 0;
89
90 if (playlist.discontinuityStarts && playlist.discontinuityStarts.length) {
91 let lastDistance = null;
92
93 for (let i = 0; i < playlist.discontinuityStarts.length; i++) {
94 let segmentIndex = playlist.discontinuityStarts[i];
95 let discontinuity = playlist.discontinuitySequence + i + 1;
96 let discontinuitySync = syncController.discontinuities[discontinuity];
97
98 if (discontinuitySync) {
99 let distance = Math.abs(currentTime - discontinuitySync.time);
100
101 // Once the distance begins to increase, we have passed
102 // currentTime and can stop looking for better candidates
103 if (lastDistance !== null && lastDistance < distance) {
104 break;
105 }
106
107 if (!syncPoint || lastDistance === null || lastDistance >= distance) {
108 lastDistance = distance;
109 syncPoint = {
110 time: discontinuitySync.time,
111 segmentIndex
112 };
113 }
114 }
115 }
116 }
117 return syncPoint;
118 }
119 },
120 // Stategy "Playlist": We have a playlist with a known mapping of
121 // segment index to display time
122 {
123 name: 'Playlist',
124 run: (syncController, playlist, duration, currentTimeline, currentTime) => {
125 if (playlist.syncInfo) {
126 let syncPoint = {
127 time: playlist.syncInfo.time,
128 segmentIndex: playlist.syncInfo.mediaSequence - playlist.mediaSequence
129 };
130
131 return syncPoint;
132 }
133 return null;
134 }
135 }
136];
137
138export default class SyncController extends videojs.EventTarget {
139 constructor(options = {}) {
140 super();
141 // Segment Loader state variables...
142 // ...for synching across variants
143 this.inspectCache_ = undefined;
144
145 // ...for synching across variants
146 this.timelines = [];
147 this.discontinuities = [];
148 this.datetimeToDisplayTime = null;
149
150 if (options.debug) {
151 this.logger_ = videojs.log.bind(videojs, 'sync-controller ->');
152 }
153 }
154
155 /**
156 * Find a sync-point for the playlist specified
157 *
158 * A sync-point is defined as a known mapping from display-time to
159 * a segment-index in the current playlist.
160 *
161 * @param {Playlist} playlist
162 * The playlist that needs a sync-point
163 * @param {Number} duration
164 * Duration of the MediaSource (Infinite if playing a live source)
165 * @param {Number} currentTimeline
166 * The last timeline from which a segment was loaded
167 * @returns {Object}
168 * A sync-point object
169 */
170 getSyncPoint(playlist, duration, currentTimeline, currentTime) {
171 const syncPoints = this.runStrategies_(playlist,
172 duration,
173 currentTimeline,
174 currentTime);
175
176 if (!syncPoints.length) {
177 // Signal that we need to attempt to get a sync-point manually
178 // by fetching a segment in the playlist and constructing
179 // a sync-point from that information
180 return null;
181 }
182
183 // Now find the sync-point that is closest to the currentTime because
184 // that should result in the most accurate guess about which segment
185 // to fetch
186 return this.selectSyncPoint_(syncPoints, { key: 'time', value: currentTime });
187 }
188
189 /**
190 * Calculate the amount of time that has expired off the playlist during playback
191 *
192 * @param {Playlist} playlist
193 * Playlist object to calculate expired from
194 * @param {Number} duration
195 * Duration of the MediaSource (Infinity if playling a live source)
196 * @returns {Number|null}
197 * The amount of time that has expired off the playlist during playback. Null
198 * if no sync-points for the playlist can be found.
199 */
200 getExpiredTime(playlist, duration) {
201 if (!playlist || !playlist.segments) {
202 return null;
203 }
204
205 const syncPoints = this.runStrategies_(playlist,
206 duration,
207 playlist.discontinuitySequence,
208 0);
209
210 // Without sync-points, there is not enough information to determine the expired time
211 if (!syncPoints.length) {
212 return null;
213 }
214
215 const syncPoint = this.selectSyncPoint_(syncPoints, {
216 key: 'segmentIndex',
217 value: 0
218 });
219
220 // If the sync-point is beyond the start of the playlist, we want to subtract the
221 // duration from index 0 to syncPoint.segmentIndex instead of adding.
222 if (syncPoint.segmentIndex > 0) {
223 syncPoint.time *= -1;
224 }
225
226 return Math.abs(syncPoint.time + sumDurations(playlist, syncPoint.segmentIndex, 0));
227 }
228
229 /**
230 * Runs each sync-point strategy and returns a list of sync-points returned by the
231 * strategies
232 *
233 * @private
234 * @param {Playlist} playlist
235 * The playlist that needs a sync-point
236 * @param {Number} duration
237 * Duration of the MediaSource (Infinity if playing a live source)
238 * @param {Number} currentTimeline
239 * The last timeline from which a segment was loaded
240 * @returns {Array}
241 * A list of sync-point objects
242 */
243 runStrategies_(playlist, duration, currentTimeline, currentTime) {
244 let syncPoints = [];
245
246 // Try to find a sync-point in by utilizing various strategies...
247 for (let i = 0; i < syncPointStrategies.length; i++) {
248 let strategy = syncPointStrategies[i];
249 let syncPoint = strategy.run(this,
250 playlist,
251 duration,
252 currentTimeline,
253 currentTime);
254
255 if (syncPoint) {
256 syncPoint.strategy = strategy.name;
257 syncPoints.push({
258 strategy: strategy.name,
259 syncPoint
260 });
261 this.logger_(`syncPoint found via <${strategy.name}>:`, syncPoint);
262 }
263 }
264
265 return syncPoints;
266 }
267
268 /**
269 * Selects the sync-point nearest the specified target
270 *
271 * @private
272 * @param {Array} syncPoints
273 * List of sync-points to select from
274 * @param {Object} target
275 * Object specifying the property and value we are targeting
276 * @param {String} target.key
277 * Specifies the property to target. Must be either 'time' or 'segmentIndex'
278 * @param {Number} target.value
279 * The value to target for the specified key.
280 * @returns {Object}
281 * The sync-point nearest the target
282 */
283 selectSyncPoint_(syncPoints, target) {
284 let bestSyncPoint = syncPoints[0].syncPoint;
285 let bestDistance = Math.abs(syncPoints[0].syncPoint[target.key] - target.value);
286 let bestStrategy = syncPoints[0].strategy;
287
288 for (let i = 1; i < syncPoints.length; i++) {
289 let newDistance = Math.abs(syncPoints[i].syncPoint[target.key] - target.value);
290
291 if (newDistance < bestDistance) {
292 bestDistance = newDistance;
293 bestSyncPoint = syncPoints[i].syncPoint;
294 bestStrategy = syncPoints[i].strategy;
295 }
296 }
297
298 this.logger_(`syncPoint with strategy <${bestStrategy}> chosen: `, bestSyncPoint);
299 return bestSyncPoint;
300 }
301
302 /**
303 * Save any meta-data present on the segments when segments leave
304 * the live window to the playlist to allow for synchronization at the
305 * playlist level later.
306 *
307 * @param {Playlist} oldPlaylist - The previous active playlist
308 * @param {Playlist} newPlaylist - The updated and most current playlist
309 */
310 saveExpiredSegmentInfo(oldPlaylist, newPlaylist) {
311 let mediaSequenceDiff = newPlaylist.mediaSequence - oldPlaylist.mediaSequence;
312
313 // When a segment expires from the playlist and it has a start time
314 // save that information as a possible sync-point reference in future
315 for (let i = mediaSequenceDiff - 1; i >= 0; i--) {
316 let lastRemovedSegment = oldPlaylist.segments[i];
317
318 if (lastRemovedSegment && typeof lastRemovedSegment.start !== 'undefined') {
319 newPlaylist.syncInfo = {
320 mediaSequence: oldPlaylist.mediaSequence + i,
321 time: lastRemovedSegment.start
322 };
323 this.logger_('playlist sync:', newPlaylist.syncInfo);
324 this.trigger('syncinfoupdate');
325 break;
326 }
327 }
328 }
329
330 /**
331 * Save the mapping from playlist's ProgramDateTime to display. This should
332 * only ever happen once at the start of playback.
333 *
334 * @param {Playlist} playlist - The currently active playlist
335 */
336 setDateTimeMapping(playlist) {
337 if (!this.datetimeToDisplayTime && playlist.dateTimeObject) {
338 let playlistTimestamp = playlist.dateTimeObject.getTime() / 1000;
339
340 this.datetimeToDisplayTime = -playlistTimestamp;
341 }
342 }
343
344 /**
345 * Reset the state of the inspection cache when we do a rendition
346 * switch
347 */
348 reset() {
349 this.inspectCache_ = undefined;
350 }
351
352 /**
353 * Probe or inspect a fmp4 or an mpeg2-ts segment to determine the start
354 * and end of the segment in it's internal "media time". Used to generate
355 * mappings from that internal "media time" to the display time that is
356 * shown on the player.
357 *
358 * @param {SegmentInfo} segmentInfo - The current active request information
359 */
360 probeSegmentInfo(segmentInfo) {
361 let segment = segmentInfo.segment;
362 let timingInfo;
363
364 if (segment.map) {
365 timingInfo = this.probeMp4Segment_(segmentInfo);
366 } else {
367 timingInfo = this.probeTsSegment_(segmentInfo);
368 }
369
370 if (timingInfo) {
371 if (this.calculateSegmentTimeMapping_(segmentInfo, timingInfo)) {
372 this.saveDiscontinuitySyncInfo_(segmentInfo);
373 }
374 }
375 }
376
377 /**
378 * Probe an fmp4 or an mpeg2-ts segment to determine the start of the segment
379 * in it's internal "media time".
380 *
381 * @private
382 * @param {SegmentInfo} segmentInfo - The current active request information
383 * @return {object} The start and end time of the current segment in "media time"
384 */
385 probeMp4Segment_(segmentInfo) {
386 let segment = segmentInfo.segment;
387 let timescales = mp4probe.timescale(segment.map.bytes);
388 let startTime = mp4probe.startTime(timescales, segmentInfo.bytes);
389
390 if (segmentInfo.timestampOffset !== null) {
391 segmentInfo.timestampOffset -= startTime;
392 }
393
394 return {
395 start: startTime,
396 end: startTime + segment.duration
397 };
398 }
399
400 /**
401 * Probe an mpeg2-ts segment to determine the start and end of the segment
402 * in it's internal "media time".
403 *
404 * @private
405 * @param {SegmentInfo} segmentInfo - The current active request information
406 * @return {object} The start and end time of the current segment in "media time"
407 */
408 probeTsSegment_(segmentInfo) {
409 let timeInfo = tsprobe(segmentInfo.bytes, this.inspectCache_);
410 let segmentStartTime;
411 let segmentEndTime;
412
413 if (!timeInfo) {
414 return null;
415 }
416
417 if (timeInfo.video && timeInfo.video.length === 2) {
418 this.inspectCache_ = timeInfo.video[1].dts;
419 segmentStartTime = timeInfo.video[0].dtsTime;
420 segmentEndTime = timeInfo.video[1].dtsTime;
421 } else if (timeInfo.audio && timeInfo.audio.length === 2) {
422 this.inspectCache_ = timeInfo.audio[1].dts;
423 segmentStartTime = timeInfo.audio[0].dtsTime;
424 segmentEndTime = timeInfo.audio[1].dtsTime;
425 }
426
427 return {
428 start: segmentStartTime,
429 end: segmentEndTime
430 };
431 }
432
433 timestampOffsetForTimeline(timeline) {
434 if (typeof this.timelines[timeline] === 'undefined') {
435 return null;
436 }
437 return this.timelines[timeline].time;
438 }
439
440 /**
441 * Use the "media time" for a segment to generate a mapping to "display time" and
442 * save that display time to the segment.
443 *
444 * @private
445 * @param {SegmentInfo} segmentInfo
446 * The current active request information
447 * @param {object} timingInfo
448 * The start and end time of the current segment in "media time"
449 * @returns {Boolean}
450 * Returns false if segment time mapping could not be calculated
451 */
452 calculateSegmentTimeMapping_(segmentInfo, timingInfo) {
453 let segment = segmentInfo.segment;
454 let mappingObj = this.timelines[segmentInfo.timeline];
455
456 if (segmentInfo.timestampOffset !== null) {
457 this.logger_('tsO:', segmentInfo.timestampOffset);
458
459 mappingObj = {
460 time: segmentInfo.startOfSegment,
461 mapping: segmentInfo.startOfSegment - timingInfo.start
462 };
463 this.timelines[segmentInfo.timeline] = mappingObj;
464 this.trigger('timestampoffset');
465
466 segment.start = segmentInfo.startOfSegment;
467 segment.end = timingInfo.end + mappingObj.mapping;
468 } else if (mappingObj) {
469 segment.start = timingInfo.start + mappingObj.mapping;
470 segment.end = timingInfo.end + mappingObj.mapping;
471 } else {
472 return false;
473 }
474
475 return true;
476 }
477
478 /**
479 * Each time we have discontinuity in the playlist, attempt to calculate the location
480 * in display of the start of the discontinuity and save that. We also save an accuracy
481 * value so that we save values with the most accuracy (closest to 0.)
482 *
483 * @private
484 * @param {SegmentInfo} segmentInfo - The current active request information
485 */
486 saveDiscontinuitySyncInfo_(segmentInfo) {
487 let playlist = segmentInfo.playlist;
488 let segment = segmentInfo.segment;
489
490 // If the current segment is a discontinuity then we know exactly where
491 // the start of the range and it's accuracy is 0 (greater accuracy values
492 // mean more approximation)
493 if (segment.discontinuity) {
494 this.discontinuities[segment.timeline] = {
495 time: segment.start,
496 accuracy: 0
497 };
498 } else if (playlist.discontinuityStarts.length) {
499 // Search for future discontinuities that we can provide better timing
500 // information for and save that information for sync purposes
501 for (let i = 0; i < playlist.discontinuityStarts.length; i++) {
502 let segmentIndex = playlist.discontinuityStarts[i];
503 let discontinuity = playlist.discontinuitySequence + i + 1;
504 let mediaIndexDiff = segmentIndex - segmentInfo.mediaIndex;
505 let accuracy = Math.abs(mediaIndexDiff);
506
507 if (!this.discontinuities[discontinuity] ||
508 this.discontinuities[discontinuity].accuracy > accuracy) {
509 let time;
510
511 if (mediaIndexDiff < 0) {
512 time = segment.start - sumDurations(playlist,
513 segmentInfo.mediaIndex,
514 segmentIndex);
515 } else {
516 time = segment.end + sumDurations(playlist,
517 segmentInfo.mediaIndex + 1,
518 segmentIndex);
519 }
520
521 this.discontinuities[discontinuity] = {
522 time,
523 accuracy
524 };
525 }
526 }
527 }
528 }
529
530 /**
531 * A debugging logger noop that is set to console.log only if debugging
532 * is enabled globally
533 *
534 * @private
535 */
536 logger_() {}
537}