UNPKG

18.4 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 const segment = segmentInfo.segment;
362 const playlist = segmentInfo.playlist;
363 let timingInfo;
364
365 if (segment.map) {
366 timingInfo = this.probeMp4Segment_(segmentInfo);
367 } else {
368 timingInfo = this.probeTsSegment_(segmentInfo);
369 }
370
371 if (timingInfo) {
372 if (this.calculateSegmentTimeMapping_(segmentInfo, timingInfo)) {
373 this.saveDiscontinuitySyncInfo_(segmentInfo);
374
375 // If the playlist does not have sync information yet, record that information
376 // now with segment timing information
377 if (!playlist.syncInfo) {
378 playlist.syncInfo = {
379 mediaSequence: playlist.mediaSequence + segmentInfo.mediaIndex,
380 time: segment.start
381 };
382 }
383 }
384 }
385
386 return timingInfo;
387 }
388
389 /**
390 * Probe an fmp4 or an mpeg2-ts segment to determine the start of the segment
391 * in it's internal "media time".
392 *
393 * @private
394 * @param {SegmentInfo} segmentInfo - The current active request information
395 * @return {object} The start and end time of the current segment in "media time"
396 */
397 probeMp4Segment_(segmentInfo) {
398 let segment = segmentInfo.segment;
399 let timescales = mp4probe.timescale(segment.map.bytes);
400 let startTime = mp4probe.startTime(timescales, segmentInfo.bytes);
401
402 if (segmentInfo.timestampOffset !== null) {
403 segmentInfo.timestampOffset -= startTime;
404 }
405
406 return {
407 start: startTime,
408 end: startTime + segment.duration
409 };
410 }
411
412 /**
413 * Probe an mpeg2-ts segment to determine the start and end of the segment
414 * in it's internal "media time".
415 *
416 * @private
417 * @param {SegmentInfo} segmentInfo - The current active request information
418 * @return {object} The start and end time of the current segment in "media time"
419 */
420 probeTsSegment_(segmentInfo) {
421 let timeInfo = tsprobe(segmentInfo.bytes, this.inspectCache_);
422 let segmentStartTime;
423 let segmentEndTime;
424
425 if (!timeInfo) {
426 return null;
427 }
428
429 if (timeInfo.video && timeInfo.video.length === 2) {
430 this.inspectCache_ = timeInfo.video[1].dts;
431 segmentStartTime = timeInfo.video[0].dtsTime;
432 segmentEndTime = timeInfo.video[1].dtsTime;
433 } else if (timeInfo.audio && timeInfo.audio.length === 2) {
434 this.inspectCache_ = timeInfo.audio[1].dts;
435 segmentStartTime = timeInfo.audio[0].dtsTime;
436 segmentEndTime = timeInfo.audio[1].dtsTime;
437 }
438
439 return {
440 start: segmentStartTime,
441 end: segmentEndTime,
442 containsVideo: timeInfo.video && timeInfo.video.length === 2,
443 containsAudio: timeInfo.audio && timeInfo.audio.length === 2
444 };
445 }
446
447 timestampOffsetForTimeline(timeline) {
448 if (typeof this.timelines[timeline] === 'undefined') {
449 return null;
450 }
451 return this.timelines[timeline].time;
452 }
453
454 mappingForTimeline(timeline) {
455 if (typeof this.timelines[timeline] === 'undefined') {
456 return null;
457 }
458 return this.timelines[timeline].mapping;
459 }
460
461 /**
462 * Use the "media time" for a segment to generate a mapping to "display time" and
463 * save that display time to the segment.
464 *
465 * @private
466 * @param {SegmentInfo} segmentInfo
467 * The current active request information
468 * @param {object} timingInfo
469 * The start and end time of the current segment in "media time"
470 * @returns {Boolean}
471 * Returns false if segment time mapping could not be calculated
472 */
473 calculateSegmentTimeMapping_(segmentInfo, timingInfo) {
474 let segment = segmentInfo.segment;
475 let mappingObj = this.timelines[segmentInfo.timeline];
476
477 if (segmentInfo.timestampOffset !== null) {
478 this.logger_('tsO:', segmentInfo.timestampOffset);
479
480 mappingObj = {
481 time: segmentInfo.startOfSegment,
482 mapping: segmentInfo.startOfSegment - timingInfo.start
483 };
484 this.timelines[segmentInfo.timeline] = mappingObj;
485 this.trigger('timestampoffset');
486
487 segment.start = segmentInfo.startOfSegment;
488 segment.end = timingInfo.end + mappingObj.mapping;
489 } else if (mappingObj) {
490 segment.start = timingInfo.start + mappingObj.mapping;
491 segment.end = timingInfo.end + mappingObj.mapping;
492 } else {
493 return false;
494 }
495
496 return true;
497 }
498
499 /**
500 * Each time we have discontinuity in the playlist, attempt to calculate the location
501 * in display of the start of the discontinuity and save that. We also save an accuracy
502 * value so that we save values with the most accuracy (closest to 0.)
503 *
504 * @private
505 * @param {SegmentInfo} segmentInfo - The current active request information
506 */
507 saveDiscontinuitySyncInfo_(segmentInfo) {
508 let playlist = segmentInfo.playlist;
509 let segment = segmentInfo.segment;
510
511 // If the current segment is a discontinuity then we know exactly where
512 // the start of the range and it's accuracy is 0 (greater accuracy values
513 // mean more approximation)
514 if (segment.discontinuity) {
515 this.discontinuities[segment.timeline] = {
516 time: segment.start,
517 accuracy: 0
518 };
519 } else if (playlist.discontinuityStarts.length) {
520 // Search for future discontinuities that we can provide better timing
521 // information for and save that information for sync purposes
522 for (let i = 0; i < playlist.discontinuityStarts.length; i++) {
523 let segmentIndex = playlist.discontinuityStarts[i];
524 let discontinuity = playlist.discontinuitySequence + i + 1;
525 let mediaIndexDiff = segmentIndex - segmentInfo.mediaIndex;
526 let accuracy = Math.abs(mediaIndexDiff);
527
528 if (!this.discontinuities[discontinuity] ||
529 this.discontinuities[discontinuity].accuracy > accuracy) {
530 let time;
531
532 if (mediaIndexDiff < 0) {
533 time = segment.start - sumDurations(playlist,
534 segmentInfo.mediaIndex,
535 segmentIndex);
536 } else {
537 time = segment.end + sumDurations(playlist,
538 segmentInfo.mediaIndex + 1,
539 segmentIndex);
540 }
541
542 this.discontinuities[discontinuity] = {
543 time,
544 accuracy
545 };
546 }
547 }
548 }
549 }
550
551 /**
552 * A debugging logger noop that is set to console.log only if debugging
553 * is enabled globally
554 *
555 * @private
556 */
557 logger_() {}
558}