UNPKG

shaka-player

Version:
704 lines (668 loc) • 23.4 kB
/*! @license * Shaka Player * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ goog.provide('shaka.ads.HlsInterstitialParser'); goog.require('shaka.ads.SvtaInterstitialParser'); goog.require('shaka.ads.Utils'); goog.require('shaka.net.NetworkingEngine'); goog.require('shaka.util.NumberUtils'); goog.require('shaka.util.URL'); /** * Interprets HLS interstitial metadata without playing media. The static * methods are pure; an instance holds the state of the current asset (the * playback session ID and the Date Ranges already seen). */ shaka.ads.HlsInterstitialParser = class { constructor() { /** @private {?string} */ this.sessionId_ = null; /** @private {!Set<string>} */ this.metadataIds_ = new Set(); } /** * Converts a metadata frame into what the interstitial manager must * schedule or update. Insertion (HLS interstitials) and measurement (SVTA) * can be enabled independently. * * @param {shaka.extern.HLSMetadata} metadata * @param {boolean} isLive * @param {!shaka.net.NetworkingEngine} networkingEngine * @param {boolean} insertion Whether HLS interstitials are enabled. * @param {boolean} measurement Whether SVTA signaling is enabled. * @return {!shaka.ads.HlsInterstitialParser.Result} */ parse(metadata, isLive, networkingEngine, insertion, measurement) { const Parser = shaka.ads.HlsInterstitialParser; const SvtaParser = shaka.ads.SvtaInterstitialParser; /** @type {!shaka.ads.HlsInterstitialParser.Result} */ const result = { interstitials: [], deferred: [], playoutLimitUpdate: null, preloadHint: null, }; const svta = measurement && SvtaParser.isValidMetadata(metadata); const parseSvta = () => { const parsed = SvtaParser.parseMetadata(metadata, networkingEngine); result.interstitials = parsed.interstitials; result.deferred = parsed.deferred; return result; }; if (metadata.type == 'com.apple.hls.preload') { // RFC 8216bis Appendix F: the target's resources may be resolved from // the start of this Date Range on. const targetId = Parser.getMetadataValue(metadata, 'X-TARGET-ID'); if (insertion && targetId) { result.preloadHint = {targetId, startTime: metadata.startTime}; } return result; } if (!insertion || !Parser.isInterstitialMetadata(metadata)) { // Embedded measurement is independent of disabling insertions. return svta ? parseSvta() : result; } const id = Parser.getMetadataValue(metadata, 'ID'); // A subsequent EXT-X-DATERANGE with the same ID augments the existing // Date Range with additional attributes (RFC 8216bis Section 4.4.5.1). const limit = parseFloat( Parser.getMetadataValue(metadata, 'X-PLAYOUT-LIMIT') || ''); if (id && Number.isFinite(limit)) { result.playoutLimitUpdate = {id, playoutLimit: limit}; } if (id && this.metadataIds_.has(id)) { return result; } const parsed = Parser.parseMetadata(metadata, isLive); if (!parsed.interstitials.length && !parsed.assetList) { // Native HLS groups date-range attributes under its legacy metadata // type, including SVTA slots already embedded in the stream. return svta ? parseSvta() : result; } if (id) { this.metadataIds_.add(id); } if (!this.sessionId_) { this.sessionId_ = window.crypto.randomUUID(); } const sessionId = this.sessionId_; const signaling = svta ? SvtaParser.decodeMetadata(metadata) : null; for (const interstitial of parsed.interstitials) { interstitial.uri = Parser.getUriWithParams( interstitial.uri || '', sessionId); SvtaParser.decorateInterstitial(interstitial, signaling); } result.interstitials = parsed.interstitials; if (parsed.assetList) { result.deferred.push(Parser.createDeferredInterstitial( parsed.assetList, networkingEngine, sessionId)); } return result; } /** * @param {shaka.extern.HLSMetadata} metadata * @return {boolean} */ static isInterstitialMetadata(metadata) { const Parser = shaka.ads.HlsInterstitialParser; const hasAsset = Parser.getMetadataValue(metadata, 'X-ASSET-URI') != null || Parser.getMetadataValue(metadata, 'X-ASSET-LIST') != null; return metadata.type == 'com.apple.hls.interstitial' || metadata.type == 'com.apple.quicktime.HLS' || (hasAsset && shaka.ads.SvtaInterstitialParser.isValidMetadata(metadata)); } /** * @param {shaka.extern.HLSMetadata} hlsMetadata * @param {boolean} isLive * @return {!shaka.ads.HlsInterstitialParser.ParsedMetadata} */ static parseMetadata(hlsMetadata, isLive) { const Parser = shaka.ads.HlsInterstitialParser; const NumberUtils = shaka.util.NumberUtils; const interstitialsAd = []; const result = {interstitials: interstitialsAd, assetList: null}; if (Parser.getMetadataValue(hlsMetadata, 'X-OVERLAY-ID') != null) { result.interstitials = Parser.parseOverlay_(hlsMetadata); return result; } if (!hlsMetadata) { return result; } const assetUri = Parser.getMetadataValue(hlsMetadata, 'X-ASSET-URI'); const assetList = Parser.getMetadataValue(hlsMetadata, 'X-ASSET-LIST'); if (!assetUri && !assetList) { return result; } const id = Parser.getMetadataValue(hlsMetadata, 'ID'); const {startTime, endTime} = Parser.getInterstitialTimes_(hlsMetadata, id); const restrict = Parser.getMetadataValue(hlsMetadata, 'X-RESTRICT'); let isSkippable = true; let canJump = true; if (restrict != null) { isSkippable = !restrict.includes('SKIP'); canJump = !restrict.includes('JUMP'); } let skipOffset = isSkippable ? 0 : null; const skipControlOffset = Parser.getMetadataValue(hlsMetadata, 'X-SKIP-CONTROL-OFFSET'); if (skipControlOffset != null) { skipOffset = parseFloat(skipControlOffset); if (isNaN(skipOffset)) { skipOffset = isSkippable ? 0 : null; } } let skipFor = null; const skipControlDuration = Parser.getMetadataValue(hlsMetadata, 'X-SKIP-CONTROL-DURATION'); if (skipControlDuration != null) { skipFor = parseFloat(skipControlDuration); if (isNaN(skipFor)) { skipFor = null; } } let resumeOffset = null; const resume = Parser.getMetadataValue(hlsMetadata, 'X-RESUME-OFFSET'); if (resume != null) { resumeOffset = parseFloat(resume); if (isNaN(resumeOffset)) { resumeOffset = null; } } if (resumeOffset != null && resumeOffset != 0 && endTime && endTime != Infinity && NumberUtils.isFloatEqual(startTime + resumeOffset, endTime)) { resumeOffset = null; } let playoutLimit = null; const playout = Parser.getMetadataValue(hlsMetadata, 'X-PLAYOUT-LIMIT'); if (playout != null) { playoutLimit = parseFloat(playout); if (isNaN(playoutLimit)) { playoutLimit = null; } } const {once, pre, post} = Parser.parseCue_(hlsMetadata); let timelineRange = false; const timelineOccupies = Parser.getMetadataValue(hlsMetadata, 'X-TIMELINE-OCCUPIES'); if (timelineOccupies != null) { timelineRange = timelineOccupies.includes('RANGE'); } else if (resume == null && isLive) { timelineRange = !pre && !post; } if (assetUri != null) { if (!assetUri) { return result; } interstitialsAd.push(shaka.ads.Utils.createInterstitial({ id, startTime, endTime, uri: assetUri, isSkippable, skipOffset, skipFor, canJump, resumeOffset, playoutLimit, once, pre, post, timelineRange, })); } else if (assetList != null) { if (!assetList) { return result; } /** @type {shaka.ads.HlsInterstitialParser.AssetListDescriptor} */ const descriptor = { id, groupId: null, startTime, endTime, assetListUri: assetList, isSkippable, skipOffset, skipFor, canJump, resumeOffset, playoutLimit, once, pre, post, timelineRange, resolving: false, resolved: false, }; result.assetList = descriptor; } return result; } /** * @param {shaka.extern.HLSMetadata} hlsMetadata * @return {!Array<shaka.extern.AdInterstitial>} * @private */ static parseOverlay_(hlsMetadata) { const Parser = shaka.ads.HlsInterstitialParser; const interstitialsAd = []; if (!hlsMetadata) { return interstitialsAd; } const uri = Parser.getMetadataValue(hlsMetadata, 'X-ASSET-URI'); if (!uri) { return interstitialsAd; } const id = Parser.getMetadataValue(hlsMetadata, 'X-OVERLAY-ID'); const {startTime, endTime} = Parser.getInterstitialTimes_(hlsMetadata, id); const {once, pre, post} = Parser.parseCue_(hlsMetadata); const mimeType = Parser.getMetadataValue(hlsMetadata, 'X-ASSET-MIMETYPE'); const loop = Parser.getMetadataValue(hlsMetadata, 'X-LOOP') == 'YES'; let z = 1; const depth = Parser.getMetadataValue(hlsMetadata, 'X-DEPTH'); if (depth != null) { z = parseFloat(depth); if (isNaN(z)) { z = 1; } } const background = Parser.getMetadataValue(hlsMetadata, 'X-BACKGROUND'); const viewport = { x: 1920, y: 1080, }; const viewportValue = Parser.getMetadataValue(hlsMetadata, 'X-VIEWPORT'); if (viewportValue != null) { const size = viewportValue.split('x'); if (size.length != 2) { return interstitialsAd; } viewport.x = parseFloat(size[0]); viewport.y = parseFloat(size[1]); } /** @type {!shaka.extern.AdPositionInfo} */ const overlay = { viewport: { x: viewport.x, y: viewport.y, }, topLeft: { x: 0, y: 0, }, size: { x: viewport.x, y: viewport.y, }, }; const overlayPosition = Parser.getMetadataValue(hlsMetadata, 'X-OVERLAY-POSITION'); if (overlayPosition != null) { const position = overlayPosition.split('x'); if (position.length != 2) { return interstitialsAd; } overlay.topLeft.x = parseFloat(position[0]); overlay.topLeft.y = parseFloat(position[1]); } const overlaySize = Parser.getMetadataValue(hlsMetadata, 'X-OVERLAY-SIZE'); if (overlaySize != null) { const size = overlaySize.split('x'); if (size.length != 2) { return interstitialsAd; } overlay.size.x = parseFloat(size[0]); overlay.size.y = parseFloat(size[1]); } /** @type {?shaka.extern.AdPositionInfo} */ let currentVideo = null; const squeezeCurrent = Parser.getMetadataValue(hlsMetadata, 'X-SQUEEZECURRENT'); if (squeezeCurrent != null) { let percentage = parseFloat(squeezeCurrent); if (isNaN(percentage)) { percentage = 1; } currentVideo = { viewport: { x: 1920, y: 1080, }, topLeft: { x: 0, y: 0, }, size: { x: 1920 * percentage, y: 1080 * percentage, }, }; const squeezeCurrentPosition = Parser.getMetadataValue(hlsMetadata, 'X-SQUEEZECURRENT-POSITION'); if (squeezeCurrentPosition != null) { const position = squeezeCurrentPosition.split('x'); if (position.length != 2) { return interstitialsAd; } currentVideo.topLeft.x = parseFloat(position[0]); currentVideo.topLeft.y = parseFloat(position[1]); } } interstitialsAd.push(shaka.ads.Utils.createInterstitial({ id, startTime, endTime, uri, mimeType, once, pre, post, timelineRange: true, loop, overlay, displayOnBackground: z == -1, currentVideo, background, })); return interstitialsAd; } /** * Parses a fetched asset list without performing requests. URI resolution * uses the response URI, including redirects. Assets skipped on a live * start retain their original indexes and offsets. * * @param {!shaka.ads.HlsInterstitialParser.AssetsList} data * @param {!shaka.ads.HlsInterstitialParser.AssetListDescriptor} descriptor * @param {string} responseUri * @param {number=} startOffset * @return {!Array<shaka.extern.AdInterstitial>} */ static parseAssetList(data, descriptor, responseUri, startOffset = 0) { if (!data || !Array.isArray(data['ASSETS'])) { throw new Error('Invalid HLS asset list'); } const control = data['SKIP-CONTROL'] || {}; const skipOffset = Number.isFinite(control['OFFSET']) ? control['OFFSET'] : descriptor.skipOffset; const skipFor = Number.isFinite(control['DURATION']) ? control['DURATION'] : descriptor.skipFor; const SvtaParser = shaka.ads.SvtaInterstitialParser; const podEnvelope = SvtaParser.parseEnvelope( data[SvtaParser.KEY], 'pod'); const pod = podEnvelope ? /** @type {!shaka.extern.AdCreativeSignaling.Pod} */ (podEnvelope.payload[0]) : null; const slotEnvelopes = data['ASSETS'].map((asset) => asset && SvtaParser.parseEnvelope( asset[SvtaParser.KEY], 'slot')); const podSize = slotEnvelopes.filter((slot) => slot != null).length; const interstitials = []; let position = 0; let cumulativeDuration = 0; for (let i = 0; i < data['ASSETS'].length; i++) { const asset = data['ASSETS'][i]; if (slotEnvelopes[i]) { position++; } if (!asset || typeof asset['URI'] != 'string' || asset['DURATION'] < 0) { throw new Error('Invalid HLS asset description'); } const duration = parseFloat(asset['DURATION']) || 0; const assetOffset = cumulativeDuration; cumulativeDuration += duration; if (!asset['URI'] || (duration > 0 && cumulativeDuration <= startOffset)) { continue; } const interstitial = shaka.ads.Utils.createInterstitial({ id: descriptor.id + '_shaka_asset_' + i, groupId: descriptor.id, startTime: descriptor.startTime, endTime: descriptor.endTime, uri: shaka.util.URL.resolve(responseUri, asset['URI']), isSkippable: descriptor.isSkippable, skipOffset, skipFor, canJump: descriptor.canJump, resumeOffset: descriptor.resumeOffset, playoutLimit: descriptor.playoutLimit, once: descriptor.once, pre: descriptor.pre, post: descriptor.post, timelineRange: descriptor.timelineRange, }); SvtaParser.decorateInterstitial(interstitial, slotEnvelopes[i], pod); if (interstitial.adCreativeSignaling) { interstitial.podSize = podSize; interstitial.podPosition = position; } // Playback offsets are execution context, separate from the slot's // offset in its pod. The manager uses this to start within an asset. if (startOffset > assetOffset) { interstitial.startOffset = startOffset - assetOffset; } interstitials.push(interstitial); } return interstitials; } /** * Creates a deferred resolver for an HLS asset list. Parsing remains pure; * only invoking resolve performs the request. The common manager controls * when to invoke it and can abort it without knowing the resource format. * * @param {!shaka.ads.HlsInterstitialParser.AssetListDescriptor} descriptor * @param {!shaka.net.NetworkingEngine} networkingEngine * @param {string} sessionId * @return {!shaka.extern.DeferredInterstitial} */ static createDeferredInterstitial(descriptor, networkingEngine, sessionId) { const Parser = shaka.ads.HlsInterstitialParser; // The resource holds all the descriptor attributes, including updates // of its playout limit received while it is unresolved. return shaka.ads.Utils.createDeferredInterstitial(descriptor, networkingEngine, (offset) => Parser.getUriWithParams( descriptor.assetListUri, sessionId, offset), (data, uri, offset, resource) => { const interstitials = Parser.parseAssetList( /** @type {!shaka.ads.HlsInterstitialParser.AssetsList} */ (data), /** @type {?} */ (resource), uri, offset); for (const interstitial of interstitials) { interstitial.uri = Parser.getUriWithParams( interstitial.uri || '', sessionId); } return interstitials; }, { type: shaka.net.NetworkingEngine.AdvancedRequestType .INTERSTITIAL_ASSET_LIST, }); } /** * @param {string} uri * @param {string} sessionId * @param {number=} offset * @return {string} */ static getUriWithParams(uri, sessionId, offset = 0) { if (uri.startsWith('data:')) { return uri; } const params = new Map(); params.set('_HLS_primary_id', sessionId); if (offset > 0) { params.set('_HLS_start_offset', String(Math.round(offset * 1000) / 1000)); } return shaka.util.URL.appendParams(uri, params); } /** * Returns the string data of the HLS metadata frame with the given key, or * null if there is no such frame. * * @param {shaka.extern.HLSMetadata} hlsMetadata * @param {string} key * @return {?string} */ static getMetadataValue(hlsMetadata, key) { const frame = hlsMetadata.values.find((v) => v.key == key); return frame ? /** @type {string} */ (frame.data) : null; } /** * Computes the start/end times of an interstitial from its HLS metadata. When * the Date Range has no ID, the times are floored to a tenth of a second. * * @param {shaka.extern.HLSMetadata} hlsMetadata * @param {?string} id * @return {{startTime: number, endTime: ?number}} * @private */ static getInterstitialTimes_(hlsMetadata, id) { const startTime = id == null ? Math.floor(hlsMetadata.startTime * 10) / 10 : hlsMetadata.startTime; let endTime = hlsMetadata.endTime; if (hlsMetadata.endTime && hlsMetadata.endTime != Infinity && typeof(hlsMetadata.endTime) == 'number') { endTime = id == null ? Math.floor(hlsMetadata.endTime * 10) / 10 : hlsMetadata.endTime; } return {startTime, endTime}; } /** * Parses the CUE attribute (ONCE/PRE/POST) from HLS metadata. X-CUE was * the provisional name and is supported as a fallback for compatibility. * * @param {shaka.extern.HLSMetadata} hlsMetadata * @return {{once: boolean, pre: boolean, post: boolean}} * @private */ static parseCue_(hlsMetadata) { const Parser = shaka.ads.HlsInterstitialParser; const cue = Parser.getMetadataValue(hlsMetadata, 'CUE') || Parser.getMetadataValue(hlsMetadata, 'X-CUE'); return { once: cue != null && cue.includes('ONCE'), pre: cue != null && cue.includes('PRE'), post: cue != null && cue.includes('POST'), }; } }; /** * @typedef {{ * interstitials: !Array<shaka.extern.AdInterstitial>, * assetList: ?shaka.ads.HlsInterstitialParser.AssetListDescriptor, * }} */ shaka.ads.HlsInterstitialParser.ParsedMetadata; /** * @typedef {{ * interstitials: !Array<shaka.extern.AdInterstitial>, * deferred: !Array<!shaka.extern.DeferredInterstitial>, * playoutLimitUpdate: ?{id: string, playoutLimit: number}, * preloadHint: ?{targetId: string, startTime: number}, * }} * * @property {!Array<shaka.extern.AdInterstitial>} interstitials * Interstitials ready to be scheduled. * @property {!Array<!shaka.extern.DeferredInterstitial>} deferred * Resources (asset lists, remote pods) to be resolved before scheduling. * @property {?{id: string, playoutLimit: number}} playoutLimitUpdate * A playout limit for an interstitial or resource that may already exist. * @property {?{targetId: string, startTime: number}} preloadHint * The time from which the target's resources may be resolved. */ shaka.ads.HlsInterstitialParser.Result; /** * Holds the parsed metadata of an HLS X-ASSET-LIST interstitial whose * resolution has been deferred until playback approaches it. * * @typedef {{ * id: ?string, * groupId: ?string, * startTime: number, * endTime: ?number, * assetListUri: string, * isSkippable: boolean, * skipOffset: ?number, * skipFor: ?number, * canJump: boolean, * resumeOffset: ?number, * playoutLimit: ?number, * once: boolean, * pre: boolean, * post: boolean, * timelineRange: boolean, * resolutionTimeOffset: (number|undefined), * resolving: boolean, * resolved: boolean, * }} * * @property {?string} id * @property {?string} groupId * @property {number} startTime * @property {?number} endTime * @property {string} assetListUri * @property {boolean} isSkippable * @property {?number} skipOffset * @property {?number} skipFor * @property {boolean} canJump * @property {?number} resumeOffset * @property {?number} playoutLimit * @property {boolean} once * @property {boolean} pre * @property {boolean} post * @property {boolean} timelineRange * @property {(number|undefined)} resolutionTimeOffset * The offset in seconds before startTime at which the asset list may be * resolved. Undefined or 0 means use the interstitialPreloadAheadTime * default. * @property {boolean} resolving * Whether a deferred resolution request is already in flight. * @property {boolean} resolved * Whether the asset list has already been resolved. Retained (only for live * streams that allow starting mid-interstitial) so that seeking back into the * break can re-request the asset list with an updated _HLS_start_offset. */ shaka.ads.HlsInterstitialParser.AssetListDescriptor; /* eslint-disable @stylistic/max-len */ /** * @typedef {{ * ASSETS: !Array<shaka.ads.HlsInterstitialParser.Asset>, * SKIP-CONTROL: (shaka.ads.HlsInterstitialParser.SkipControl|undefined), * X-AD-CREATIVE-SIGNALING: (shaka.extern.AdCreativeSignaling.CarriageEnvelope|undefined), * }} * * @property {!Array<shaka.ads.HlsInterstitialParser.Asset>} ASSETS * @property {?shaka.ads.HlsInterstitialParser.SkipControl} SKIP-CONTROL * @property {?shaka.extern.AdCreativeSignaling.CarriageEnvelope} X-AD-CREATIVE-SIGNALING */ shaka.ads.HlsInterstitialParser.AssetsList; /* eslint-enable @stylistic/max-len */ /* eslint-disable @stylistic/max-len */ /** * @typedef {{ * URI: string, * DURATION: number, * X-AD-CREATIVE-SIGNALING: (shaka.extern.AdCreativeSignaling.CarriageEnvelope|undefined), * }} * * @property {string} URI * @property {number} DURATION * @property {?shaka.extern.AdCreativeSignaling.CarriageEnvelope} X-AD-CREATIVE-SIGNALING */ shaka.ads.HlsInterstitialParser.Asset; /* eslint-enable @stylistic/max-len */ /** * @typedef {{ * OFFSET: (number|undefined), * DURATION: (number|undefined), * }} * * @property {number} OFFSET * @property {number} DURATION */ shaka.ads.HlsInterstitialParser.SkipControl;