/** * @file sync-controller.js */ 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var _createClass = (function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ('value' in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; })(); var _get = function get(_x2, _x3, _x4) { var _again = true; _function: while (_again) { var object = _x2, property = _x3, receiver = _x4; _again = false; if (object === null) object = Function.prototype; var desc = Object.getOwnPropertyDescriptor(object, property); if (desc === undefined) { var parent = Object.getPrototypeOf(object); if (parent === null) { return undefined; } else { _x2 = parent; _x3 = property; _x4 = receiver; _again = true; desc = parent = undefined; continue _function; } } else if ('value' in desc) { return desc.value; } else { var getter = desc.get; if (getter === undefined) { return undefined; } return getter.call(receiver); } } }; function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } } function _inherits(subClass, superClass) { if (typeof superClass !== 'function' && superClass !== null) { throw new TypeError('Super expression must either be null or a function, not ' + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } var _muxJsLibMp4Probe = require('mux.js/lib/mp4/probe'); var _muxJsLibMp4Probe2 = _interopRequireDefault(_muxJsLibMp4Probe); var _muxJsLibToolsTsInspectorJs = require('mux.js/lib/tools/ts-inspector.js'); var _playlist = require('./playlist'); var _videoJs = require('video.js'); var _videoJs2 = _interopRequireDefault(_videoJs); var syncPointStrategies = [ // Stategy "VOD": Handle the VOD-case where the sync-point is *always* // the equivalence display-time 0 === segment-index 0 { name: 'VOD', run: function run(syncController, playlist, duration, currentTimeline, currentTime) { if (duration !== Infinity) { var syncPoint = { time: 0, segmentIndex: 0 }; return syncPoint; } return null; } }, // Stategy "ProgramDateTime": We have a program-date-time tag in this playlist { name: 'ProgramDateTime', run: function run(syncController, playlist, duration, currentTimeline, currentTime) { if (syncController.datetimeToDisplayTime && playlist.dateTimeObject) { var playlistTime = playlist.dateTimeObject.getTime() / 1000; var playlistStart = playlistTime + syncController.datetimeToDisplayTime; var syncPoint = { time: playlistStart, segmentIndex: 0 }; return syncPoint; } return null; } }, // Stategy "Segment": We have a known time mapping for a timeline and a // segment in the current timeline with timing data { name: 'Segment', run: function run(syncController, playlist, duration, currentTimeline, currentTime) { var segments = playlist.segments || []; var syncPoint = null; var lastDistance = null; currentTime = currentTime || 0; for (var i = 0; i < segments.length; i++) { var segment = segments[i]; if (segment.timeline === currentTimeline && typeof segment.start !== 'undefined') { var distance = Math.abs(currentTime - segment.start); // Once the distance begins to increase, we have passed // currentTime and can stop looking for better candidates if (lastDistance !== null && lastDistance < distance) { break; } if (!syncPoint || lastDistance === null || lastDistance >= distance) { lastDistance = distance; syncPoint = { time: segment.start, segmentIndex: i }; } } } return syncPoint; } }, // Stategy "Discontinuity": We have a discontinuity with a known // display-time { name: 'Discontinuity', run: function run(syncController, playlist, duration, currentTimeline, currentTime) { var syncPoint = null; currentTime = currentTime || 0; if (playlist.discontinuityStarts && playlist.discontinuityStarts.length) { var lastDistance = null; for (var i = 0; i < playlist.discontinuityStarts.length; i++) { var segmentIndex = playlist.discontinuityStarts[i]; var discontinuity = playlist.discontinuitySequence + i + 1; var discontinuitySync = syncController.discontinuities[discontinuity]; if (discontinuitySync) { var distance = Math.abs(currentTime - discontinuitySync.time); // Once the distance begins to increase, we have passed // currentTime and can stop looking for better candidates if (lastDistance !== null && lastDistance < distance) { break; } if (!syncPoint || lastDistance === null || lastDistance >= distance) { lastDistance = distance; syncPoint = { time: discontinuitySync.time, segmentIndex: segmentIndex }; } } } } return syncPoint; } }, // Stategy "Playlist": We have a playlist with a known mapping of // segment index to display time { name: 'Playlist', run: function run(syncController, playlist, duration, currentTimeline, currentTime) { if (playlist.syncInfo) { var syncPoint = { time: playlist.syncInfo.time, segmentIndex: playlist.syncInfo.mediaSequence - playlist.mediaSequence }; return syncPoint; } return null; } }]; exports.syncPointStrategies = syncPointStrategies; var SyncController = (function (_videojs$EventTarget) { _inherits(SyncController, _videojs$EventTarget); function SyncController() { var options = arguments.length <= 0 || arguments[0] === undefined ? {} : arguments[0]; _classCallCheck(this, SyncController); _get(Object.getPrototypeOf(SyncController.prototype), 'constructor', this).call(this); // Segment Loader state variables... // ...for synching across variants this.inspectCache_ = undefined; // ...for synching across variants this.timelines = []; this.discontinuities = []; this.datetimeToDisplayTime = null; if (options.debug) { this.logger_ = _videoJs2['default'].log.bind(_videoJs2['default'], 'sync-controller ->'); } } /** * Find a sync-point for the playlist specified * * A sync-point is defined as a known mapping from display-time to * a segment-index in the current playlist. * * @param {Playlist} playlist * The playlist that needs a sync-point * @param {Number} duration * Duration of the MediaSource (Infinite if playing a live source) * @param {Number} currentTimeline * The last timeline from which a segment was loaded * @returns {Object} * A sync-point object */ _createClass(SyncController, [{ key: 'getSyncPoint', value: function getSyncPoint(playlist, duration, currentTimeline, currentTime) { var syncPoints = this.runStrategies_(playlist, duration, currentTimeline, currentTime); if (!syncPoints.length) { // Signal that we need to attempt to get a sync-point manually // by fetching a segment in the playlist and constructing // a sync-point from that information return null; } // Now find the sync-point that is closest to the currentTime because // that should result in the most accurate guess about which segment // to fetch return this.selectSyncPoint_(syncPoints, { key: 'time', value: currentTime }); } /** * Calculate the amount of time that has expired off the playlist during playback * * @param {Playlist} playlist * Playlist object to calculate expired from * @param {Number} duration * Duration of the MediaSource (Infinity if playling a live source) * @returns {Number|null} * The amount of time that has expired off the playlist during playback. Null * if no sync-points for the playlist can be found. */ }, { key: 'getExpiredTime', value: function getExpiredTime(playlist, duration) { if (!playlist || !playlist.segments) { return null; } var syncPoints = this.runStrategies_(playlist, duration, playlist.discontinuitySequence, 0); // Without sync-points, there is not enough information to determine the expired time if (!syncPoints.length) { return null; } var syncPoint = this.selectSyncPoint_(syncPoints, { key: 'segmentIndex', value: 0 }); // If the sync-point is beyond the start of the playlist, we want to subtract the // duration from index 0 to syncPoint.segmentIndex instead of adding. if (syncPoint.segmentIndex > 0) { syncPoint.time *= -1; } return Math.abs(syncPoint.time + (0, _playlist.sumDurations)(playlist, syncPoint.segmentIndex, 0)); } /** * Runs each sync-point strategy and returns a list of sync-points returned by the * strategies * * @private * @param {Playlist} playlist * The playlist that needs a sync-point * @param {Number} duration * Duration of the MediaSource (Infinity if playing a live source) * @param {Number} currentTimeline * The last timeline from which a segment was loaded * @returns {Array} * A list of sync-point objects */ }, { key: 'runStrategies_', value: function runStrategies_(playlist, duration, currentTimeline, currentTime) { var syncPoints = []; // Try to find a sync-point in by utilizing various strategies... for (var i = 0; i < syncPointStrategies.length; i++) { var strategy = syncPointStrategies[i]; var syncPoint = strategy.run(this, playlist, duration, currentTimeline, currentTime); if (syncPoint) { syncPoint.strategy = strategy.name; syncPoints.push({ strategy: strategy.name, syncPoint: syncPoint }); this.logger_('syncPoint found via <' + strategy.name + '>:', syncPoint); } } return syncPoints; } /** * Selects the sync-point nearest the specified target * * @private * @param {Array} syncPoints * List of sync-points to select from * @param {Object} target * Object specifying the property and value we are targeting * @param {String} target.key * Specifies the property to target. Must be either 'time' or 'segmentIndex' * @param {Number} target.value * The value to target for the specified key. * @returns {Object} * The sync-point nearest the target */ }, { key: 'selectSyncPoint_', value: function selectSyncPoint_(syncPoints, target) { var bestSyncPoint = syncPoints[0].syncPoint; var bestDistance = Math.abs(syncPoints[0].syncPoint[target.key] - target.value); var bestStrategy = syncPoints[0].strategy; for (var i = 1; i < syncPoints.length; i++) { var newDistance = Math.abs(syncPoints[i].syncPoint[target.key] - target.value); if (newDistance < bestDistance) { bestDistance = newDistance; bestSyncPoint = syncPoints[i].syncPoint; bestStrategy = syncPoints[i].strategy; } } this.logger_('syncPoint with strategy <' + bestStrategy + '> chosen: ', bestSyncPoint); return bestSyncPoint; } /** * Save any meta-data present on the segments when segments leave * the live window to the playlist to allow for synchronization at the * playlist level later. * * @param {Playlist} oldPlaylist - The previous active playlist * @param {Playlist} newPlaylist - The updated and most current playlist */ }, { key: 'saveExpiredSegmentInfo', value: function saveExpiredSegmentInfo(oldPlaylist, newPlaylist) { var mediaSequenceDiff = newPlaylist.mediaSequence - oldPlaylist.mediaSequence; // When a segment expires from the playlist and it has a start time // save that information as a possible sync-point reference in future for (var i = mediaSequenceDiff - 1; i >= 0; i--) { var lastRemovedSegment = oldPlaylist.segments[i]; if (lastRemovedSegment && typeof lastRemovedSegment.start !== 'undefined') { newPlaylist.syncInfo = { mediaSequence: oldPlaylist.mediaSequence + i, time: lastRemovedSegment.start }; this.logger_('playlist sync:', newPlaylist.syncInfo); this.trigger('syncinfoupdate'); break; } } } /** * Save the mapping from playlist's ProgramDateTime to display. This should * only ever happen once at the start of playback. * * @param {Playlist} playlist - The currently active playlist */ }, { key: 'setDateTimeMapping', value: function setDateTimeMapping(playlist) { if (!this.datetimeToDisplayTime && playlist.dateTimeObject) { var playlistTimestamp = playlist.dateTimeObject.getTime() / 1000; this.datetimeToDisplayTime = -playlistTimestamp; } } /** * Reset the state of the inspection cache when we do a rendition * switch */ }, { key: 'reset', value: function reset() { this.inspectCache_ = undefined; } /** * Probe or inspect a fmp4 or an mpeg2-ts segment to determine the start * and end of the segment in it's internal "media time". Used to generate * mappings from that internal "media time" to the display time that is * shown on the player. * * @param {SegmentInfo} segmentInfo - The current active request information */ }, { key: 'probeSegmentInfo', value: function probeSegmentInfo(segmentInfo) { var segment = segmentInfo.segment; var playlist = segmentInfo.playlist; var timingInfo = undefined; if (segment.map) { timingInfo = this.probeMp4Segment_(segmentInfo); } else { timingInfo = this.probeTsSegment_(segmentInfo); } if (timingInfo) { if (this.calculateSegmentTimeMapping_(segmentInfo, timingInfo)) { this.saveDiscontinuitySyncInfo_(segmentInfo); // If the playlist does not have sync information yet, record that information // now with segment timing information if (!playlist.syncInfo) { playlist.syncInfo = { mediaSequence: playlist.mediaSequence + segmentInfo.mediaIndex, time: segment.start }; } } } return timingInfo; } /** * Probe an fmp4 or an mpeg2-ts segment to determine the start of the segment * in it's internal "media time". * * @private * @param {SegmentInfo} segmentInfo - The current active request information * @return {object} The start and end time of the current segment in "media time" */ }, { key: 'probeMp4Segment_', value: function probeMp4Segment_(segmentInfo) { var segment = segmentInfo.segment; var timescales = _muxJsLibMp4Probe2['default'].timescale(segment.map.bytes); var startTime = _muxJsLibMp4Probe2['default'].startTime(timescales, segmentInfo.bytes); if (segmentInfo.timestampOffset !== null) { segmentInfo.timestampOffset -= startTime; } return { start: startTime, end: startTime + segment.duration }; } /** * Probe an mpeg2-ts segment to determine the start and end of the segment * in it's internal "media time". * * @private * @param {SegmentInfo} segmentInfo - The current active request information * @return {object} The start and end time of the current segment in "media time" */ }, { key: 'probeTsSegment_', value: function probeTsSegment_(segmentInfo) { var timeInfo = (0, _muxJsLibToolsTsInspectorJs.inspect)(segmentInfo.bytes, this.inspectCache_); var segmentStartTime = undefined; var segmentEndTime = undefined; if (!timeInfo) { return null; } if (timeInfo.video && timeInfo.video.length === 2) { this.inspectCache_ = timeInfo.video[1].dts; segmentStartTime = timeInfo.video[0].dtsTime; segmentEndTime = timeInfo.video[1].dtsTime; } else if (timeInfo.audio && timeInfo.audio.length === 2) { this.inspectCache_ = timeInfo.audio[1].dts; segmentStartTime = timeInfo.audio[0].dtsTime; segmentEndTime = timeInfo.audio[1].dtsTime; } return { start: segmentStartTime, end: segmentEndTime, containsVideo: timeInfo.video && timeInfo.video.length === 2, containsAudio: timeInfo.audio && timeInfo.audio.length === 2 }; } }, { key: 'timestampOffsetForTimeline', value: function timestampOffsetForTimeline(timeline) { if (typeof this.timelines[timeline] === 'undefined') { return null; } return this.timelines[timeline].time; } }, { key: 'mappingForTimeline', value: function mappingForTimeline(timeline) { if (typeof this.timelines[timeline] === 'undefined') { return null; } return this.timelines[timeline].mapping; } /** * Use the "media time" for a segment to generate a mapping to "display time" and * save that display time to the segment. * * @private * @param {SegmentInfo} segmentInfo * The current active request information * @param {object} timingInfo * The start and end time of the current segment in "media time" * @returns {Boolean} * Returns false if segment time mapping could not be calculated */ }, { key: 'calculateSegmentTimeMapping_', value: function calculateSegmentTimeMapping_(segmentInfo, timingInfo) { var segment = segmentInfo.segment; var mappingObj = this.timelines[segmentInfo.timeline]; if (segmentInfo.timestampOffset !== null) { this.logger_('tsO:', segmentInfo.timestampOffset); mappingObj = { time: segmentInfo.startOfSegment, mapping: segmentInfo.startOfSegment - timingInfo.start }; this.timelines[segmentInfo.timeline] = mappingObj; this.trigger('timestampoffset'); segment.start = segmentInfo.startOfSegment; segment.end = timingInfo.end + mappingObj.mapping; } else if (mappingObj) { segment.start = timingInfo.start + mappingObj.mapping; segment.end = timingInfo.end + mappingObj.mapping; } else { return false; } return true; } /** * Each time we have discontinuity in the playlist, attempt to calculate the location * in display of the start of the discontinuity and save that. We also save an accuracy * value so that we save values with the most accuracy (closest to 0.) * * @private * @param {SegmentInfo} segmentInfo - The current active request information */ }, { key: 'saveDiscontinuitySyncInfo_', value: function saveDiscontinuitySyncInfo_(segmentInfo) { var playlist = segmentInfo.playlist; var segment = segmentInfo.segment; // If the current segment is a discontinuity then we know exactly where // the start of the range and it's accuracy is 0 (greater accuracy values // mean more approximation) if (segment.discontinuity) { this.discontinuities[segment.timeline] = { time: segment.start, accuracy: 0 }; } else if (playlist.discontinuityStarts.length) { // Search for future discontinuities that we can provide better timing // information for and save that information for sync purposes for (var i = 0; i < playlist.discontinuityStarts.length; i++) { var segmentIndex = playlist.discontinuityStarts[i]; var discontinuity = playlist.discontinuitySequence + i + 1; var mediaIndexDiff = segmentIndex - segmentInfo.mediaIndex; var accuracy = Math.abs(mediaIndexDiff); if (!this.discontinuities[discontinuity] || this.discontinuities[discontinuity].accuracy > accuracy) { var time = undefined; if (mediaIndexDiff < 0) { time = segment.start - (0, _playlist.sumDurations)(playlist, segmentInfo.mediaIndex, segmentIndex); } else { time = segment.end + (0, _playlist.sumDurations)(playlist, segmentInfo.mediaIndex + 1, segmentIndex); } this.discontinuities[discontinuity] = { time: time, accuracy: accuracy }; } } } } /** * A debugging logger noop that is set to console.log only if debugging * is enabled globally * * @private */ }, { key: 'logger_', value: function logger_() {} }]); return SyncController; })(_videoJs2['default'].EventTarget); exports['default'] = SyncController;