From 89409cee3eaeb6764dbc191b7408bf45eecdced3 Mon Sep 17 00:00:00 2001 From: theodab Date: Thu, 24 Mar 2022 15:58:53 -0700 Subject: [PATCH] feat(hls): Read EXT-X-PROGRAM-DATE-TIME (#4034) This makes the HLS parser read the EXT-X-PROGRAM-DATE-TIME value on manifests, and use it to make sure that segments are inserted at the correct place in the timeline, when in sequence mode. Issue #2337 --- demo/common/message_ids.js | 1 + demo/config.js | 2 + demo/locales/en.json | 1 + demo/locales/source.json | 4 + externs/shaka/player.js | 8 +- lib/hls/hls_parser.js | 145 ++++++++++++++++++++- lib/hls/manifest_text_parser.js | 5 +- lib/media/media_source_engine.js | 9 +- lib/media/segment_index.js | 14 ++- lib/media/segment_reference.js | 9 +- lib/media/streaming_engine.js | 5 +- lib/util/player_configuration.js | 1 + lib/util/xml_utils.js | 2 +- test/hls/hls_live_unit.js | 142 +++++++++++++-------- test/hls/hls_parser_unit.js | 167 +++++++++++++++++++++++++ test/test/util/manifest_parser_util.js | 5 +- 16 files changed, 453 insertions(+), 67 deletions(-) diff --git a/demo/common/message_ids.js b/demo/common/message_ids.js index 8b6484045a..159eddcca1 100644 --- a/demo/common/message_ids.js +++ b/demo/common/message_ids.js @@ -188,6 +188,7 @@ shakaDemo.MessageIds = { IGNORE_DASH_SUGGESTED_PRESENTATION_DELAY: 'DEMO_IGNORE_DASH_SUGGESTED_PRESENTATION_DELAY', IGNORE_HLS_IMAGE_FAILURES: 'DEMO_IGNORE_HLS_IMAGE_FAILURES', IGNORE_HLS_TEXT_FAILURES: 'DEMO_IGNORE_HLS_TEXT_FAILURES', + IGNORE_MANIFEST_PROGRAM_DATE_TIME: 'DEMO_IGNORE_MANIFEST_PROGRAM_DATE_TIME', IGNORE_MIN_BUFFER_TIME: 'DEMO_IGNORE_MIN_BUFFER_TIME', IGNORE_TEXT_FAILURES: 'DEMO_IGNORE_TEXT_FAILURES', INACCURATE_MANIFEST_TOLERANCE: 'DEMO_INACCURATE_MANIFEST_TOLERANCE', diff --git a/demo/config.js b/demo/config.js index 5c647f57dc..738e01719b 100644 --- a/demo/config.js +++ b/demo/config.js @@ -213,6 +213,8 @@ shakaDemo.Config = class { 'manifest.hls.defaultAudioCodec') .addTextInput_(MessageIds.DEFAULT_VIDEO_CODEC, 'manifest.hls.defaultVideoCodec') + .addBoolInput_(MessageIds.IGNORE_MANIFEST_PROGRAM_DATE_TIME, + 'manifest.hls.ignoreManifestProgramDateTime') .addNumberInput_(MessageIds.AVAILABILITY_WINDOW_OVERRIDE, 'manifest.availabilityWindowOverride', /* canBeDecimal= */ true, diff --git a/demo/locales/en.json b/demo/locales/en.json index d03dcb19c2..b69622cd85 100644 --- a/demo/locales/en.json +++ b/demo/locales/en.json @@ -99,6 +99,7 @@ "DEMO_IMA_ASSET_KEY": "Asset key (for LIVE DAI Content)", "DEMO_IMA_CONTENT_SRC_ID": "Content source ID (for VOD DAI Content)", "DEMO_IMA_VIDEO_ID": "Video ID (for VOD DAI Content)", + "DEMO_IGNORE_MANIFEST_PROGRAM_DATE_TIME": "Ignore Program Date Time from manifest", "DEMO_IGNORE_MIN_BUFFER_TIME": "Ignore Min Buffer Time", "DEMO_IGNORE_TEXT_FAILURES": "Ignore Text Stream Failures", "DEMO_INACCURATE_MANIFEST_TOLERANCE": "Inaccurate Manifest Tolerance", diff --git a/demo/locales/source.json b/demo/locales/source.json index 0cdc7468c5..aa2920a5fb 100644 --- a/demo/locales/source.json +++ b/demo/locales/source.json @@ -399,6 +399,10 @@ "description": "The label on a field that allows users to provide a video id for a custom asset.", "message": "Video ID (for VOD DAI Content)" }, + "DEMO_IGNORE_MANIFEST_PROGRAM_DATE_TIME": { + "description": "The name of a configuration value.", + "message": "Ignore Program Date Time from manifest" + }, "DEMO_IGNORE_MIN_BUFFER_TIME": { "description": "The name of a configuration value.", "message": "Ignore Min Buffer Time" diff --git a/externs/shaka/player.js b/externs/shaka/player.js index 444d7e89b0..87abd72a26 100644 --- a/externs/shaka/player.js +++ b/externs/shaka/player.js @@ -747,7 +747,8 @@ shaka.extern.DashManifestConfiguration; * ignoreTextStreamFailures: boolean, * ignoreImageStreamFailures: boolean, * defaultAudioCodec: string, - * defaultVideoCodec: string + * defaultVideoCodec: string, + * ignoreManifestProgramDateTime: boolean * }} * * @property {boolean} ignoreTextStreamFailures @@ -762,6 +763,11 @@ shaka.extern.DashManifestConfiguration; * @property {string} defaultVideoCodec * The default video codec if it is not specified in the HLS playlist. * Defaults to 'avc1.42E01E'. + * @property {boolean} ignoreManifestProgramDateTime + * If true, the HLS parser will ignore the + * EXT-X-PROGRAM-DATE-TIME tags in the manifest. + * Meant for tags that are incorrect or malformed. + * Defaults to false. * @exportDoc */ shaka.extern.HlsManifestConfiguration; diff --git a/lib/hls/hls_parser.js b/lib/hls/hls_parser.js index 05a2bcab06..5adf6a5976 100644 --- a/lib/hls/hls_parser.js +++ b/lib/hls/hls_parser.js @@ -38,6 +38,7 @@ goog.require('shaka.util.OperationManager'); goog.require('shaka.util.Pssh'); goog.require('shaka.util.Timer'); goog.require('shaka.util.Platform'); +goog.require('shaka.util.XmlUtils'); goog.requireType('shaka.hls.Segment'); @@ -120,6 +121,15 @@ shaka.hls.HlsParser = class { */ this.updatePlaylistDelay_ = 0; + /** + * A time offset to apply to EXT-X-PROGRAM-DATE-TIME values to normalize + * them so that they start at 0. This is necessary because these times will + * be used to set presentation times for segments. + * null means we don't have enough data yet. + * @private {?number} + */ + this.syncTimeOffset_ = null; + /** * This timer is used to trigger the start of a manifest update. A manifest * update is async. Once the update is finished, the timer will be restarted @@ -351,6 +361,42 @@ shaka.hls.HlsParser = class { // No-op } + /** + * If necessary, makes sure that sync times will be normalized to 0, so that + * a stream does not start buffering at 50 years in because sync times are + * measured in time since 1970. + * @private + */ + calculateSyncTimeOffset_() { + if (this.syncTimeOffset_ != null) { + // The offset was already calculated. + return; + } + + const segments = new Set(); + let lowestSyncTime = Infinity; + for (const streamInfo of this.uriToStreamInfosMap_.values()) { + const segmentIndex = streamInfo.stream.segmentIndex; + if (segmentIndex) { + segmentIndex.forEachTopLevelReference((segment) => { + if (segment.syncTime != null) { + lowestSyncTime = Math.min(lowestSyncTime, segment.syncTime); + segments.add(segment); + } + }); + } + } + if (segments.size > 0) { + this.syncTimeOffset_ = -lowestSyncTime; + for (const segment of segments) { + segment.syncTime += this.syncTimeOffset_; + for (const partial of segment.partialReferences) { + partial.syncTime += this.syncTimeOffset_; + } + } + } + } + /** * Parses the manifest. * @@ -433,6 +479,10 @@ shaka.hls.HlsParser = class { shaka.util.Error.Code.OPERATION_ABORTED); } + // Now that we have generated all streams, we can determine the offset to + // apply to sync times. + this.calculateSyncTimeOffset_(); + if (this.aesEncrypted_ && variants.length == 0) { // We do not support AES-128 encryption with HLS yet. Variants is null // when the playlist is encrypted with AES-128. @@ -1706,8 +1756,8 @@ shaka.hls.HlsParser = class { * @param {number} startTime * @param {!Map.} variables * @param {string} absoluteMediaPlaylistUri - * @return {!shaka.media.SegmentReference} * @param {string} type + * @return {!shaka.media.SegmentReference} * @private */ createSegmentReference_( @@ -1730,6 +1780,23 @@ shaka.hls.HlsParser = class { 'true, and see https://bit.ly/3clctcj for details.'); } + let syncTime = null; + if (!this.config_.hls.ignoreManifestProgramDateTime) { + const dateTimeTag = + shaka.hls.Utils.getFirstTagWithName(tags, 'EXT-X-PROGRAM-DATE-TIME'); + if (dateTimeTag && dateTimeTag.value) { + const time = shaka.util.XmlUtils.parseDate(dateTimeTag.value); + goog.asserts.assert(time != null, + 'EXT-X-PROGRAM-DATE-TIME format not valid'); + // Sync time offset is null on the first go-through. This indicates that + // we have not yet seen every stream, and thus do not yet have enough + // information to determine how to normalize the sync times. + // For that first go-through, the sync time will be applied after the + // references are all created. Until then, just offset by 0. + syncTime = time + (this.syncTimeOffset_ || 0); + } + } + // Create SegmentReferences for the partial segments. const partialSegmentRefs = []; if (this.lowLatencyMode_ && hlsSegment.partialSegments.length) { @@ -1839,6 +1906,7 @@ shaka.hls.HlsParser = class { partialSegmentRefs, tilesLayout, tileDuration, + syncTime, ); } @@ -1966,6 +2034,81 @@ shaka.hls.HlsParser = class { references.push(reference); } + // If some segments have sync times, but not all, extrapolate the sync + // times of the ones with none. + const someSyncTime = references.some((ref) => ref.syncTime != null); + if (someSyncTime) { + for (let i = 0; i < references.length; i++) { + const reference = references[i]; + if (reference.syncTime != null) { + // No need to extrapolate. + continue; + } + // Find the nearest segment with syncTime, in either direction. + // This looks forward and backward simultaneously, keeping track of what + // to offset the syncTime it finds by as it goes. + let forwardAdd = 0; + let forwardI = i; + /** + * Look forwards one reference at a time, summing all durations as we + * go, until we find a reference with a syncTime to use as a basis. + * This DOES count the original reference, but DOESN'T count the first + * reference with a syncTime (as we approach it from behind). + * @return {?number} + */ + const lookForward = () => { + const other = references[forwardI]; + if (other) { + if (other.syncTime != null) { + return other.syncTime + forwardAdd; + } + forwardAdd -= other.endTime - other.startTime; + forwardI += 1; + } + return null; + }; + let backwardAdd = 0; + let backwardI = i; + /** + * Look backwards one reference at a time, summing all durations as we + * go, until we find a reference with a syncTime to use as a basis. + * This DOESN'T count the original reference, but DOES count the first + * reference with a syncTime (as we approach it from ahead). + * @return {?number} + */ + const lookBackward = () => { + const other = references[backwardI]; + if (other) { + if (other != reference) { + backwardAdd += other.endTime - other.startTime; + } + if (other.syncTime != null) { + return other.syncTime + backwardAdd; + } + backwardI -= 1; + } + return null; + }; + while (reference.syncTime == null) { + reference.syncTime = lookBackward(); + if (reference.syncTime == null) { + reference.syncTime = lookForward(); + } + } + } + } + + // Split the sync times properly among partial segments. + if (someSyncTime) { + for (const reference of references) { + let syncTime = reference.syncTime; + for (const partial of reference.partialReferences) { + partial.syncTime = syncTime; + syncTime += partial.endTime - partial.startTime; + } + } + } + return references; } diff --git a/lib/hls/manifest_text_parser.js b/lib/hls/manifest_text_parser.js index 1e3f57c7fe..a0c9fbb654 100644 --- a/lib/hls/manifest_text_parser.js +++ b/lib/hls/manifest_text_parser.js @@ -179,9 +179,8 @@ shaka.hls.ManifestTextParser = class { segmentTags.push(currentMapTag); } // The URI appears after all of the tags describing the segment. - const segment = - new shaka.hls.Segment(absoluteSegmentUri, segmentTags, - partialSegmentTags); + const segment = new shaka.hls.Segment(absoluteSegmentUri, segmentTags, + partialSegmentTags); segments.push(segment); segmentTags = []; partialSegmentTags = []; diff --git a/lib/media/media_source_engine.js b/lib/media/media_source_engine.js index 688147565c..3f5fe6be23 100644 --- a/lib/media/media_source_engine.js +++ b/lib/media/media_source_engine.js @@ -506,7 +506,6 @@ shaka.media.MediaSourceEngine = class { * @param {?number} endTime relative to the start of the presentation * @param {?boolean} hasClosedCaptions True if the buffer contains CEA closed * captions - * @param {boolean=} seeked True if we just seeked * @param {boolean=} sequenceMode True if sequence mode * @return {!Promise} @@ -515,11 +514,11 @@ shaka.media.MediaSourceEngine = class { seeked, sequenceMode) { const ContentType = shaka.util.ManifestParserUtils.ContentType; - // If we just cleared buffer and is on an unbuffered seek, we need to set - // the new timestampOffset of the sourceBuffer. - // Don't do this for text streams, though, since they don't use MediaSource - // anyway. if (startTime != null && sequenceMode && contentType != ContentType.TEXT) { + // If we just cleared buffer and is on an unbuffered seek, we need to set + // the new timestampOffset of the sourceBuffer. + // Don't do this for text streams, though, since they don't use + // MediaSource anyway. if (seeked) { const timestampOffset = /** @type {number} */ (startTime); this.enqueueOperation_( diff --git a/lib/media/segment_index.js b/lib/media/segment_index.js index 6147b80abe..defd379cc8 100644 --- a/lib/media/segment_index.js +++ b/lib/media/segment_index.js @@ -101,6 +101,17 @@ shaka.media.SegmentIndex = class { } + /** + * Iterates over all top-level segment references in this segment index. + * @param {function(!shaka.media.SegmentReference)} fn + */ + forEachTopLevelReference(fn) { + for (const reference of this.references) { + fn(reference); + } + } + + /** * Finds the position of the segment for the given time, in seconds, relative * to the start of the presentation. Returns the position of the segment @@ -348,7 +359,8 @@ shaka.media.SegmentIndex = class { lastReference.appendWindowEnd, lastReference.partialReferences, lastReference.tilesLayout, - lastReference.tileDuration); + lastReference.tileDuration, + lastReference.syncTime); } diff --git a/lib/media/segment_reference.js b/lib/media/segment_reference.js index ab146015aa..1dca44a4eb 100644 --- a/lib/media/segment_reference.js +++ b/lib/media/segment_reference.js @@ -159,11 +159,15 @@ shaka.media.SegmentReference = class { * The explicit duration of an individual tile within the tiles grid. * If not provided, the duration should be automatically calculated based on * the duration of the reference. + * @param {?number=} syncTime + * A time value, expressed in the same scale as the start and end time, which + * is used to synchronize between streams. */ constructor( startTime, endTime, uris, startByte, endByte, initSegmentReference, timestampOffset, appendWindowStart, appendWindowEnd, - partialReferences = [], tilesLayout = '', tileDuration = null) { + partialReferences = [], tilesLayout = '', tileDuration = null, + syncTime = null) { // A preload hinted Partial Segment has the same startTime and endTime. goog.asserts.assert(startTime <= endTime, 'startTime must be less than or equal to endTime'); @@ -213,6 +217,9 @@ shaka.media.SegmentReference = class { /** @type {?number} */ this.tileDuration = tileDuration; + + /** @type {?number} */ + this.syncTime = syncTime; } /** diff --git a/lib/media/streaming_engine.js b/lib/media/streaming_engine.js index ed10a3a7ad..7af7728a55 100644 --- a/lib/media/streaming_engine.js +++ b/lib/media/streaming_engine.js @@ -1595,14 +1595,15 @@ shaka.media.StreamingEngine = class { await this.evict_(mediaState, presentationTime); this.destroyer_.ensureNotDestroyed(); - shaka.log.v1(logPrefix, 'appending media segment'); + shaka.log.v1(logPrefix, 'appending media segment at', + (reference.syncTime == null ? 'unknown' : reference.syncTime)); const seeked = mediaState.seeked; mediaState.seeked = false; await this.playerInterface_.mediaSourceEngine.appendBuffer( mediaState.type, segment, - reference.startTime, + reference.syncTime == null ? reference.startTime : reference.syncTime, reference.endTime, hasClosedCaptions, seeked, diff --git a/lib/util/player_configuration.js b/lib/util/player_configuration.js index 46e7290e58..fe4f5d27e9 100644 --- a/lib/util/player_configuration.js +++ b/lib/util/player_configuration.js @@ -121,6 +121,7 @@ shaka.util.PlayerConfiguration = class { ignoreImageStreamFailures: false, defaultAudioCodec: 'mp4a.40.2', defaultVideoCodec: 'avc1.42E01E', + ignoreManifestProgramDateTime: false, }, }; diff --git a/lib/util/xml_utils.js b/lib/util/xml_utils.js index 84f4bf3cde..34e66b2dc6 100644 --- a/lib/util/xml_utils.js +++ b/lib/util/xml_utils.js @@ -191,7 +191,7 @@ shaka.util.XmlUtils = class { } const result = Date.parse(dateString); - return (!isNaN(result) ? Math.floor(result / 1000.0) : null); + return isNaN(result) ? null : (result / 1000.0); } diff --git a/test/hls/hls_live_unit.js b/test/hls/hls_live_unit.js index 7e76acda1b..a9ee408d66 100644 --- a/test/hls/hls_live_unit.js +++ b/test/hls/hls_live_unit.js @@ -194,8 +194,10 @@ describe('HlsParser live', () => { describe('update', () => { it('adds new segments when they appear', async () => { - const ref1 = ManifestParser.makeReference('test:/main.mp4', 0, 2); - const ref2 = ManifestParser.makeReference('test:/main2.mp4', 2, 4); + const ref1 = makeReference( + 'test:/main.mp4', 0, 2, /* syncTime= */ null); + const ref2 = makeReference( + 'test:/main2.mp4', 2, 4, /* syncTime= */ null); await testUpdate( master, media, [ref1], mediaWithAdditionalSegment, [ref1, ref2]); @@ -209,8 +211,10 @@ describe('HlsParser live', () => { ].join(''); const masterWithTwoVariants = master + secondVariant; - const ref1 = ManifestParser.makeReference('test:/main.mp4', 0, 2); - const ref2 = ManifestParser.makeReference('test:/main2.mp4', 2, 4); + const ref1 = makeReference( + 'test:/main.mp4', 0, 2, /* syncTime= */ null); + const ref2 = makeReference( + 'test:/main2.mp4', 2, 4, /* syncTime= */ null); await testUpdate( masterWithTwoVariants, media, [ref1], mediaWithAdditionalSegment, @@ -230,8 +234,10 @@ describe('HlsParser live', () => { ].join(''); const masterWithAudio = masterlist + audio; - const ref1 = ManifestParser.makeReference('test:/main.mp4', 0, 2); - const ref2 = ManifestParser.makeReference('test:/main2.mp4', 2, 4); + const ref1 = makeReference( + 'test:/main.mp4', 0, 2, /* syncTime= */ null); + const ref2 = makeReference( + 'test:/main2.mp4', 2, 4, /* syncTime= */ null); await testUpdate( masterWithAudio, media, [ref1], mediaWithAdditionalSegment, @@ -251,9 +257,12 @@ describe('HlsParser live', () => { const updatedMedia1 = media + newSegment1; const updatedMedia2 = updatedMedia1 + newSegment2; - const ref1 = ManifestParser.makeReference('test:/main.mp4', 0, 2); - const ref2 = ManifestParser.makeReference('test:/main2.mp4', 2, 4); - const ref3 = ManifestParser.makeReference('test:/main3.mp4', 4, 6); + const ref1 = makeReference( + 'test:/main.mp4', 0, 2, /* syncTime= */ null); + const ref2 = makeReference( + 'test:/main2.mp4', 2, 4, /* syncTime= */ null); + const ref3 = makeReference( + 'test:/main3.mp4', 4, 6, /* syncTime= */ null); fakeNetEngine .setResponseText('test:/master', master) @@ -523,15 +532,15 @@ describe('HlsParser live', () => { .setResponseValue('test:/main.mp4', segmentData) .setResponseValue('test:/main2.mp4', segmentData); - const ref1 = ManifestParser.makeReference( - 'test:/main.mp4', 0, 2, + const ref1 = makeReference( + 'test:/main.mp4', 0, 2, /* syncTime= */ null, /* baseUri= */ '', /* startByte= */ 0, /* endByte= */ null, /* timestampOffset= */ 0); // Expect the timestamp offset to be set for the segment after the // EXT-X-DISCONTINUITY tag. - const ref2 = ManifestParser.makeReference( - 'test:/main2.mp4', 2, 4, + const ref2 = makeReference( + 'test:/main2.mp4', 2, 4, /* syncTime= */ null, /* baseUri= */ '', /* startByte= */ 0, /* endByte= */ null, /* timestampOffset= */ 0); @@ -570,32 +579,32 @@ describe('HlsParser live', () => { .setResponseValue('test:/partial.mp4', segmentData) .setResponseValue('test:/partial2.mp4', segmentData); - const partialRef = ManifestParser.makeReference( - 'test:/partial.mp4', 0, 2, + const partialRef = makeReference( + 'test:/partial.mp4', 0, 2, /* syncTime= */ null, /* baseUri= */ '', /* startByte= */ 0, /* endByte= */ 199); - const partialRef2 = ManifestParser.makeReference( - 'test:/partial2.mp4', 2, 4, + const partialRef2 = makeReference( + 'test:/partial2.mp4', 2, 4, /* syncTime= */ null, /* baseUri= */ '', /* startByte= */ 200, /* endByte= */ 429); - const partialRef3 = ManifestParser.makeReference( - 'test:/partial.mp4', 4, 6, + const partialRef3 = makeReference( + 'test:/partial.mp4', 4, 6, /* syncTime= */ null, /* baseUri= */ '', /* startByte= */ 0, /* endByte= */ 209); // A preload hinted partial segment doesn't have duration information, // so its startTime and endTime are the same. - const preloadRef = ManifestParser.makeReference( - 'test:/partial.mp4', 6, 6, + const preloadRef = makeReference( + 'test:/partial.mp4', 6, 6, /* syncTime= */ null, /* baseUri= */ '', /* startByte= */ 210, /* endByte= */ null); - const ref = ManifestParser.makeReference( - 'test:/main.mp4', 0, 4, + const ref = makeReference( + 'test:/main.mp4', 0, 4, /* syncTime= */ null, /* baseUri= */ '', /* startByte= */ 0, /* endByte= */ 429, /* timestampOffset= */ 0, [partialRef, partialRef2]); // ref2 is not fully published yet, so it doesn't have a segment uri. - const ref2 = ManifestParser.makeReference( - '', 4, 6, + const ref2 = makeReference( + '', 4, 6, /* syncTime= */ null, /* baseUri= */ '', /* startByte= */ 0, /* endByte= */ null, /* timestampOffset= */ 0, [partialRef3, preloadRef]); @@ -607,16 +616,20 @@ describe('HlsParser live', () => { describe('update', () => { it('adds new segments when they appear', async () => { - const ref1 = ManifestParser.makeReference('test:/main.mp4', 0, 2); - const ref2 = ManifestParser.makeReference('test:/main2.mp4', 2, 4); + const ref1 = makeReference( + 'test:/main.mp4', 0, 2, /* syncTime= */ null); + const ref2 = makeReference( + 'test:/main2.mp4', 2, 4, /* syncTime= */ null); await testUpdate( master, media, [ref1], mediaWithAdditionalSegment, [ref1, ref2]); }); it('evicts removed segments', async () => { - const ref1 = ManifestParser.makeReference('test:/main.mp4', 0, 2); - const ref2 = ManifestParser.makeReference('test:/main2.mp4', 2, 4); + const ref1 = makeReference( + 'test:/main.mp4', 0, 2, /* syncTime= */ null); + const ref2 = makeReference( + 'test:/main2.mp4', 2, 4, /* syncTime= */ null); await testUpdate( master, mediaWithAdditionalSegment, [ref1, ref2], @@ -624,12 +637,13 @@ describe('HlsParser live', () => { }); it('handles updates with redirects', async () => { - const oldRef1 = ManifestParser.makeReference('test:/main.mp4', 0, 2); + const oldRef1 = makeReference( + 'test:/main.mp4', 0, 2, /* syncTime= */ null); - const newRef1 = - ManifestParser.makeReference('test:/redirected/main.mp4', 0, 2); - const newRef2 = - ManifestParser.makeReference('test:/redirected/main2.mp4', 2, 4); + const newRef1 = makeReference( + 'test:/redirected/main.mp4', 0, 2, /* syncTime= */ null); + const newRef2 = makeReference( + 'test:/redirected/main2.mp4', 2, 4, /* syncTime= */ null); let playlistFetchCount = 0; @@ -656,8 +670,8 @@ describe('HlsParser live', () => { .setResponseValue('test:/init.mp4', initSegmentData) .setResponseValue('test:/main.mp4', segmentData); - const expectedRef = ManifestParser.makeReference( - 'test:/main.mp4', 0, 2); + const expectedRef = makeReference( + 'test:/main.mp4', 0, 2, /* syncTime= */ null); // In live content, we do not set timestampOffset. expectedRef.timestampOffset = 0; @@ -674,9 +688,11 @@ describe('HlsParser live', () => { .setResponseValue('test:/init.mp4', initSegmentData) .setResponseValue('test:/main.mp4', segmentData); - const ref1 = ManifestParser.makeReference('test:/main.mp4', 0, 2); + const ref1 = makeReference( + 'test:/main.mp4', 0, 2, /* syncTime= */ null); - const ref2 = ManifestParser.makeReference('test:/main2.mp4', 2, 4); + const ref2 = makeReference( + 'test:/main2.mp4', 2, 4, /* syncTime= */ null); const manifest = await parser.start('test:/master', playerInterface); const video = manifest.variants[0].video; @@ -712,9 +728,11 @@ describe('HlsParser live', () => { .setResponseValue('test:/main.mp4', segmentData) .setResponseValue('test:/main2.mp4', segmentData); - const ref1 = ManifestParser.makeReference('test:/main.mp4', 0, 2); + const ref1 = makeReference( + 'test:/main.mp4', 0, 2, /* syncTime= */ null); - const ref2 = ManifestParser.makeReference('test:/main2.mp4', 2, 4); + const ref2 = makeReference( + 'test:/main2.mp4', 2, 4, /* syncTime= */ null); const manifest = await parser.start('test:/master', playerInterface); @@ -811,9 +829,12 @@ describe('HlsParser live', () => { ].join(''); playerInterface.isLowLatencyMode = () => true; - const ref1 = ManifestParser.makeReference('test:/main.mp4', 0, 2); - const ref2 = ManifestParser.makeReference('test:/main2.mp4', 2, 4); - const ref3 = ManifestParser.makeReference('test:/main3.mp4', 4, 6); + const ref1 = makeReference( + 'test:/main.mp4', 0, 2, /* syncTime= */ null); + const ref2 = makeReference( + 'test:/main2.mp4', 2, 4, /* syncTime= */ null); + const ref3 = makeReference( + 'test:/main3.mp4', 4, 6, /* syncTime= */ null); // With 'SKIPPED-SEGMENTS', ref1 is skipped from the playlist, // and ref1 should be in the SegmentReferences list. // ref3 should be appended to the SegmentReferences list. @@ -853,27 +874,27 @@ describe('HlsParser live', () => { playerInterface.isLowLatencyMode = () => true; - const ref1 = ManifestParser.makeReference( - 'test:/main.mp4', 0, 2, + const ref1 = makeReference( + 'test:/main.mp4', 0, 2, /* syncTime= */ null, /* baseUri= */ '', /* startByte= */ 0, /* endByte= */ null, /* timestampOffset= */ 0); // Expect the timestamp offset to be set for the segment after the // EXT-X-DISCONTINUITY tag. - const ref2 = ManifestParser.makeReference( - 'test:/main2.mp4', 2, 4, + const ref2 = makeReference( + 'test:/main2.mp4', 2, 4, /* syncTime= */ null, /* baseUri= */ '', /* startByte= */ 0, /* endByte= */ null, /* timestampOffset= */ 0); // Expect the timestamp offset to be set for the segment, with the // EXT-X-DISCONTINUITY tag skipped in the playlist. - const ref3 = ManifestParser.makeReference( - 'test:/main3.mp4', 4, 6, + const ref3 = makeReference( + 'test:/main3.mp4', 4, 6, /* syncTime= */ null, /* baseUri= */ '', /* startByte= */ 0, /* endByte= */ null, /* timestampOffset= */ 0); - const ref4 = ManifestParser.makeReference( - 'test:/main4.mp4', 6, 8, + const ref4 = makeReference( + 'test:/main4.mp4', 6, 8, /* syncTime= */ null, /* baseUri= */ '', /* startByte= */ 0, /* endByte= */ null, /* timestampOffset= */ 0); @@ -886,4 +907,23 @@ describe('HlsParser live', () => { }); }); // describe('update') }); // describe('playlist type LIVE') + + /** + * @param {string} uri A relative URI to http://example.com + * @param {number} start + * @param {number} end + * @param {?number} syncTime + * @param {string=} baseUri + * @param {number=} startByte + * @param {?number=} endByte + * @param {number=} timestampOffset + * @param {!Array.=} partialReferences + * @param {?string=} tilesLayout + * @return {!shaka.media.SegmentReference} + */ + function makeReference(uri, start, end, syncTime, baseUri, startByte, endByte, + timestampOffset, partialReferences, tilesLayout) { + return ManifestParser.makeReference(uri, start, end, baseUri, startByte, + endByte, timestampOffset, partialReferences, tilesLayout, syncTime); + } }); // describe('HlsParser live') diff --git a/test/hls/hls_parser_unit.js b/test/hls/hls_parser_unit.js index c12a32cfec..0b92a1ec63 100644 --- a/test/hls/hls_parser_unit.js +++ b/test/hls/hls_parser_unit.js @@ -1869,6 +1869,173 @@ describe('HlsParser', () => { expect(actual).toEqual(manifest); }); + describe('produces syncTime', () => { + /** + * @param {number} startTime + * @param {number} endTime + * @param {number} syncTime + * @return {!shaka.media.SegmentReference} + */ + function makeReference(startTime, endTime, syncTime) { + const initUris = () => ['test:/init.mp4']; + const init = new shaka.media.InitSegmentReference(initUris, 0, 615); + const uris = () => ['test:/main.mp4']; + return new shaka.media.SegmentReference( + startTime, endTime, uris, 0, null, init, 0, 0, Infinity, + [], undefined, undefined, syncTime); + } + + /** + * @param {string} media + * @param {!Array.} startTimes + * @param {(function(!shaka.media.SegmentReference))=} modifyFn + */ + async function test(media, startTimes, modifyFn) { + const master = [ + '#EXTM3U\n', + '#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1,vtt",', + 'RESOLUTION=960x540,FRAME-RATE=60\n', + 'video\n', + ].join(''); + + const segments = []; + for (let i = 0; i < startTimes.length - 1; i++) { + const startTime = startTimes[i]; + const endTime = startTimes[i + 1]; + const reference = makeReference(startTime, endTime, startTime); + if (modifyFn) { + modifyFn(reference); + } + segments.push(reference); + } + + const manifest = shaka.test.ManifestGenerator.generate((manifest) => { + manifest.anyTimeline(); + manifest.addPartialVariant((variant) => { + variant.addPartialStream(ContentType.VIDEO, (stream) => { + stream.segmentIndex = new shaka.media.SegmentIndex(segments); + }); + }); + manifest.sequenceMode = true; + }); + + fakeNetEngine + .setResponseText('test:/master', master) + .setResponseText('test:/video', media) + .setResponseValue('test:/init.mp4', initSegmentData) + .setResponseValue('test:/main.mp4', segmentData); + + const actual = await parser.start('test:/master', playerInterface); + expect(actual).toEqual(manifest); + } + + it('from EXT-X-PROGRAM-DATE-TIME', async () => { + await test([ + '#EXTM3U\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXT-X-MAP:URI="init.mp4",BYTERANGE="616@0"\n', + '#EXT-X-PROGRAM-DATE-TIME:2000-01-01T00:00:05.00Z\n', + '#EXTINF:5,\n', + 'main.mp4\n', + '#EXT-X-PROGRAM-DATE-TIME:2000-01-01T00:00:10.00Z\n', + '#EXTINF:5,\n', + 'main.mp4\n', + '#EXT-X-PROGRAM-DATE-TIME:2000-01-01T00:00:15.00Z\n', + '#EXTINF:5,\n', + 'main.mp4\n', + '#EXT-X-PROGRAM-DATE-TIME:2000-01-01T00:00:20.00Z\n', + '#EXTINF:5,\n', + 'main.mp4\n', + '#EXT-X-PROGRAM-DATE-TIME:2000-01-01T00:00:25.00Z\n', + '#EXTINF:5,\n', + 'main.mp4', + ].join(''), [0, 5, 10, 15, 20, 25]); + }); + + it('when some EXT-X-PROGRAM-DATE-TIME values are missing', async () => { + await test([ + '#EXTM3U\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXT-X-MAP:URI="init.mp4",BYTERANGE="616@0"\n', + '#EXTINF:2,\n', + 'main.mp4\n', + '#EXT-X-PROGRAM-DATE-TIME:2000-01-01T00:00:10.00Z\n', + '#EXTINF:5,\n', + 'main.mp4\n', + '#EXTINF:5,\n', + 'main.mp4\n', + '#EXTINF:5,\n', + 'main.mp4\n', + '#EXT-X-PROGRAM-DATE-TIME:2000-01-01T00:00:25.00Z\n', + '#EXTINF:2,\n', + 'main.mp4\n', + '#EXTINF:4,\n', + 'main.mp4', + ].join(''), [0, 2, 7, 12, 17, 19, 23]); + }); + + it('except when ignoreManifestProgramDateTime is set', async () => { + const config = shaka.util.PlayerConfiguration.createDefault().manifest; + config.hls.ignoreManifestProgramDateTime = true; + parser.configure(config); + await test([ + '#EXTM3U\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXT-X-MAP:URI="init.mp4",BYTERANGE="616@0"\n', + '#EXT-X-PROGRAM-DATE-TIME:2000-01-01T00:00:05.00Z\n', + '#EXTINF:5,\n', + 'main.mp4\n', + '#EXT-X-PROGRAM-DATE-TIME:2000-01-01T00:00:10.00Z\n', + '#EXTINF:5,\n', + 'main.mp4\n', + '#EXT-X-PROGRAM-DATE-TIME:2000-01-01T00:00:15.00Z\n', + '#EXTINF:5,\n', + 'main.mp4\n', + '#EXT-X-PROGRAM-DATE-TIME:2000-01-01T00:00:20.00Z\n', + '#EXTINF:5,\n', + 'main.mp4\n', + '#EXT-X-PROGRAM-DATE-TIME:2000-01-01T00:00:25.00Z\n', + '#EXTINF:5,\n', + 'main.mp4', + ].join(''), [0, 5, 10, 15, 20, 25], (reference) => { + reference.syncTime = null; + }); + }); + + it('when there are partial segments', async () => { + playerInterface.isLowLatencyMode = () => true; + await test([ + '#EXTM3U\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXT-X-MAP:URI="init.mp4",BYTERANGE="616@0"\n', + '#EXT-X-PROGRAM-DATE-TIME:2000-01-01T00:00:05.00Z\n', + '#EXTINF:5,\n', + 'main.mp4\n', + '#EXT-X-PROGRAM-DATE-TIME:2000-01-01T00:00:10.00Z\n', + '#EXTINF:5,\n', + 'main.mp4\n', + '#EXT-X-PROGRAM-DATE-TIME:2000-01-01T00:00:15.00Z\n', + '#EXT-X-PART:DURATION=2.5,URI="main.mp4"\n', + '#EXT-X-PART:DURATION=2.5,URI="main.mp4"\n', + '#EXTINF:5,\n', + 'main.mp4\n', + '#EXT-X-PROGRAM-DATE-TIME:2000-01-01T00:00:20.00Z\n', + '#EXTINF:5,\n', + 'main.mp4\n', + '#EXT-X-PROGRAM-DATE-TIME:2000-01-01T00:00:25.00Z\n', + '#EXTINF:5,\n', + 'main.mp4', + ].join(''), [0, 5, 10, 15, 20, 25], (reference) => { + if (reference.startTime == 10) { + reference.partialReferences = [ + makeReference(10, 12.5, 10), + makeReference(12.5, 15, 12.5), + ]; + } + }); + }); + }); + it('drops failed text streams when configured to', async () => { const master = [ '#EXTM3U\n', diff --git a/test/test/util/manifest_parser_util.js b/test/test/util/manifest_parser_util.js index b25c4285cf..d8f9da6870 100644 --- a/test/test/util/manifest_parser_util.js +++ b/test/test/util/manifest_parser_util.js @@ -57,11 +57,12 @@ shaka.test.ManifestParser = class { * @param {number=} timestampOffset * @param {!Array.=} partialReferences * @param {?string=} tilesLayout + * @param {?number=} syncTime * @return {!shaka.media.SegmentReference} */ static makeReference(uri, start, end, baseUri = '', startByte = 0, endByte = null, timestampOffset = 0, - partialReferences = [], tilesLayout = '') { + partialReferences = [], tilesLayout = '', syncTime = null) { const getUris = () => uri.length ? [baseUri + uri] : []; // If a test wants to verify these, they can be set explicitly after @@ -86,6 +87,8 @@ shaka.test.ManifestParser = class { appendWindowEnd, partialReferences, tilesLayout, + /* tileDuration= */ undefined, + syncTime, ); } };