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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 literal 0 HcmV?d00001