From c59922bae593b780dac2a0cdcba41691567b66a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Velad=20Galv=C3=A1n?= Date: Wed, 17 Jul 2024 11:00:43 +0200 Subject: [PATCH] feat(Ads): Add basic VAST support without IMA (#7052) This only includes playback, no tracking is sent. --- externs/shaka/ads.js | 10 ++++ lib/ads/ad_manager.js | 14 +++++ lib/ads/ad_utils.js | 84 +++++++++++++++++++++++++++++- lib/ads/interstitial_ad.js | 11 +++- lib/ads/interstitial_ad_manager.js | 76 +++++++++++++++++++++------ lib/ads/media_tailor_ad.js | 40 +------------- lib/text/cue.js | 27 +--------- lib/text/sbv_text_parser.js | 33 +----------- lib/text/vtt_text_parser.js | 7 ++- lib/util/error.js | 5 ++ lib/util/tXml.js | 3 +- lib/util/text_parser.js | 46 ++++++++++++++++ test/test/util/fake_ad_manager.js | 3 ++ 13 files changed, 240 insertions(+), 119 deletions(-) diff --git a/externs/shaka/ads.js b/externs/shaka/ads.js index feb8dbb2f2..cd31134311 100644 --- a/externs/shaka/ads.js +++ b/externs/shaka/ads.js @@ -64,6 +64,7 @@ shaka.extern.AdCuePoint; * endTime: ?number, * uri: string, * isSkippable: boolean, + * skipOffset: ?number, * canJump: boolean, * resumeOffset: ?number, * playoutLimit: ?number, @@ -87,6 +88,9 @@ shaka.extern.AdCuePoint; * ShakaPlayer supports (either in MSE or src=) * @property {boolean} isSkippable * Indicate if the interstitial is skippable. + * @property {?number} skipOffset + * Time value that identifies when skip controls are made available to the + * end user. * @property {boolean} canJump * Indicate if the interstitial is jumpable. * @property {?number} resumeOffset @@ -250,6 +254,12 @@ shaka.extern.IAdManager = class extends EventTarget { * @param {shaka.extern.AdInterstitial} interstitial */ addCustomInterstitial(interstitial) {} + + /** + * @param {string} url + * @return {!Promise} + */ + addAdUrlInterstitial(url) {} }; diff --git a/lib/ads/ad_manager.js b/lib/ads/ad_manager.js index 8e43937c50..523404f490 100644 --- a/lib/ads/ad_manager.js +++ b/lib/ads/ad_manager.js @@ -846,6 +846,20 @@ shaka.ads.AdManager = class extends shaka.util.FakeEventTarget { this.interstitialAdManager_.addInterstitials([interstitial]); } + /** + * @override + * @export + */ + addAdUrlInterstitial(url) { + if (!this.interstitialAdManager_) { + throw new shaka.util.Error( + shaka.util.Error.Severity.RECOVERABLE, + shaka.util.Error.Category.ADS, + shaka.util.Error.Code.INTERSTITIAL_AD_MANAGER_NOT_INITIALIZED); + } + return this.interstitialAdManager_.addAdUrlInterstitial(url); + } + /** * @param {!shaka.util.FakeEvent} event * @private diff --git a/lib/ads/ad_utils.js b/lib/ads/ad_utils.js index 391fbb3a6f..a787ea1d9d 100644 --- a/lib/ads/ad_utils.js +++ b/lib/ads/ad_utils.js @@ -7,13 +7,95 @@ goog.provide('shaka.ads.Utils'); +goog.require('shaka.util.TextParser'); +goog.require('shaka.util.TXml'); + /** * A class responsible for ad utils. * @export */ shaka.ads.Utils = class { - + /** + * @param {!shaka.extern.xml.Node} vast + * @param {?number} currentTime + * @return {!Array.} + */ + static parseVastToInterstitials(vast, currentTime) { + const TXml = shaka.util.TXml; + /** @type {!Array.} */ + const interstitials = []; + + let startTime = 0; + if (currentTime != null) { + startTime = currentTime; + } + + for (const ad of TXml.findChildren(vast, 'Ad')) { + const inline = TXml.findChild(ad, 'InLine'); + if (!inline) { + continue; + } + const creatives = TXml.findChild(inline, 'Creatives'); + if (!creatives) { + continue; + } + for (const creative of TXml.findChildren(creatives, 'Creative')) { + const linear = TXml.findChild(creative, 'Linear'); + if (!linear) { + continue; + } + let skipOffset = null; + if (linear.attributes['skipoffset']) { + skipOffset = shaka.util.TextParser.parseTime( + linear.attributes['skipoffset']); + if (isNaN(skipOffset)) { + skipOffset = null; + } + } + const mediaFiles = TXml.findChild(linear, 'MediaFiles'); + if (!mediaFiles) { + continue; + } + const medias = TXml.findChildren(mediaFiles, 'MediaFile'); + let checkMedias = medias; + const streamingMedias = medias.filter((media) => { + return media.attributes['delivery'] == 'streaming'; + }); + if (streamingMedias.length) { + checkMedias = streamingMedias; + } + const sortedMedias = checkMedias.sort((a, b) => { + const aHeight = parseInt(a.attributes['height'], 10) || 0; + const bHeight = parseInt(b.attributes['height'], 10) || 0; + return bHeight - aHeight; + }); + for (const media of sortedMedias) { + const adUrl = TXml.getTextContents(media); + if (!adUrl) { + continue; + } + interstitials.push({ + id: null, + startTime: startTime, + endTime: null, + uri: adUrl, + isSkippable: skipOffset != null, + skipOffset, + canJump: false, + resumeOffset: 0, + playoutLimit: null, + once: true, + pre: currentTime == null, + post: currentTime == Infinity, + timelineRange: false, + }); + break; + } + } + } + return interstitials; + } }; /** diff --git a/lib/ads/interstitial_ad.js b/lib/ads/interstitial_ad.js index 9ab5efbef2..42860c4a01 100755 --- a/lib/ads/interstitial_ad.js +++ b/lib/ads/interstitial_ad.js @@ -15,17 +15,22 @@ shaka.ads.InterstitialAd = class { /** * @param {HTMLMediaElement} video * @param {boolean} isSkippable + * @param {?number} skipOffset * @param {function()} onSkip * @param {number} sequenceLength * @param {number} adPosition */ - constructor(video, isSkippable, onSkip, sequenceLength, adPosition) { + constructor(video, isSkippable, skipOffset, onSkip, + sequenceLength, adPosition) { /** @private {HTMLMediaElement} */ this.video_ = video; /** @private {boolean} */ this.isSkippable_ = isSkippable; + /** @private {?number} */ + this.skipOffset_ = skipOffset; + /** @private {function()} */ this.onSkip_ = onSkip; @@ -91,7 +96,9 @@ shaka.ads.InterstitialAd = class { */ getTimeUntilSkippable() { if (this.isSkippable_) { - return 0; + const canSkipIn = + this.getRemainingTime() + this.skipOffset_ - this.getDuration(); + return Math.max(canSkipIn, 0); } return Math.max(this.getRemainingTime(), 0); } diff --git a/lib/ads/interstitial_ad_manager.js b/lib/ads/interstitial_ad_manager.js index 21e4232b3e..b589e384ab 100644 --- a/lib/ads/interstitial_ad_manager.js +++ b/lib/ads/interstitial_ad_manager.js @@ -15,6 +15,7 @@ goog.require('shaka.log'); goog.require('shaka.media.PreloadManager'); goog.require('shaka.net.NetworkingEngine'); goog.require('shaka.util.Dom'); +goog.require('shaka.util.Error'); goog.require('shaka.util.EventManager'); goog.require('shaka.util.FakeEvent'); goog.require('shaka.util.IReleasable'); @@ -22,6 +23,7 @@ goog.require('shaka.util.Platform'); goog.require('shaka.util.PublicPromise'); goog.require('shaka.util.StringUtils'); goog.require('shaka.util.Timer'); +goog.require('shaka.util.TXml'); /** @@ -223,6 +225,32 @@ shaka.ads.InterstitialAdManager = class { } } + /** + * @param {string} url + * @return {!Promise} + */ + async addAdUrlInterstitial(url) { + const type = shaka.net.NetworkingEngine.RequestType.ADS; + const request = shaka.net.NetworkingEngine.makeRequest( + [url], + shaka.net.NetworkingEngine.defaultRetryParameters()); + const op = this.basePlayer_.getNetworkingEngine().request(type, request); + const response = await op.promise; + const data = shaka.util.TXml.parseXml(response.data, 'VAST,vmap:VMAP'); + if (!data) { + throw new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.ADS, + shaka.util.Error.Code.VAST_INVALID_XML); + } + let interstitials = []; + if (data.tagName == 'VAST') { + interstitials = shaka.ads.Utils.parseVastToInterstitials( + data, this.lastTime_); + } + this.addInterstitials(interstitials); + } + /** * @param {!Array.} interstitials @@ -488,7 +516,8 @@ shaka.ads.InterstitialAdManager = class { }; const ad = new shaka.ads.InterstitialAd(this.video_, - interstitial.isSkippable, onSkip, sequenceLength, adPosition); + interstitial.isSkippable, interstitial.skipOffset, + onSkip, sequenceLength, adPosition); if (!this.usingBaseVideo_) { ad.setMuted(this.baseVideo_.muted); ad.setVolume(this.baseVideo_.volume); @@ -502,21 +531,36 @@ shaka.ads.InterstitialAdManager = class { this.onEvent_(new shaka.util.FakeEvent( shaka.ads.Utils.AD_SKIP_STATE_CHANGED)); } + const eventsSent = new Set(); this.adEventManager_.listenOnce(this.player_, 'error', error); - this.adEventManager_.listenOnce(this.player_, 'firstquartile', () => { - updateBaseVideoTime(); - this.onEvent_( - new shaka.util.FakeEvent(shaka.ads.Utils.AD_FIRST_QUARTILE)); - }); - this.adEventManager_.listenOnce(this.player_, 'midpoint', () => { - updateBaseVideoTime(); - this.onEvent_( - new shaka.util.FakeEvent(shaka.ads.Utils.AD_MIDPOINT)); - }); - this.adEventManager_.listenOnce(this.player_, 'thirdquartile', () => { - updateBaseVideoTime(); - this.onEvent_( - new shaka.util.FakeEvent(shaka.ads.Utils.AD_THIRD_QUARTILE)); + this.adEventManager_.listen(this.video_, 'timeupdate', () => { + const duration = this.video_.duration; + if (!duration) { + return; + } + if (interstitial.isSkippable && interstitial.skipOffset && + ad.canSkipNow() && ad.getRemainingTime() > 0 && + ad.getDuration() > 0) { + this.onEvent_( + new shaka.util.FakeEvent(shaka.ads.Utils.AD_SKIP_STATE_CHANGED)); + } + const currentPercent = 100 * this.video_.currentTime / duration; + if (currentPercent >= 25 && !eventsSent.has('firstquartile')) { + updateBaseVideoTime(); + this.onEvent_( + new shaka.util.FakeEvent(shaka.ads.Utils.AD_FIRST_QUARTILE)); + eventsSent.add('firstquartile'); + } else if (currentPercent >= 50 && !eventsSent.has('midpoint')) { + updateBaseVideoTime(); + this.onEvent_( + new shaka.util.FakeEvent(shaka.ads.Utils.AD_MIDPOINT)); + eventsSent.add('midpoint'); + } else if (currentPercent >= 75 && !eventsSent.has('thirdquartile')) { + updateBaseVideoTime(); + this.onEvent_( + new shaka.util.FakeEvent(shaka.ads.Utils.AD_THIRD_QUARTILE)); + eventsSent.add('thirdquartile'); + } }); this.adEventManager_.listenOnce(this.player_, 'complete', complete); this.adEventManager_.listen(this.video_, 'play', () => { @@ -647,6 +691,7 @@ shaka.ads.InterstitialAdManager = class { endTime: hlsInterstitial.endTime, uri, isSkippable, + skipOffset: isSkippable ? 0 : null, canJump, resumeOffset, playoutLimit, @@ -679,6 +724,7 @@ shaka.ads.InterstitialAdManager = class { endTime: hlsInterstitial.endTime, uri: asset['URI'], isSkippable, + skipOffset: isSkippable ? 0 : null, canJump, resumeOffset, playoutLimit, diff --git a/lib/ads/media_tailor_ad.js b/lib/ads/media_tailor_ad.js index d34ccd65e0..b414d2ec6b 100755 --- a/lib/ads/media_tailor_ad.js +++ b/lib/ads/media_tailor_ad.js @@ -25,7 +25,7 @@ shaka.ads.MediaTailorAd = class { this.ad_ = mediaTailorAd; /** @private {?number} */ - this.skipOffset_ = this.parseTime_(this.ad_.skipOffset); + this.skipOffset_ = shaka.util.TextParser.parseTime(this.ad_.skipOffset); /** @private {HTMLMediaElement} */ this.video_ = video; @@ -311,42 +311,4 @@ shaka.ads.MediaTailorAd = class { isSkipped() { return this.isSkipped_; } - - /** - * Parses a time from string. - * - * @param {?string} time - * @return {?number} - * @private - */ - parseTime_(time) { - if (!time) { - return null; - } - const parser = new shaka.util.TextParser(time); - const results = parser.readRegex(shaka.ads.MediaTailorAd.timeFormat_); - if (results == null) { - return null; - } - // This capture is optional, but will still be in the array as undefined, - // in which case it is 0. - const hours = Number(results[1]) || 0; - const minutes = Number(results[2]); - const seconds = Number(results[3]); - const milliseconds = Number(results[4]) || 0; - if (minutes > 59 || seconds > 59) { - return null; - } - - return (milliseconds / 1000) + seconds + (minutes * 60) + (hours * 3600); - } }; - -/** - * @const - * @private {!RegExp} - * @example 00:00.000 or 00:00:00.000 or 0:00:00.000 or - * 00:00.00 or 00:00:00.00 or 0:00:00.00 or 00:00:00 - */ -shaka.ads.MediaTailorAd.timeFormat_ = - /(?:(\d{1,}):)?(\d{2}):(\d{2})((\.(\d{1,3})))?/g; diff --git a/lib/text/cue.js b/lib/text/cue.js index 4eba6ec510..4218b07258 100644 --- a/lib/text/cue.js +++ b/lib/text/cue.js @@ -681,8 +681,7 @@ shaka.text.Cue = class { if (!time) { break; } - const parser = new shaka.util.TextParser(time); - const cueTime = shaka.text.Cue.parseTime(parser); + const cueTime = shaka.util.TextParser.parseTime(time); if (cueTime) { nestedCue.startTime = cueTime; } @@ -728,30 +727,6 @@ shaka.text.Cue = class { } } - /** - * Parses time from the given parser. - * - * @param {!shaka.util.TextParser} parser - * @return {?number} - */ - static parseTime(parser) { - const results = parser.readRegex(shaka.text.Cue.timeFormat_); - if (results == null) { - return null; - } - // This capture is optional, but will still be in the array as undefined, - // in which case it is 0. - const hours = Number(results[1]) || 0; - const minutes = Number(results[2]); - const seconds = Number(results[3]); - const milliseconds = Number(results[4]); - if (minutes > 59 || seconds > 59) { - return null; - } - - return (milliseconds / 1000) + seconds + (minutes * 60) + (hours * 3600); - } - /** * Merges values created in parseStyle_ * @param {!shaka.text.Cue} cue diff --git a/lib/text/sbv_text_parser.js b/lib/text/sbv_text_parser.js index fc7a75e375..a05e7b1d27 100644 --- a/lib/text/sbv_text_parser.js +++ b/lib/text/sbv_text_parser.js @@ -48,7 +48,6 @@ shaka.text.SbvTextParser = class { * @export */ parseMedia(data, time) { - const SbvTextParser = shaka.text.SbvTextParser; const StringUtils = shaka.util.StringUtils; // Get the input as a string. @@ -72,9 +71,9 @@ shaka.text.SbvTextParser = class { const lines = block.split('\n'); // Parse the times. const parser = new shaka.util.TextParser(lines[0]); - const start = SbvTextParser.parseTime_(parser); + const start = parser.parseTime(); const expect = parser.readRegex(/,/g); - const end = SbvTextParser.parseTime_(parser); + const end = parser.parseTime(); if (start == null || expect == null || end == null) { throw new shaka.util.Error( @@ -93,34 +92,6 @@ shaka.text.SbvTextParser = class { return cues; } - - /** - * Parses a SubViewer time from the given parser. - * - * @param {!shaka.util.TextParser} parser - * @return {?number} - * @private - */ - static parseTime_(parser) { - // 00:00.000 or 00:00:00.000 or 0:00:00.000 or - // 00:00.00 or 00:00:00.00 or 0:00:00.00 - const regexExpresion = /(?:(\d{1,}):)?(\d{2}):(\d{2})\.(\d{2,3})/g; - const results = parser.readRegex(regexExpresion); - if (results == null) { - return null; - } - // This capture is optional, but will still be in the array as undefined, - // in which case it is 0. - const hours = Number(results[1]) || 0; - const minutes = Number(results[2]); - const seconds = Number(results[3]); - const milliseconds = Number(results[4]); - if (minutes > 59 || seconds > 59) { - return null; - } - - return (milliseconds / 1000) + seconds + (minutes * 60) + (hours * 3600); - } }; diff --git a/lib/text/vtt_text_parser.js b/lib/text/vtt_text_parser.js index ddbc8fa02d..c7daa6f6de 100644 --- a/lib/text/vtt_text_parser.js +++ b/lib/text/vtt_text_parser.js @@ -150,8 +150,7 @@ shaka.text.VttTextParser = class { shaka.util.Error.Code.INVALID_TEXT_HEADER); } - const parser = new shaka.util.TextParser(cueTimeMatch[1]); - const cueTime = shaka.text.Cue.parseTime(parser); + const cueTime = shaka.util.TextParser.parseTime(cueTimeMatch[1]); if (cueTime == null) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, @@ -379,9 +378,9 @@ shaka.text.VttTextParser = class { // Parse the times. const parser = new shaka.util.TextParser(text[0]); - let start = shaka.text.Cue.parseTime(parser); + let start = parser.parseTime(); const expect = parser.readRegex(/[ \t]+-->[ \t]+/g); - let end = shaka.text.Cue.parseTime(parser); + let end = parser.parseTime(); if (start == null || expect == null || end == null) { shaka.log.alwaysWarn( diff --git a/lib/util/error.js b/lib/util/error.js index ceb558544a..b2153c9665 100644 --- a/lib/util/error.js +++ b/lib/util/error.js @@ -1154,4 +1154,9 @@ shaka.util.Error.Code = { * Ad Insertion. Call adManager.initInterstitial() to do it. */ 'INTERSTITIAL_AD_MANAGER_NOT_INITIALIZED': 10006, + + /** + * The VAST contained invalid XML markup. + */ + 'VAST_INVALID_XML': 4001, }; diff --git a/lib/util/tXml.js b/lib/util/tXml.js index 2d5e01e427..cd9aa5bb21 100644 --- a/lib/util/tXml.js +++ b/lib/util/tXml.js @@ -61,7 +61,8 @@ shaka.util.TXml = class { if (!expectedRootElemName && result.length) { return result[0]; } - const rootNode = result.find((n) => n.tagName === expectedRootElemName); + const rootNode = result.find((n) => + expectedRootElemName.split(',').includes(n.tagName)); if (rootNode) { return rootNode; } diff --git a/lib/util/text_parser.js b/lib/util/text_parser.js index 880ca7daea..c48d7c5d18 100644 --- a/lib/util/text_parser.js +++ b/lib/util/text_parser.js @@ -131,4 +131,50 @@ shaka.util.TextParser = class { }; } } + + /** + * Parses a time. + * + * @return {?number} + */ + parseTime() { + const results = this.readRegex(shaka.util.TextParser.timeFormat_); + if (results == null) { + return null; + } + // This capture is optional, but will still be in the array as undefined, + // in which case it is 0. + const hours = Number(results[1]) || 0; + const minutes = Number(results[2]); + const seconds = Number(results[3]); + const milliseconds = Number(results[6]) || 0; + if (minutes > 59 || seconds > 59) { + return null; + } + + return (milliseconds / 1000) + seconds + (minutes * 60) + (hours * 3600); + } + + /** + * Parses a time from string. + * + * @param {?string} time + * @return {?number} + */ + static parseTime(time) { + if (!time) { + return null; + } + const parser = new shaka.util.TextParser(time); + return parser.parseTime(); + } }; + +/** + * @const + * @private {!RegExp} + * @example 00:00.000 or 00:00:00.000 or 0:00:00.000 or + * 00:00.00 or 00:00:00.00 or 0:00:00.00 or 00:00:00 + */ +shaka.util.TextParser.timeFormat_ = + /(?:(\d{1,}):)?(\d{2}):(\d{2})((\.(\d{1,3})))?/g; diff --git a/test/test/util/fake_ad_manager.js b/test/test/util/fake_ad_manager.js index 8f3bf11d45..b51e576a40 100644 --- a/test/test/util/fake_ad_manager.js +++ b/test/test/util/fake_ad_manager.js @@ -97,6 +97,9 @@ shaka.test.FakeAdManager = class extends shaka.util.FakeEventTarget { /** @override */ addCustomInterstitial(interstitial) {} + /** @override */ + addAdUrlInterstitial(url) {} + /** * @param {!shaka.test.FakeAd} ad */