From b2673fdf4bf7f0be8e6c12e2ebf62275417fc934 Mon Sep 17 00:00:00 2001 From: Joaquin Bartaburu <93544650+JoaquinBCh@users.noreply.github.com> Date: Fri, 29 Nov 2024 08:31:52 -0300 Subject: [PATCH] feat: CMCD v2 LTC and MSD keys (#7412) --- AUTHORS | 1 + CONTRIBUTORS | 3 + demo/config.js | 1 + externs/cmcd.js | 17 +++- externs/shaka/player.js | 9 +- lib/player.js | 27 +++++- lib/util/cmcd_manager.js | 152 ++++++++++++++++++++++++++++--- lib/util/player_configuration.js | 1 + test/cast/cast_utils_unit.js | 1 + test/player_unit.js | 39 ++++++++ test/util/cmcd_manager_unit.js | 83 +++++++++++++++++ 11 files changed, 317 insertions(+), 17 deletions(-) diff --git a/AUTHORS b/AUTHORS index 9aa71c3872..749e868a49 100644 --- a/AUTHORS +++ b/AUTHORS @@ -81,6 +81,7 @@ Peter Nycander Philo Inc. <*@philo.com> PikachuEXE Prakash +Qualabs <*@qualabs.com> Robert Colantuoni Robert Galluccio Rodolphe Breton diff --git a/CONTRIBUTORS b/CONTRIBUTORS index c78e5553c6..ade2ba7e29 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -77,12 +77,14 @@ Jeffrey Swan Jesper Haug Karsrud Jesse Gunsch Jimmy Ly +Joaquin Bartaburu Joey Parrish Johan Sundström John Bowers Jonas Birmé Jono Ward Jozef Chúťka +Juan Manuel Tomás Julian Domingo Jun Hong Chong Jürgen Kartnaller @@ -130,6 +132,7 @@ Sander Saares Sandra Lokshina Sanil Raut Satheesh Velmurugan +Sebastián Piquerez Semih Gokceoglu Seongryun Jo Seth Madison diff --git a/demo/config.js b/demo/config.js index 727148b550..03517d7d9b 100644 --- a/demo/config.js +++ b/demo/config.js @@ -339,6 +339,7 @@ shakaDemo.Config = class { .addBoolInput_('Enabled', 'cmcd.enabled') .addTextInput_('Session ID', 'cmcd.sessionId') .addTextInput_('Content ID', 'cmcd.contentId') + .addTextInput_('Version', 'cmcd.version') .addNumberInput_('RTP safety Factor', 'cmcd.rtpSafetyFactor', /* canBeDecimal= */ true) .addBoolInput_('Use Headers', 'cmcd.useHeaders'); diff --git a/externs/cmcd.js b/externs/cmcd.js index a8cf49e3b4..d3091094e1 100644 --- a/externs/cmcd.js +++ b/externs/cmcd.js @@ -31,7 +31,9 @@ * st: (string|undefined), * v: (number|undefined), * bs: (boolean|undefined), - * rtp: (number|undefined) + * rtp: (number|undefined), + * msd: (number|undefined), + * ltc: (number|undefined), * }} * * @description @@ -182,5 +184,18 @@ * delivery. The concept is that each client receives the throughput necessary * for great performance, but no more. The CDN may not support the rtp * feature. + * + * @property {number} msd + * The Media Start Delay represents in milliseconds the delay between the + * initiation of the playback request and the moment the first frame is + * rendered. This is sent only once when it is calculated. + * + * @property {number} ltc + * Live Stream Latency + * + * The time delta between when a given media timestamp was made available at + * the origin and when it was rendered by the client. The accuracy of this + * estimate is dependent on synchronization between the packager and the + * player clocks. */ var CmcdData; diff --git a/externs/shaka/player.js b/externs/shaka/player.js index af516c60ff..9c069f31f2 100644 --- a/externs/shaka/player.js +++ b/externs/shaka/player.js @@ -2101,7 +2101,8 @@ shaka.extern.AdvancedAbrConfiguration; * sessionId: string, * contentId: string, * rtpSafetyFactor: number, - * includeKeys: !Array + * includeKeys: !Array, + * version: number * }} * * @description @@ -2140,6 +2141,12 @@ shaka.extern.AdvancedAbrConfiguration; * will be included. *
* Defaults to []. + * @property {number} version + * The CMCD version. + * Valid values are 1 or 2, corresponding to CMCD v1 + * and CMCD v2 specifications, respectively. + *
+ * Defaults to 1. * @exportDoc */ shaka.extern.CmcdConfiguration; diff --git a/lib/player.js b/lib/player.js index ad84c85dab..0e6b7b4be8 100644 --- a/lib/player.js +++ b/lib/player.js @@ -1218,6 +1218,9 @@ shaka.Player = class extends shaka.util.FakeEventTarget { const onError = (error) => this.onVideoError_(error); this.attachEventManager_.listen(mediaElement, 'error', onError); this.video_ = mediaElement; + if (this.cmcdManager_) { + this.cmcdManager_.setMediaElement(mediaElement); + } } // Only initialize media source if the platform supports it. @@ -2640,6 +2643,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { if (this.cmcdManager_) { this.cmcdManager_.setLowLatency( this.manifest_.isLowLatency && this.config_.streaming.lowLatencyMode); + this.cmcdManager_.setStartTimeOfLoad(startTimeOfLoad * 1000); } shaka.Player.applyPlayRange_(this.manifest_.presentationTimeline, @@ -3752,6 +3756,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { getNetworkingEngine: () => this.getNetworkingEngine(), getVariantTracks: () => this.getVariantTracks(), isLive: () => this.isLive(), + getLiveLatency: () => this.getLiveLatency(), }; return new shaka.util.CmcdManager(playerInterface, this.config_.cmcd); @@ -5772,6 +5777,22 @@ shaka.Player = class extends shaka.util.FakeEventTarget { return info; } + /** + * Get latency in milliseconds between the live edge and what's currently + * playing. + * + * @return {?number} The latency in milliseconds, or null if nothing + * is playing. + */ + getLiveLatency() { + if (!this.video_) { + return null; + } + const now = this.getPresentationStartTimeAsDate().getTime() + + this.video_.currentTime * 1000; + return Date.now() - now; + } + /** * Get statistics for the current playback session. If the player is not * playing content, this will return an empty stats object. @@ -5841,10 +5862,8 @@ shaka.Player = class extends shaka.util.FakeEventTarget { } if (this.isLive()) { - const now = this.getPresentationStartTimeAsDate().valueOf() + - element.currentTime * 1000; - const latency = (Date.now() - now) / 1000; - this.stats_.setLiveLatency(latency); + const latency = this.getLiveLatency() || 0; + this.stats_.setLiveLatency(latency / 1000); } if (this.manifest_) { diff --git a/lib/util/cmcd_manager.js b/lib/util/cmcd_manager.js index a7294be83e..f5151aec11 100644 --- a/lib/util/cmcd_manager.js +++ b/lib/util/cmcd_manager.js @@ -10,6 +10,7 @@ goog.require('goog.Uri'); goog.require('shaka.log'); goog.require('shaka.net.NetworkingEngine'); goog.require('shaka.util.ArrayUtils'); +goog.require('shaka.util.EventManager'); goog.requireType('shaka.media.SegmentReference'); @@ -56,6 +57,44 @@ shaka.util.CmcdManager = class { * @private {boolean} */ this.lowLatency_ = false; + + /** + * @private {number|undefined} + */ + this.playbackPlayTime_ = undefined; + + /** + * @private {number|undefined} + */ + this.playbackPlayingTime_ = undefined; + + /** + * @private {number} + */ + this.startTimeOfLoad_ = 0; + + /** + * @private {boolean} + */ + this.msdSent_ = false; + + /** + * @private {shaka.util.EventManager} + */ + this.eventManager_ = new shaka.util.EventManager(); + + /** @private {HTMLMediaElement} */ + this.video_ = null; + } + + + /** + * Set media element and setup event listeners + * @param {HTMLMediaElement} mediaElement The video element + */ + setMediaElement(mediaElement) { + this.video_ = mediaElement; + this.setupMSDEventListeners_(); } /** @@ -77,6 +116,12 @@ shaka.util.CmcdManager = class { this.buffering_ = true; this.starved_ = false; this.lowLatency_ = false; + this.playbackPlayTime_ = 0; + this.playbackPlayingTime_ = 0; + this.startTimeOfLoad_ = 0; + this.msdSent_ = false; + this.video_ = null; + this.eventManager_.removeAll(); } /** @@ -120,6 +165,24 @@ shaka.util.CmcdManager = class { } } + /** + * Set start time of load if autoplay is enabled + * + * @param {number} startTimeOfLoad + */ + setStartTimeOfLoad(startTimeOfLoad) { + if (this.video_) { + const playResult = this.video_.play(); + if (playResult) { + playResult.then(() => { + this.startTimeOfLoad_ = startTimeOfLoad; + }).catch((e) => { + this.startTimeOfLoad_ = 0; + }); + } + } + } + /** * Apply CMCD data to a request. * @@ -360,6 +423,40 @@ shaka.util.CmcdManager = class { } } + /** + * Set playbackPlayTime_ when the play event is triggered + * @private + */ + onPlaybackPlay_() { + if (!this.playbackPlayTime_) { + this.playbackPlayTime_ = Date.now(); + } + } + + /** + * Set playbackPlayingTime_ + * @private + */ + onPlaybackPlaying_() { + if (!this.playbackPlayingTime_) { + this.playbackPlayingTime_ = Date.now(); + } + } + + /** + * Setup event listeners for msd calculation + * @private + */ + setupMSDEventListeners_() { + const onPlaybackPlay = () => this.onPlaybackPlay_(); + this.eventManager_.listenOnce( + this.video_, 'play', onPlaybackPlay); + + const onPlaybackPlaying = () => this.onPlaybackPlaying_(); + this.eventManager_.listenOnce( + this.video_, 'playing', onPlaybackPlaying); + } + /** * Create baseline CMCD data * @@ -371,7 +468,7 @@ shaka.util.CmcdManager = class { this.config_.sessionId = window.crypto.randomUUID(); } return { - v: shaka.util.CmcdManager.Version, + v: this.config_.version, sf: this.sf_, sid: this.config_.sessionId, cid: this.config_.contentId, @@ -410,6 +507,18 @@ shaka.util.CmcdManager = class { data.su = this.buffering_; } + if (data.v === shaka.util.CmcdManager.Version.VERSION_2) { + if (this.playerInterface_.isLive()) { + data.ltc = this.playerInterface_.getLiveLatency(); + } + + const msd = this.calculateMSD_(); + if (msd != undefined) { + data.msd = msd; + this.msdSent_ = true; + } + } + const output = this.filterKeys_(data); if (useHeaders) { @@ -614,6 +723,23 @@ shaka.util.CmcdManager = class { return toPath.join('/'); } + /** + * Calculate messured start delay + * + * @return {number|undefined} + * @private + */ + calculateMSD_() { + if (!this.msdSent_ && + this.playbackPlayingTime_ && + this.playbackPlayTime_) { + const startTime = this.startTimeOfLoad_ || this.playbackPlayTime_; + return this.playbackPlayingTime_ - startTime; + } + return undefined; + } + + /** * Calculate requested maximun throughput * @@ -810,8 +936,8 @@ shaka.util.CmcdManager = class { const headerGroups = [{}, {}, {}, {}]; const headerMap = { br: 0, d: 0, ot: 0, tb: 0, - bl: 1, dl: 1, mtp: 1, nor: 1, nrr: 1, su: 1, - cid: 2, pr: 2, sf: 2, sid: 2, st: 2, v: 2, + bl: 1, dl: 1, mtp: 1, nor: 1, nrr: 1, su: 1, ltc: 1, + cid: 2, pr: 2, sf: 2, sid: 2, st: 2, v: 2, msd: 2, bs: 3, rtp: 3, }; @@ -873,7 +999,8 @@ shaka.util.CmcdManager = class { * getCurrentTime: function():number, * getPlaybackRate: function():number, * getVariantTracks: function():Array., - * isLive: function():boolean + * isLive: function():boolean, + * getLiveLatency: function():number * }} * * @property {function():number} getBandwidthEstimate @@ -888,6 +1015,9 @@ shaka.util.CmcdManager = class { * Get the variant tracks * @property {function():boolean} isLive * Get if the player is playing live content. + * @property {function():number} getLiveLatency + * Get latency in milliseconds between the live edge and what's currently + * playing. */ shaka.util.CmcdManager.PlayerInterface; @@ -907,6 +1037,13 @@ shaka.util.CmcdManager.ObjectType = { OTHER: 'o', }; +/** + * @enum {number} + */ +shaka.util.CmcdManager.Version = { + VERSION_1: 1, + VERSION_2: 2, +}; /** * @enum {string} @@ -929,10 +1066,3 @@ shaka.util.CmcdManager.StreamingFormat = { SMOOTH: 's', OTHER: 'o', }; - - -/** - * The CMCD spec version - * @const {number} - */ -shaka.util.CmcdManager.Version = 1; diff --git a/lib/util/player_configuration.js b/lib/util/player_configuration.js index e6342ef817..92b58398eb 100644 --- a/lib/util/player_configuration.js +++ b/lib/util/player_configuration.js @@ -370,6 +370,7 @@ shaka.util.PlayerConfiguration = class { rtpSafetyFactor: 5, useHeaders: false, includeKeys: [], + version: 1, }; const cmsd = { diff --git a/test/cast/cast_utils_unit.js b/test/cast/cast_utils_unit.js index 8782c1cb7a..856ecf1fe6 100644 --- a/test/cast/cast_utils_unit.js +++ b/test/cast/cast_utils_unit.js @@ -35,6 +35,7 @@ describe('CastUtils', () => { 'getNonDefaultConfiguration', 'addFont', 'getFetchedPlaybackInfo', + 'getLiveLatency', 'isRemotePlayback', // Test helper methods (not @export'd) diff --git a/test/player_unit.js b/test/player_unit.js index 7ed0d9f11d..b7253c63d5 100644 --- a/test/player_unit.js +++ b/test/player_unit.js @@ -2872,6 +2872,45 @@ describe('Player', () => { } }); // describe('languages') + describe('getLiveLatency()', () => { + let timeline; + + beforeEach(async () => { + // Create a presentation timeline for a live stream. + timeline = new shaka.media.PresentationTimeline(300, 0); + timeline.setStatic(false); // Indicate that this is a live stream. + + // Set an initial program date time to simulate a live stream with a + // known start time. + timeline.setInitialProgramDateTime(1000); + + // Generate a manifest that uses this timeline. + manifest = shaka.test.ManifestGenerator.generate((manifest) => { + manifest.presentationTimeline = timeline; + manifest.addVariant(0, (variant) => { + variant.addVideo(1); + }); + }); + + // Load the player with the live manifest. + await player.load(fakeManifestUri, null, fakeMimeType); + + video.currentTime = 10; // Simulate that we're 10 seconds into playback. + }); + + it('returns null if video element does not exist', async () => { + await player.detach(); + const latency = player.getLiveLatency(); + expect(latency).toBeNull(); + }); + + it('returns correct latency when video is playing', () => { + Date.now = () => 2000 * 1000; + const latency = player.getLiveLatency(); + expect(latency).toBe(990000); + }); + }); + describe('getStats', () => { const oldDateNow = Date.now; diff --git a/test/util/cmcd_manager_unit.js b/test/util/cmcd_manager_unit.js index eaba205ab5..302daf207d 100644 --- a/test/util/cmcd_manager_unit.js +++ b/test/util/cmcd_manager_unit.js @@ -31,6 +31,7 @@ describe('CmcdManager', () => { const playerInterface = { isLive: () => false, + getLiveLatency: () => 0, getBandwidthEstimate: () => 10000000, getBufferedInfo: () => ({ video: [ @@ -64,6 +65,7 @@ describe('CmcdManager', () => { rtpSafetyFactor: 5, useHeaders: false, includeKeys: [], + version: 1, }; function createCmcdConfig(cfg = {}) { @@ -505,6 +507,87 @@ describe('CmcdManager', () => { const result = request.uris[0]; expect(result).not.toContain('?CMCD='); }); + + it('returns cmcd v2 data in query if version is 2', async () => { + // Set live to true to enable ltc + playerInterface.isLive = () => true; + cmcdManager = createCmcdManager({ + version: 2, + includeKeys: ['ltc', 'msd', 'v'], + }); + networkingEngine = createNetworkingEngine(cmcdManager); + + // Trigger Play and Playing events + cmcdManager.onPlaybackPlay_(); + cmcdManager.onPlaybackPlaying_(); + const request = NetworkingEngine.makeRequest([uri], retry); + await networkingEngine.request(RequestType.MANIFEST, request, + {type: AdvancedRequestType.MPD}); + const result = request.uris[0]; + expect(result).toContain(encodeURIComponent('v=2')); + expect(result).toContain(encodeURIComponent('ltc')); + expect(result).toContain(encodeURIComponent('msd')); + }); + + it('doesnt return cmcd v2 data in query if version is not 2', + async () => { + // Set live to true to enable ltc + playerInterface.isLive = () => true; + + const cmcdManagerTmp = createCmcdManager({ + version: 1, + includeKeys: ['ltc', 'msd'], + }); + networkingEngine = createNetworkingEngine(cmcdManagerTmp); + + // Trigger Play and Playing events + cmcdManagerTmp.onPlaybackPlay_(); + cmcdManagerTmp.onPlaybackPlaying_(); + + const request = NetworkingEngine.makeRequest([uri], retry); + await networkingEngine.request(RequestType.MANIFEST, request, + {type: AdvancedRequestType.MPD}); + const result = request.uris[0]; + expect(result).not.toContain(encodeURIComponent('ltc')); + expect(result).not.toContain(encodeURIComponent('msd')); + }); + + it('returns cmcd v2 data in header if version is 2', async () => { + playerInterface.isLive = () => true; + cmcdManager = createCmcdManager({ + version: 2, + includeKeys: ['ltc', 'msd'], + useHeaders: true, + }); + networkingEngine = createNetworkingEngine(cmcdManager); + + // Trigger Play and Playing events + cmcdManager.onPlaybackPlay_(); + cmcdManager.onPlaybackPlaying_(); + const request = NetworkingEngine.makeRequest([uri], retry); + await networkingEngine.request(RequestType.MANIFEST, request, + {type: AdvancedRequestType.MPD}); + expect(request.headers['CMCD-Request']).toContain('ltc'); + expect(request.headers['CMCD-Session']).toContain('msd'); + }); + + it('doesnt return cmcd v2 data in headers if version is not 2', + async () => { + playerInterface.isLive = () => true; + cmcdManager = createCmcdManager({ + version: 1, + includeKeys: ['ltc', 'msd'], + useHeaders: true, + }); + networkingEngine = createNetworkingEngine(cmcdManager); + cmcdManager.onPlaybackPlay_(); + cmcdManager.onPlaybackPlaying_(); + const request = NetworkingEngine.makeRequest([uri], retry); + await networkingEngine.request(RequestType.MANIFEST, request, + {type: AdvancedRequestType.MPD}); + expect(request.headers['CMCD-Request']).not.toContain('ltc'); + expect(request.headers['CMCD-Session']).not.toContain('msd'); + }); }); }); });