From 450eb2dcd022c358bd4dc21c493f9aa6fe1dd9c0 Mon Sep 17 00:00:00 2001
From: dzianis-dashkevich
<98566601+dzianis-dashkevich@users.noreply.github.com>
Date: Mon, 14 Mar 2022 07:39:28 -0700
Subject: [PATCH] feat: add dts-based timestamp offset calculation with feature
toggle (#1251)
---
README.md | 6 +
index.html | 5 +
scripts/index.js | 5 +-
src/master-playlist-controller.js | 1 +
src/segment-loader.js | 25 +++-
src/videojs-http-streaming.js | 2 +
test/segment-loader.test.js | 187 ++++++++++++++++++++++++++++++
test/segments/videoDiffPtsDts.ts | Bin 0 -> 19552 bytes
8 files changed, 229 insertions(+), 2 deletions(-)
create mode 100644 test/segments/videoDiffPtsDts.ts
diff --git a/README.md b/README.md
index ef4c480eb..832129ceb 100644
--- a/README.md
+++ b/README.md
@@ -58,6 +58,7 @@ Video.js Compatibility: 6.0, 7.0
- [handlePartialData](#handlepartialdata)
- [liveRangeSafeTimeDelta](#liverangesafetimedelta)
- [useNetworkInformationApi](#usenetworkinformationapi)
+ - [useDtsForTimestampOffset](#usedtsfortimestampoffset)
- [captionServices](#captionservices)
- [Format](#format)
- [Example](#example)
@@ -479,6 +480,11 @@ This option defaults to `false`.
* Default: `false`
* Use [window.networkInformation.downlink](https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/downlink) to estimate the network's bandwidth. Per mdn, _The value is never greater than 10 Mbps, as a non-standard anti-fingerprinting measure_. Given this, if bandwidth estimates from both the player and networkInfo are >= 10 Mbps, the player will use the larger of the two values as its bandwidth estimate.
+##### useDtsForTimestampOffset
+* Type: `boolean`,
+* Default: `false`
+* Use [Decode Timestamp](https://www.w3.org/TR/media-source/#decode-timestamp) instead of [Presentation Timestamp](https://www.w3.org/TR/media-source/#presentation-timestamp) for [timestampOffset](https://www.w3.org/TR/media-source/#dom-sourcebuffer-timestampoffset) calculation. This option was introduced to align with DTS-based browsers. This option affects only transmuxed data (eg: transport stream). For more info please check the following [issue](https://github.com/videojs/http-streaming/issues/1247).
+
##### captionServices
* Type: `object`
* Default: undefined
diff --git a/index.html b/index.html
index 77ccf83fd..b2e56ffa6 100644
--- a/index.html
+++ b/index.html
@@ -151,6 +151,11 @@
+
+
+
+
+
diff --git a/scripts/index.js b/scripts/index.js
index 4a6d2c35e..72f4a8540 100644
--- a/scripts/index.js
+++ b/scripts/index.js
@@ -448,6 +448,7 @@
'exact-manifest-timings',
'pixel-diff-selector',
'network-info',
+ 'dts-offset',
'override-native',
'preload',
'mirror-source'
@@ -501,6 +502,7 @@
'liveui',
'pixel-diff-selector',
'network-info',
+ 'dts-offset',
'exact-manifest-timings'
].forEach(function(name) {
stateEls[name].addEventListener('change', function(event) {
@@ -568,7 +570,8 @@
experimentalLLHLS: getInputValue(stateEls.llhls),
experimentalExactManifestTimings: getInputValue(stateEls['exact-manifest-timings']),
experimentalLeastPixelDiffSelector: getInputValue(stateEls['pixel-diff-selector']),
- useNetworkInformationApi: getInputValue(stateEls['network-info'])
+ useNetworkInformationApi: getInputValue(stateEls['network-info']),
+ useDtsForTimestampOffset: getInputValue(stateEls['dts-offset'])
}
}
});
diff --git a/src/master-playlist-controller.js b/src/master-playlist-controller.js
index c05c59c51..d50d6a6d6 100644
--- a/src/master-playlist-controller.js
+++ b/src/master-playlist-controller.js
@@ -238,6 +238,7 @@ export class MasterPlaylistController extends videojs.EventTarget {
const segmentLoaderSettings = {
vhs: this.vhs_,
parse708captions: options.parse708captions,
+ useDtsForTimestampOffset: options.useDtsForTimestampOffset,
captionServices,
mediaSource: this.mediaSource,
currentTime: this.tech_.currentTime.bind(this.tech_),
diff --git a/src/segment-loader.js b/src/segment-loader.js
index 84c86f708..a609c6196 100644
--- a/src/segment-loader.js
+++ b/src/segment-loader.js
@@ -559,6 +559,7 @@ export default class SegmentLoader extends videojs.EventTarget {
this.timelineChangeController_ = settings.timelineChangeController;
this.shouldSaveSegmentTimingInfo_ = true;
this.parse708captions_ = settings.parse708captions;
+ this.useDtsForTimestampOffset_ = settings.useDtsForTimestampOffset;
this.captionServices_ = settings.captionServices;
this.experimentalExactManifestTimings = settings.experimentalExactManifestTimings;
@@ -2905,7 +2906,11 @@ export default class SegmentLoader extends videojs.EventTarget {
// the timing info here comes from video. In the event that the audio is longer than
// the video, this will trim the start of the audio.
// This also trims any offset from 0 at the beginning of the media
- segmentInfo.timestampOffset -= segmentInfo.timingInfo.start;
+ segmentInfo.timestampOffset -= this.getSegmentStartTimeForTimestampOffsetCalculation_({
+ videoTimingInfo: segmentInfo.segment.videoTimingInfo,
+ audioTimingInfo: segmentInfo.segment.audioTimingInfo,
+ timingInfo: segmentInfo.timingInfo
+ });
// In the event that there are part segment downloads, each will try to update the
// timestamp offset. Retaining this bit of state prevents us from updating in the
// future (within the same segment), however, there may be a better way to handle it.
@@ -2926,6 +2931,24 @@ export default class SegmentLoader extends videojs.EventTarget {
}
}
+ getSegmentStartTimeForTimestampOffsetCalculation_({ videoTimingInfo, audioTimingInfo, timingInfo }) {
+ if (!this.useDtsForTimestampOffset_) {
+ return timingInfo.start;
+ }
+
+ if (videoTimingInfo && typeof videoTimingInfo.transmuxedDecodeStart === 'number') {
+ return videoTimingInfo.transmuxedDecodeStart;
+ }
+
+ // handle audio only
+ if (audioTimingInfo && typeof audioTimingInfo.transmuxedDecodeStart === 'number') {
+ return audioTimingInfo.transmuxedDecodeStart;
+ }
+
+ // handle content not transmuxed (e.g., MP4)
+ return timingInfo.start;
+ }
+
updateTimingInfoEnd_(segmentInfo) {
segmentInfo.timingInfo = segmentInfo.timingInfo || {};
const trackInfo = this.getMediaInfo_();
diff --git a/src/videojs-http-streaming.js b/src/videojs-http-streaming.js
index 130233348..bb1690197 100644
--- a/src/videojs-http-streaming.js
+++ b/src/videojs-http-streaming.js
@@ -631,6 +631,7 @@ class VhsHandler extends Component {
this.source_.useBandwidthFromLocalStorage :
this.options_.useBandwidthFromLocalStorage || false;
this.options_.useNetworkInformationApi = this.options_.useNetworkInformationApi || false;
+ this.options_.useDtsForTimestampOffset = this.options_.useDtsForTimestampOffset || false;
this.options_.customTagParsers = this.options_.customTagParsers || [];
this.options_.customTagMappers = this.options_.customTagMappers || [];
this.options_.cacheEncryptionKeys = this.options_.cacheEncryptionKeys || false;
@@ -684,6 +685,7 @@ class VhsHandler extends Component {
'liveRangeSafeTimeDelta',
'experimentalLLHLS',
'useNetworkInformationApi',
+ 'useDtsForTimestampOffset',
'experimentalExactManifestTimings',
'experimentalLeastPixelDiffSelector'
].forEach((option) => {
diff --git a/test/segment-loader.test.js b/test/segment-loader.test.js
index da9476e01..d7ddcc213 100644
--- a/test/segment-loader.test.js
+++ b/test/segment-loader.test.js
@@ -28,6 +28,7 @@ import {
oneSecond as oneSecondSegment,
audio as audioSegment,
video as videoSegment,
+ videoDiffPtsDts as videoDiffPtsDtsSegment,
videoOneSecond as videoOneSecondSegment,
videoOneSecond1 as videoOneSecond1Segment,
videoOneSecond2 as videoOneSecond2Segment,
@@ -1145,6 +1146,192 @@ QUnit.module('SegmentLoader', function(hooks) {
});
});
+ QUnit.test('should use video PTS value for timestamp offset calculation when useDtsForTimestampOffset set as false', function(assert) {
+ loader = new SegmentLoader(LoaderCommonSettings.call(this, {
+ loaderType: 'main',
+ segmentMetadataTrack: this.segmentMetadataTrack,
+ useDtsForTimestampOffset: false
+ }), {});
+
+ const playlist = playlistWithDuration(20, { uri: 'playlist.m3u8' });
+
+ return this.setupMediaSource(loader.mediaSource_, loader.sourceUpdater_).then(() => {
+ return new Promise((resolve, reject) => {
+ loader.one('appended', resolve);
+ loader.one('error', reject);
+
+ loader.playlist(playlist);
+ loader.load();
+
+ this.clock.tick(100);
+
+ standardXHRResponse(this.requests.shift(), videoDiffPtsDtsSegment());
+ });
+ }).then(() => {
+ assert.equal(
+ loader.sourceUpdater_.videoTimestampOffset(),
+ -playlist.segments[0].videoTimingInfo.transmuxedPresentationStart,
+ 'set video timestampOffset'
+ );
+
+ assert.equal(
+ loader.sourceUpdater_.audioTimestampOffset(),
+ -playlist.segments[0].videoTimingInfo.transmuxedPresentationStart,
+ 'set audio timestampOffset'
+ );
+ });
+ });
+
+ QUnit.test('should use video DTS value for timestamp offset calculation when useDtsForTimestampOffset set as true', function(assert) {
+ loader = new SegmentLoader(LoaderCommonSettings.call(this, {
+ loaderType: 'main',
+ segmentMetadataTrack: this.segmentMetadataTrack,
+ useDtsForTimestampOffset: true
+ }), {});
+
+ const playlist = playlistWithDuration(20, { uri: 'playlist.m3u8' });
+
+ return this.setupMediaSource(loader.mediaSource_, loader.sourceUpdater_).then(() => {
+ return new Promise((resolve, reject) => {
+ loader.one('appended', resolve);
+ loader.one('error', reject);
+
+ loader.playlist(playlist);
+ loader.load();
+
+ this.clock.tick(100);
+ // segment
+ standardXHRResponse(this.requests.shift(), videoDiffPtsDtsSegment());
+ });
+ }).then(() => {
+ assert.equal(
+ loader.sourceUpdater_.videoTimestampOffset(),
+ -playlist.segments[0].videoTimingInfo.transmuxedDecodeStart,
+ 'set video timestampOffset'
+ );
+
+ assert.equal(
+ loader.sourceUpdater_.audioTimestampOffset(),
+ -playlist.segments[0].videoTimingInfo.transmuxedDecodeStart,
+ 'set audio timestampOffset'
+ );
+ });
+ });
+
+ QUnit.test('should use video DTS value as primary for muxed segments (eg: audio and video together) for timestamp offset calculation when useDtsForTimestampOffset set as true', function(assert) {
+ loader = new SegmentLoader(LoaderCommonSettings.call(this, {
+ loaderType: 'main',
+ segmentMetadataTrack: this.segmentMetadataTrack,
+ useDtsForTimestampOffset: true
+ }), {});
+
+ const playlist = playlistWithDuration(20, { uri: 'playlist.m3u8' });
+
+ return this.setupMediaSource(loader.mediaSource_, loader.sourceUpdater_).then(() => {
+ return new Promise((resolve, reject) => {
+ loader.one('appended', resolve);
+ loader.one('error', reject);
+
+ loader.playlist(playlist);
+ loader.load();
+
+ this.clock.tick(100);
+
+ standardXHRResponse(this.requests.shift(), muxedSegment());
+ });
+ }).then(() => {
+ assert.equal(
+ loader.sourceUpdater_.videoTimestampOffset(),
+ -playlist.segments[0].videoTimingInfo.transmuxedDecodeStart,
+ 'set video timestampOffset'
+ );
+
+ assert.equal(
+ loader.sourceUpdater_.audioTimestampOffset(),
+ -playlist.segments[0].videoTimingInfo.transmuxedDecodeStart,
+ 'set audio timestampOffset'
+ );
+ });
+ });
+
+ QUnit.test('should use audio DTS value for timestamp offset calculation when useDtsForTimestampOffset set as true and only audio', function(assert) {
+ loader = new SegmentLoader(LoaderCommonSettings.call(this, {
+ loaderType: 'main',
+ segmentMetadataTrack: this.segmentMetadataTrack,
+ useDtsForTimestampOffset: true
+ }), {});
+
+ const playlist = playlistWithDuration(20, { uri: 'playlist.m3u8' });
+
+ return this.setupMediaSource(loader.mediaSource_, loader.sourceUpdater_, { isAudioOnly: true }).then(() => {
+ return new Promise((resolve, reject) => {
+ loader.one('appended', resolve);
+ loader.one('error', reject);
+
+ loader.playlist(playlist);
+ loader.load();
+
+ this.clock.tick(100);
+ // segment
+ standardXHRResponse(this.requests.shift(), audioSegment());
+ });
+ }).then(() => {
+ assert.equal(
+ loader.sourceUpdater_.audioTimestampOffset(),
+ -playlist.segments[0].audioTimingInfo.transmuxedDecodeStart,
+ 'set audio timestampOffset'
+ );
+ });
+ });
+
+ QUnit.test('should fallback to segment\'s start time when there is no transmuxed content (eg: mp4) and useDtsForTimestampOffset is set as true', function(assert) {
+ loader = new SegmentLoader(LoaderCommonSettings.call(this, {
+ loaderType: 'main',
+ segmentMetadataTrack: this.segmentMetadataTrack,
+ useDtsForTimestampOffset: true
+ }), {});
+
+ const playlist = playlistWithDuration(10);
+ const ogPost = loader.transmuxer_.postMessage;
+
+ loader.transmuxer_.postMessage = (message) => {
+ if (message.action === 'probeMp4StartTime') {
+ const evt = newEvent('message');
+
+ evt.data = {action: 'probeMp4StartTime', startTime: 11, data: message.data};
+
+ loader.transmuxer_.dispatchEvent(evt);
+ return;
+ }
+ return ogPost.call(loader.transmuxer_, message);
+ };
+
+ return this.setupMediaSource(loader.mediaSource_, loader.sourceUpdater_).then(() => {
+ return new Promise((resolve, reject) => {
+ loader.one('appended', resolve);
+ loader.one('error', reject);
+
+ playlist.segments.forEach((segment) => {
+ segment.map = {
+ resolvedUri: 'init.mp4',
+ byterange: { length: Infinity, offset: 0 }
+ };
+ });
+ loader.playlist(playlist);
+ loader.load();
+
+ this.clock.tick(100);
+ // init
+ standardXHRResponse(this.requests.shift(), mp4VideoInitSegment());
+ // segment
+ standardXHRResponse(this.requests.shift(), mp4VideoSegment());
+ });
+ }).then(() => {
+ assert.equal(loader.sourceUpdater_.videoTimestampOffset(), -11, 'set video timestampOffset');
+ assert.equal(loader.sourceUpdater_.audioTimestampOffset(), -11, 'set audio timestampOffset');
+ });
+ });
+
QUnit.test('updates timestamps when segments do not start at zero', function(assert) {
const playlist = playlistWithDuration(10);
const ogPost = loader.transmuxer_.postMessage;
diff --git a/test/segments/videoDiffPtsDts.ts b/test/segments/videoDiffPtsDts.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c6c710c4a9b2086875424b80ce6f23e7b1fa3941
GIT binary patch
literal 19552
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