Skip to content

Commit 89409ce

Browse files
authored
feat(hls): Read EXT-X-PROGRAM-DATE-TIME (shaka-project#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 shaka-project#2337
1 parent 2c5457b commit 89409ce

16 files changed

+453
-67
lines changed

demo/common/message_ids.js

+1
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ shakaDemo.MessageIds = {
188188
IGNORE_DASH_SUGGESTED_PRESENTATION_DELAY: 'DEMO_IGNORE_DASH_SUGGESTED_PRESENTATION_DELAY',
189189
IGNORE_HLS_IMAGE_FAILURES: 'DEMO_IGNORE_HLS_IMAGE_FAILURES',
190190
IGNORE_HLS_TEXT_FAILURES: 'DEMO_IGNORE_HLS_TEXT_FAILURES',
191+
IGNORE_MANIFEST_PROGRAM_DATE_TIME: 'DEMO_IGNORE_MANIFEST_PROGRAM_DATE_TIME',
191192
IGNORE_MIN_BUFFER_TIME: 'DEMO_IGNORE_MIN_BUFFER_TIME',
192193
IGNORE_TEXT_FAILURES: 'DEMO_IGNORE_TEXT_FAILURES',
193194
INACCURATE_MANIFEST_TOLERANCE: 'DEMO_INACCURATE_MANIFEST_TOLERANCE',

demo/config.js

+2
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,8 @@ shakaDemo.Config = class {
213213
'manifest.hls.defaultAudioCodec')
214214
.addTextInput_(MessageIds.DEFAULT_VIDEO_CODEC,
215215
'manifest.hls.defaultVideoCodec')
216+
.addBoolInput_(MessageIds.IGNORE_MANIFEST_PROGRAM_DATE_TIME,
217+
'manifest.hls.ignoreManifestProgramDateTime')
216218
.addNumberInput_(MessageIds.AVAILABILITY_WINDOW_OVERRIDE,
217219
'manifest.availabilityWindowOverride',
218220
/* canBeDecimal= */ true,

demo/locales/en.json

+1
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
"DEMO_IMA_ASSET_KEY": "Asset key (for LIVE DAI Content)",
100100
"DEMO_IMA_CONTENT_SRC_ID": "Content source ID (for VOD DAI Content)",
101101
"DEMO_IMA_VIDEO_ID": "Video ID (for VOD DAI Content)",
102+
"DEMO_IGNORE_MANIFEST_PROGRAM_DATE_TIME": "Ignore Program Date Time from manifest",
102103
"DEMO_IGNORE_MIN_BUFFER_TIME": "Ignore Min Buffer Time",
103104
"DEMO_IGNORE_TEXT_FAILURES": "Ignore Text Stream Failures",
104105
"DEMO_INACCURATE_MANIFEST_TOLERANCE": "Inaccurate Manifest Tolerance",

demo/locales/source.json

+4
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,10 @@
399399
"description": "The label on a field that allows users to provide a video id for a custom asset.",
400400
"message": "Video ID (for VOD DAI Content)"
401401
},
402+
"DEMO_IGNORE_MANIFEST_PROGRAM_DATE_TIME": {
403+
"description": "The name of a configuration value.",
404+
"message": "Ignore Program Date Time from manifest"
405+
},
402406
"DEMO_IGNORE_MIN_BUFFER_TIME": {
403407
"description": "The name of a configuration value.",
404408
"message": "Ignore Min Buffer Time"

externs/shaka/player.js

+7-1
Original file line numberDiff line numberDiff line change
@@ -747,7 +747,8 @@ shaka.extern.DashManifestConfiguration;
747747
* ignoreTextStreamFailures: boolean,
748748
* ignoreImageStreamFailures: boolean,
749749
* defaultAudioCodec: string,
750-
* defaultVideoCodec: string
750+
* defaultVideoCodec: string,
751+
* ignoreManifestProgramDateTime: boolean
751752
* }}
752753
*
753754
* @property {boolean} ignoreTextStreamFailures
@@ -762,6 +763,11 @@ shaka.extern.DashManifestConfiguration;
762763
* @property {string} defaultVideoCodec
763764
* The default video codec if it is not specified in the HLS playlist.
764765
* <i>Defaults to <code>'avc1.42E01E'</code>.</i>
766+
* @property {boolean} ignoreManifestProgramDateTime
767+
* If <code>true</code>, the HLS parser will ignore the
768+
* <code>EXT-X-PROGRAM-DATE-TIME</code> tags in the manifest.
769+
* Meant for tags that are incorrect or malformed.
770+
* <i>Defaults to <code>false</code>.</i>
765771
* @exportDoc
766772
*/
767773
shaka.extern.HlsManifestConfiguration;

lib/hls/hls_parser.js

+144-1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ goog.require('shaka.util.OperationManager');
3838
goog.require('shaka.util.Pssh');
3939
goog.require('shaka.util.Timer');
4040
goog.require('shaka.util.Platform');
41+
goog.require('shaka.util.XmlUtils');
4142
goog.requireType('shaka.hls.Segment');
4243

4344

@@ -120,6 +121,15 @@ shaka.hls.HlsParser = class {
120121
*/
121122
this.updatePlaylistDelay_ = 0;
122123

124+
/**
125+
* A time offset to apply to EXT-X-PROGRAM-DATE-TIME values to normalize
126+
* them so that they start at 0. This is necessary because these times will
127+
* be used to set presentation times for segments.
128+
* null means we don't have enough data yet.
129+
* @private {?number}
130+
*/
131+
this.syncTimeOffset_ = null;
132+
123133
/**
124134
* This timer is used to trigger the start of a manifest update. A manifest
125135
* update is async. Once the update is finished, the timer will be restarted
@@ -351,6 +361,42 @@ shaka.hls.HlsParser = class {
351361
// No-op
352362
}
353363

364+
/**
365+
* If necessary, makes sure that sync times will be normalized to 0, so that
366+
* a stream does not start buffering at 50 years in because sync times are
367+
* measured in time since 1970.
368+
* @private
369+
*/
370+
calculateSyncTimeOffset_() {
371+
if (this.syncTimeOffset_ != null) {
372+
// The offset was already calculated.
373+
return;
374+
}
375+
376+
const segments = new Set();
377+
let lowestSyncTime = Infinity;
378+
for (const streamInfo of this.uriToStreamInfosMap_.values()) {
379+
const segmentIndex = streamInfo.stream.segmentIndex;
380+
if (segmentIndex) {
381+
segmentIndex.forEachTopLevelReference((segment) => {
382+
if (segment.syncTime != null) {
383+
lowestSyncTime = Math.min(lowestSyncTime, segment.syncTime);
384+
segments.add(segment);
385+
}
386+
});
387+
}
388+
}
389+
if (segments.size > 0) {
390+
this.syncTimeOffset_ = -lowestSyncTime;
391+
for (const segment of segments) {
392+
segment.syncTime += this.syncTimeOffset_;
393+
for (const partial of segment.partialReferences) {
394+
partial.syncTime += this.syncTimeOffset_;
395+
}
396+
}
397+
}
398+
}
399+
354400
/**
355401
* Parses the manifest.
356402
*
@@ -433,6 +479,10 @@ shaka.hls.HlsParser = class {
433479
shaka.util.Error.Code.OPERATION_ABORTED);
434480
}
435481

482+
// Now that we have generated all streams, we can determine the offset to
483+
// apply to sync times.
484+
this.calculateSyncTimeOffset_();
485+
436486
if (this.aesEncrypted_ && variants.length == 0) {
437487
// We do not support AES-128 encryption with HLS yet. Variants is null
438488
// when the playlist is encrypted with AES-128.
@@ -1706,8 +1756,8 @@ shaka.hls.HlsParser = class {
17061756
* @param {number} startTime
17071757
* @param {!Map.<string, string>} variables
17081758
* @param {string} absoluteMediaPlaylistUri
1709-
* @return {!shaka.media.SegmentReference}
17101759
* @param {string} type
1760+
* @return {!shaka.media.SegmentReference}
17111761
* @private
17121762
*/
17131763
createSegmentReference_(
@@ -1730,6 +1780,23 @@ shaka.hls.HlsParser = class {
17301780
'true, and see https://bit.ly/3clctcj for details.');
17311781
}
17321782

1783+
let syncTime = null;
1784+
if (!this.config_.hls.ignoreManifestProgramDateTime) {
1785+
const dateTimeTag =
1786+
shaka.hls.Utils.getFirstTagWithName(tags, 'EXT-X-PROGRAM-DATE-TIME');
1787+
if (dateTimeTag && dateTimeTag.value) {
1788+
const time = shaka.util.XmlUtils.parseDate(dateTimeTag.value);
1789+
goog.asserts.assert(time != null,
1790+
'EXT-X-PROGRAM-DATE-TIME format not valid');
1791+
// Sync time offset is null on the first go-through. This indicates that
1792+
// we have not yet seen every stream, and thus do not yet have enough
1793+
// information to determine how to normalize the sync times.
1794+
// For that first go-through, the sync time will be applied after the
1795+
// references are all created. Until then, just offset by 0.
1796+
syncTime = time + (this.syncTimeOffset_ || 0);
1797+
}
1798+
}
1799+
17331800
// Create SegmentReferences for the partial segments.
17341801
const partialSegmentRefs = [];
17351802
if (this.lowLatencyMode_ && hlsSegment.partialSegments.length) {
@@ -1839,6 +1906,7 @@ shaka.hls.HlsParser = class {
18391906
partialSegmentRefs,
18401907
tilesLayout,
18411908
tileDuration,
1909+
syncTime,
18421910
);
18431911
}
18441912

@@ -1966,6 +2034,81 @@ shaka.hls.HlsParser = class {
19662034
references.push(reference);
19672035
}
19682036

2037+
// If some segments have sync times, but not all, extrapolate the sync
2038+
// times of the ones with none.
2039+
const someSyncTime = references.some((ref) => ref.syncTime != null);
2040+
if (someSyncTime) {
2041+
for (let i = 0; i < references.length; i++) {
2042+
const reference = references[i];
2043+
if (reference.syncTime != null) {
2044+
// No need to extrapolate.
2045+
continue;
2046+
}
2047+
// Find the nearest segment with syncTime, in either direction.
2048+
// This looks forward and backward simultaneously, keeping track of what
2049+
// to offset the syncTime it finds by as it goes.
2050+
let forwardAdd = 0;
2051+
let forwardI = i;
2052+
/**
2053+
* Look forwards one reference at a time, summing all durations as we
2054+
* go, until we find a reference with a syncTime to use as a basis.
2055+
* This DOES count the original reference, but DOESN'T count the first
2056+
* reference with a syncTime (as we approach it from behind).
2057+
* @return {?number}
2058+
*/
2059+
const lookForward = () => {
2060+
const other = references[forwardI];
2061+
if (other) {
2062+
if (other.syncTime != null) {
2063+
return other.syncTime + forwardAdd;
2064+
}
2065+
forwardAdd -= other.endTime - other.startTime;
2066+
forwardI += 1;
2067+
}
2068+
return null;
2069+
};
2070+
let backwardAdd = 0;
2071+
let backwardI = i;
2072+
/**
2073+
* Look backwards one reference at a time, summing all durations as we
2074+
* go, until we find a reference with a syncTime to use as a basis.
2075+
* This DOESN'T count the original reference, but DOES count the first
2076+
* reference with a syncTime (as we approach it from ahead).
2077+
* @return {?number}
2078+
*/
2079+
const lookBackward = () => {
2080+
const other = references[backwardI];
2081+
if (other) {
2082+
if (other != reference) {
2083+
backwardAdd += other.endTime - other.startTime;
2084+
}
2085+
if (other.syncTime != null) {
2086+
return other.syncTime + backwardAdd;
2087+
}
2088+
backwardI -= 1;
2089+
}
2090+
return null;
2091+
};
2092+
while (reference.syncTime == null) {
2093+
reference.syncTime = lookBackward();
2094+
if (reference.syncTime == null) {
2095+
reference.syncTime = lookForward();
2096+
}
2097+
}
2098+
}
2099+
}
2100+
2101+
// Split the sync times properly among partial segments.
2102+
if (someSyncTime) {
2103+
for (const reference of references) {
2104+
let syncTime = reference.syncTime;
2105+
for (const partial of reference.partialReferences) {
2106+
partial.syncTime = syncTime;
2107+
syncTime += partial.endTime - partial.startTime;
2108+
}
2109+
}
2110+
}
2111+
19692112
return references;
19702113
}
19712114

lib/hls/manifest_text_parser.js

+2-3
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,8 @@ shaka.hls.ManifestTextParser = class {
179179
segmentTags.push(currentMapTag);
180180
}
181181
// The URI appears after all of the tags describing the segment.
182-
const segment =
183-
new shaka.hls.Segment(absoluteSegmentUri, segmentTags,
184-
partialSegmentTags);
182+
const segment = new shaka.hls.Segment(absoluteSegmentUri, segmentTags,
183+
partialSegmentTags);
185184
segments.push(segment);
186185
segmentTags = [];
187186
partialSegmentTags = [];

lib/media/media_source_engine.js

+4-5
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,6 @@ shaka.media.MediaSourceEngine = class {
506506
* @param {?number} endTime relative to the start of the presentation
507507
* @param {?boolean} hasClosedCaptions True if the buffer contains CEA closed
508508
* captions
509-
510509
* @param {boolean=} seeked True if we just seeked
511510
* @param {boolean=} sequenceMode True if sequence mode
512511
* @return {!Promise}
@@ -515,11 +514,11 @@ shaka.media.MediaSourceEngine = class {
515514
seeked, sequenceMode) {
516515
const ContentType = shaka.util.ManifestParserUtils.ContentType;
517516

518-
// If we just cleared buffer and is on an unbuffered seek, we need to set
519-
// the new timestampOffset of the sourceBuffer.
520-
// Don't do this for text streams, though, since they don't use MediaSource
521-
// anyway.
522517
if (startTime != null && sequenceMode && contentType != ContentType.TEXT) {
518+
// If we just cleared buffer and is on an unbuffered seek, we need to set
519+
// the new timestampOffset of the sourceBuffer.
520+
// Don't do this for text streams, though, since they don't use
521+
// MediaSource anyway.
523522
if (seeked) {
524523
const timestampOffset = /** @type {number} */ (startTime);
525524
this.enqueueOperation_(

lib/media/segment_index.js

+13-1
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,17 @@ shaka.media.SegmentIndex = class {
101101
}
102102

103103

104+
/**
105+
* Iterates over all top-level segment references in this segment index.
106+
* @param {function(!shaka.media.SegmentReference)} fn
107+
*/
108+
forEachTopLevelReference(fn) {
109+
for (const reference of this.references) {
110+
fn(reference);
111+
}
112+
}
113+
114+
104115
/**
105116
* Finds the position of the segment for the given time, in seconds, relative
106117
* to the start of the presentation. Returns the position of the segment
@@ -348,7 +359,8 @@ shaka.media.SegmentIndex = class {
348359
lastReference.appendWindowEnd,
349360
lastReference.partialReferences,
350361
lastReference.tilesLayout,
351-
lastReference.tileDuration);
362+
lastReference.tileDuration,
363+
lastReference.syncTime);
352364
}
353365

354366

lib/media/segment_reference.js

+8-1
Original file line numberDiff line numberDiff line change
@@ -159,11 +159,15 @@ shaka.media.SegmentReference = class {
159159
* The explicit duration of an individual tile within the tiles grid.
160160
* If not provided, the duration should be automatically calculated based on
161161
* the duration of the reference.
162+
* @param {?number=} syncTime
163+
* A time value, expressed in the same scale as the start and end time, which
164+
* is used to synchronize between streams.
162165
*/
163166
constructor(
164167
startTime, endTime, uris, startByte, endByte, initSegmentReference,
165168
timestampOffset, appendWindowStart, appendWindowEnd,
166-
partialReferences = [], tilesLayout = '', tileDuration = null) {
169+
partialReferences = [], tilesLayout = '', tileDuration = null,
170+
syncTime = null) {
167171
// A preload hinted Partial Segment has the same startTime and endTime.
168172
goog.asserts.assert(startTime <= endTime,
169173
'startTime must be less than or equal to endTime');
@@ -213,6 +217,9 @@ shaka.media.SegmentReference = class {
213217

214218
/** @type {?number} */
215219
this.tileDuration = tileDuration;
220+
221+
/** @type {?number} */
222+
this.syncTime = syncTime;
216223
}
217224

218225
/**

lib/media/streaming_engine.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -1595,14 +1595,15 @@ shaka.media.StreamingEngine = class {
15951595

15961596
await this.evict_(mediaState, presentationTime);
15971597
this.destroyer_.ensureNotDestroyed();
1598-
shaka.log.v1(logPrefix, 'appending media segment');
1598+
shaka.log.v1(logPrefix, 'appending media segment at',
1599+
(reference.syncTime == null ? 'unknown' : reference.syncTime));
15991600

16001601
const seeked = mediaState.seeked;
16011602
mediaState.seeked = false;
16021603
await this.playerInterface_.mediaSourceEngine.appendBuffer(
16031604
mediaState.type,
16041605
segment,
1605-
reference.startTime,
1606+
reference.syncTime == null ? reference.startTime : reference.syncTime,
16061607
reference.endTime,
16071608
hasClosedCaptions,
16081609
seeked,

lib/util/player_configuration.js

+1
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ shaka.util.PlayerConfiguration = class {
121121
ignoreImageStreamFailures: false,
122122
defaultAudioCodec: 'mp4a.40.2',
123123
defaultVideoCodec: 'avc1.42E01E',
124+
ignoreManifestProgramDateTime: false,
124125
},
125126
};
126127

lib/util/xml_utils.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ shaka.util.XmlUtils = class {
191191
}
192192

193193
const result = Date.parse(dateString);
194-
return (!isNaN(result) ? Math.floor(result / 1000.0) : null);
194+
return isNaN(result) ? null : (result / 1000.0);
195195
}
196196

197197

0 commit comments

Comments
 (0)