/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.provide('shaka.ads.SvtaInterstitialParser');
goog.require('shaka.ads.Utils');
goog.require('shaka.log');
goog.require('shaka.net.NetworkingEngine');
goog.require('shaka.util.StringUtils');
goog.require('shaka.util.TXml');
goog.require('shaka.util.Uint8ArrayUtils');
/** Parses SVTA2053 version 2 creative signaling, without loading resources. */
shaka.ads.SvtaInterstitialParser = class {
/**
* @param {shaka.extern.HLSMetadata} metadata
* @return {boolean}
*/
static isValidMetadata(metadata) {
return metadata.values.some((value) =>
value.key == shaka.ads.SvtaInterstitialParser.KEY);
}
/**
* @param {shaka.extern.TimelineRegionInfo} region
* @return {boolean}
*/
static isValidRegion(region) {
return !!region.eventNode &&
(region.schemeIdUri ==
'urn:svta:advertising-wg:ad-creative-signaling' ||
region.schemeIdUri == 'urn:svta:advertising-wg:ad-id-signaling');
}
/**
* @param {shaka.extern.HLSMetadata} metadata
* @param {?shaka.net.NetworkingEngine=} networkingEngine Used to resolve
* remote pod slots. Without it, pods with remote slots are ignored.
* @return {!shaka.ads.SvtaInterstitialParser.Result}
*/
static parseMetadata(metadata, networkingEngine = null) {
const id = metadata.values.find((value) => value.key == 'ID');
return shaka.ads.SvtaInterstitialParser.parseInterstitials(
shaka.ads.SvtaInterstitialParser.decodeMetadata(metadata),
metadata.startTime, id ? String(id.data) : 'hls_' + metadata.startTime,
networkingEngine);
}
/**
* @param {shaka.extern.HLSMetadata} metadata
* @return {*} The decoded signaling, or null if there is none.
*/
static decodeMetadata(metadata) {
const frame = metadata.values.find((value) =>
value.key == shaka.ads.SvtaInterstitialParser.KEY);
try {
return JSON.parse(shaka.util.StringUtils.fromUTF8(
shaka.util.Uint8ArrayUtils.fromBase64(
/** @type {string} */ (frame.data))));
} catch (error) {
shaka.log.warning('Invalid SVTA HLS signaling', error);
return null;
}
}
/**
* @param {shaka.extern.TimelineRegionInfo} region
* @param {?shaka.net.NetworkingEngine=} networkingEngine Used to resolve
* remote pod slots. Without it, pods with remote slots are ignored.
* @return {!shaka.ads.SvtaInterstitialParser.Result}
*/
static parseRegion(region, networkingEngine = null) {
const Parser = shaka.ads.SvtaInterstitialParser;
if (!region.eventNode || !Parser.isValidRegion(region)) {
return {interstitials: [], deferred: []};
}
try {
const envelope = JSON.parse(
shaka.util.TXml.getTextContents(region.eventNode) || 'null');
return Parser.parseInterstitials(envelope, region.startTime,
region.id || 'dash_' + region.startTime, networkingEngine);
} catch (error) {
shaka.log.warning('Invalid SVTA DASH signaling', error);
return {interstitials: [], deferred: []};
}
}
/**
* Validates an envelope before any of its payload is used. The version is
* not checked, so earlier signaling without version, envelope type, slot
* type or identifiers is accepted.
*
* @param {*} data
* @param {string=} expectedType
* @return {?shaka.extern.AdCreativeSignaling.CarriageEnvelope}
*/
static parseEnvelope(data, expectedType = '') {
const Parser = shaka.ads.SvtaInterstitialParser;
if (!data || typeof data != 'object' ||
!Array.isArray(data['payload']) || !data['payload'].length) {
return null;
}
// Without a type, the payload is what the caller expects (slots for
// carriage, the requested field for remote responses).
const type = data['type'] || expectedType || 'slot';
if (expectedType && type != expectedType) {
return null;
}
const features = data['features'] || {};
for (const key in features) {
if (features[key] && key != 'remoteFields') {
shaka.log.warning('Unsupported SVTA feature', key);
return null;
}
}
const remoteFields = !!features['remoteFields'];
const validators = {
'slot': (value) => Parser.isSlot_(value, remoteFields),
'pod': (value) => Parser.isPod_(value, remoteFields),
'trackingEvent': (value) => Parser.isTrackingEvent_(value),
};
const validate = validators[type];
if (!validate || !data['payload'].every(validate)) {
return null;
}
return /** @type {!shaka.extern.AdCreativeSignaling.CarriageEnvelope} */ (
data);
}
/**
* @param {*} data
* @param {number} anchor
* @param {string} id
* @param {?shaka.net.NetworkingEngine=} networkingEngine Used to resolve
* remote pod slots. Without it, pods with remote slots are ignored.
* @return {!shaka.ads.SvtaInterstitialParser.Result}
*/
static parseInterstitials(data, anchor, id, networkingEngine = null) {
const Parser = shaka.ads.SvtaInterstitialParser;
/** @type {!shaka.ads.SvtaInterstitialParser.Result} */
const result = {interstitials: [], deferred: []};
const envelope = Parser.parseEnvelope(data);
if (!envelope || !Number.isFinite(anchor)) {
return result;
}
const interstitials = result.interstitials;
const type = envelope.type || 'slot';
if (type == 'slot') {
const slots = /** @type {!Array<shaka.extern.AdCreativeSignaling.Slot>} */
(envelope.payload);
for (let i = 0; i < slots.length; i++) {
interstitials.push(Parser.makeInterstitial_(slots[i], null, anchor,
id + '_slot_' + i, id, slots.length, i + 1));
}
} else if (type == 'pod') {
const pods = /** @type {!Array<shaka.extern.AdCreativeSignaling.Pod>} */
(envelope.payload);
for (let i = 0; i < pods.length; i++) {
const pod = pods[i];
const podId = id + '_pod_' + i;
const start = anchor + (pod.start || 0);
if (pod.$remote && pod.$remote.slots) {
// A pod with remote slots is not playable until they are resolved,
// which the manager schedules like any other deferred resource.
if (networkingEngine) {
result.deferred.push(Parser.createDeferredPod_(
pod, start, podId, networkingEngine));
}
} else {
for (let j = 0; j < pod.slots.length; j++) {
interstitials.push(Parser.makeInterstitial_(pod.slots[j], pod,
start, podId + '_slot_' + j, podId, pod.slots.length, j + 1));
}
}
}
}
return result;
}
/**
* @param {!shaka.extern.AdCreativeSignaling.Pod} pod
* @param {number} startTime
* @param {string} id
* @param {!shaka.net.NetworkingEngine} networkingEngine
* @return {!shaka.extern.DeferredInterstitial}
* @private
*/
static createDeferredPod_(pod, startTime, id, networkingEngine) {
const Parser = shaka.ads.SvtaInterstitialParser;
const uri = /** @type {string} */ (pod.$remote.slots);
return shaka.ads.Utils.createDeferredInterstitial({
id,
startTime,
endTime: startTime + pod.duration,
pre: false,
post: false,
canJump: true,
timelineRange: true,
playoutLimit: null,
}, networkingEngine, () => uri, (data) => {
const envelope = Parser.parseEnvelope(data, 'slot');
if (!envelope) {
throw new Error('Invalid SVTA remote slots');
}
// Remote slots replace the inline ones.
const slots =
/** @type {!Array<!shaka.extern.AdCreativeSignaling.Slot>} */ (
envelope.payload);
const $remote = {tracking: pod.$remote.tracking};
const resolved = /** @type {!shaka.extern.AdCreativeSignaling.Pod} */ (
Object.assign({}, pod, {slots, $remote}));
return slots.map((slot, i) => Parser.makeInterstitial_(slot, resolved,
startTime, id + '_slot_' + i, id, slots.length, i + 1));
});
}
/**
* Attaches validated slot metadata to media identified by another format,
* if the signaling describes a single slot. SVTA does not supply the URI or
* change stitching decisions.
*
* @param {!shaka.extern.AdInterstitial} interstitial
* @param {*} data
* @param {?shaka.extern.AdCreativeSignaling.Pod=} pod
*/
static decorateInterstitial(interstitial, data, pod = null) {
const Parser = shaka.ads.SvtaInterstitialParser;
const envelope = Parser.parseEnvelope(data, 'slot');
if (!envelope || envelope.payload.length != 1) {
return;
}
const slot = /** @type {!shaka.extern.AdCreativeSignaling.Slot} */
(envelope.payload[0]);
interstitial.adCreativeSignaling = slot;
interstitial.pod = pod;
interstitial.podOffset = slot.start;
interstitial.clickThroughUrl = slot.clickThrough || null;
if (slot.skipOffset != null && interstitial.isSkippable) {
interstitial.skipOffset = slot.skipOffset;
}
}
/**
* @param {!shaka.extern.AdCreativeSignaling.Slot} slot
* @param {?shaka.extern.AdCreativeSignaling.Pod} pod
* @param {number} anchor
* @param {string} id
* @param {string} groupId
* @param {number} podSize
* @param {number} podPosition
* @return {!shaka.extern.AdInterstitial}
* @private
*/
static makeInterstitial_(slot, pod, anchor, id, groupId,
podSize, podPosition) {
const startTime = anchor + slot.start;
return shaka.ads.Utils.createInterstitial({
id,
groupId,
startTime,
endTime: startTime + slot.duration,
playbackMode: shaka.ads.Utils.EMBEDDED,
isSkippable: slot.skipOffset != null,
skipOffset: slot.skipOffset ?? null,
timelineRange: true,
clickThroughUrl: slot.clickThrough || null,
adCreativeSignaling: slot,
pod,
podOffset: slot.start,
podSize,
podPosition,
});
}
/**
* @param {?Object} value
* @param {boolean} remoteFields
* @return {boolean}
* @private
*/
static isSlot_(value, remoteFields) {
const Parser = shaka.ads.SvtaInterstitialParser;
// Only what playback and measurement use is validated. Verifications
// are not used.
const valid = !!value &&
(value['type'] == null || value['type'] == 'linear') &&
Parser.isTime_(value['start']) && value['duration'] > 0 &&
Parser.isTime_(value['duration']) &&
Parser.isArray_(value['identifiers'], (id) => !!id) &&
(value['skipOffset'] == null || Parser.isTime_(value['skipOffset'])) &&
(value['clickThrough'] == null ||
typeof value['clickThrough'] == 'string') &&
Parser.isArray_(value['tracking'], Parser.isTrackingEvent_) &&
Parser.isRemote_(value['$remote'], remoteFields);
if (valid) {
// Defaults to an empty array, so users of the slot need no checks.
value['identifiers'] = value['identifiers'] || [];
}
return valid;
}
/**
* @param {?Object} value
* @param {boolean} remoteFields
* @return {boolean}
* @private
*/
static isPod_(value, remoteFields) {
const Parser = shaka.ads.SvtaInterstitialParser;
const valid = !!value && value['duration'] > 0 &&
Parser.isTime_(value['duration']) &&
(value['start'] == null || Parser.isTime_(value['start'])) &&
Parser.isArray_(value['slots'], (slot) =>
Parser.isSlot_(slot, remoteFields)) &&
Parser.isArray_(value['tracking'], Parser.isTrackingEvent_) &&
Parser.isRemote_(value['$remote'], remoteFields);
if (valid) {
// Defaults to an empty array, so users of the pod need no checks.
// Remote slots, if any, are resolved later.
value['slots'] = value['slots'] || [];
}
return valid;
}
/**
* @param {?Object} value
* @return {boolean}
* @private
*/
static isTrackingEvent_(value) {
return !!value && typeof value['type'] == 'string' &&
shaka.ads.SvtaInterstitialParser.isArray_(value['urls'],
(url) => typeof url == 'string') &&
(value['offset'] == null ||
shaka.ads.SvtaInterstitialParser.isTime_(value['offset']));
}
/**
* Remote fields are URLs, only allowed when the envelope declares them.
*
* @param {?Object} value
* @param {boolean} enabled
* @return {boolean}
* @private
*/
static isRemote_(value, enabled) {
return value == null || (enabled && typeof value == 'object' &&
Object.keys(value).every((key) => typeof value[key] == 'string'));
}
/**
* @param {*} value
* @return {boolean}
* @private
*/
static isTime_(value) {
return typeof value == 'number' && Number.isFinite(value) && value >= 0;
}
/**
* @param {*} value
* @param {function(?Object):boolean} validate
* @return {boolean}
* @private
*/
static isArray_(value, validate) {
return value == null || (Array.isArray(value) && value.every(validate));
}
};
/**
* The HLS attribute, and asset list field, that carries the signaling.
*
* @const {string}
* @noinline
*/
shaka.ads.SvtaInterstitialParser.KEY = 'X-AD-CREATIVE-SIGNALING';
/**
* @typedef {{
* interstitials: !Array<shaka.extern.AdInterstitial>,
* deferred: !Array<!shaka.extern.DeferredInterstitial>,
* }}
*
* @property {!Array<shaka.extern.AdInterstitial>} interstitials
* Interstitials ready to be scheduled.
* @property {!Array<!shaka.extern.DeferredInterstitial>} deferred
* Pods whose slots must be resolved before they can be scheduled.
*/
shaka.ads.SvtaInterstitialParser.Result;