@streamlayer/feature-advertisement 1.1.1-rc.1350 → 1.1.1-rc.2.pf58ff685
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/advertisement.js +24 -9
- package/lib/cjs/advertisement.js +24 -9
- package/lib/cjs/index.d.ts +2 -0
- package/lib/cjs/index.js +2 -0
- package/lib/cjs/units/ad-macros.d.ts +7 -0
- package/lib/cjs/units/ad-macros.js +91 -0
- package/lib/cjs/units/external-ad.d.ts +69 -0
- package/lib/cjs/units/external-ad.js +93 -62
- package/lib/cjs/units/external-pause-ad.js +7 -1
- package/lib/cjs/units/gam.d.ts +11 -1
- package/lib/cjs/units/gam.js +65 -17
- package/lib/cjs/units/parse-vast-extensions.d.ts +10 -1
- package/lib/cjs/units/parse-vast-extensions.js +5 -1
- package/lib/cjs/units/sponsor-logo.d.ts +78 -0
- package/lib/cjs/units/sponsor-logo.js +194 -0
- package/lib/cjs/units/ssai-detector.js +27 -11
- package/lib/cjs/units/ssai-log.d.ts +16 -0
- package/lib/cjs/units/ssai-log.js +23 -0
- package/lib/cjs/units/ssai-providers.js +48 -11
- package/lib/classic/advertisement.js +34 -17
- package/lib/classic/cjs/advertisement.js +34 -17
- package/lib/classic/cjs/index.js +2 -0
- package/lib/classic/cjs/units/ad-macros.js +115 -0
- package/lib/classic/cjs/units/external-ad.js +111 -64
- package/lib/classic/cjs/units/external-pause-ad.js +5 -1
- package/lib/classic/cjs/units/gam.js +73 -10
- package/lib/classic/cjs/units/parse-vast-extensions.js +5 -1
- package/lib/classic/cjs/units/sponsor-logo.js +201 -0
- package/lib/classic/cjs/units/ssai-detector.js +33 -8
- package/lib/classic/cjs/units/ssai-log.js +23 -0
- package/lib/classic/cjs/units/ssai-providers.js +86 -24
- package/lib/classic/index.js +1 -0
- package/lib/classic/units/ad-macros.js +113 -0
- package/lib/classic/units/external-ad.js +109 -65
- package/lib/classic/units/external-pause-ad.js +5 -1
- package/lib/classic/units/gam.js +73 -10
- package/lib/classic/units/parse-vast-extensions.js +5 -1
- package/lib/classic/units/sponsor-logo.js +197 -0
- package/lib/classic/units/ssai-detector.js +33 -8
- package/lib/classic/units/ssai-log.js +21 -0
- package/lib/classic/units/ssai-providers.js +86 -24
- package/lib/index.d.ts +2 -0
- package/lib/index.js +1 -0
- package/lib/units/ad-macros.d.ts +7 -0
- package/lib/units/ad-macros.js +87 -0
- package/lib/units/external-ad.d.ts +69 -0
- package/lib/units/external-ad.js +92 -64
- package/lib/units/external-pause-ad.js +7 -1
- package/lib/units/gam.d.ts +11 -1
- package/lib/units/gam.js +65 -17
- package/lib/units/parse-vast-extensions.d.ts +10 -1
- package/lib/units/parse-vast-extensions.js +5 -1
- package/lib/units/sponsor-logo.d.ts +78 -0
- package/lib/units/sponsor-logo.js +188 -0
- package/lib/units/ssai-detector.js +27 -11
- package/lib/units/ssai-log.d.ts +16 -0
- package/lib/units/ssai-log.js +18 -0
- package/lib/units/ssai-providers.js +48 -11
- package/lib/vast-client.d.ts +9 -0
- package/package.json +8 -8
package/lib/units/external-ad.js
CHANGED
|
@@ -9,6 +9,7 @@ import '@streamlayer/sdk-web-api';
|
|
|
9
9
|
import VMAP from 'vmap';
|
|
10
10
|
import { gam } from './gam.js';
|
|
11
11
|
import { parseVastExtension } from './parse-vast-extensions.js';
|
|
12
|
+
import { collectAssets, selectCardLogo } from './sponsor-logo.js';
|
|
12
13
|
const log = createLogger('ui:gam-external-ad');
|
|
13
14
|
/** Default budget for a single VAST resolution before it is treated as "no ad". */
|
|
14
15
|
const VAST_FETCH_TIMEOUT_MS = 10_000;
|
|
@@ -38,7 +39,7 @@ const withTimeout = (promise, ms) => {
|
|
|
38
39
|
* Get the first valid creative from an ad
|
|
39
40
|
*/
|
|
40
41
|
const getCreative = (ad) => {
|
|
41
|
-
return ad.creatives.find((creative) => creative
|
|
42
|
+
return ad.creatives.find((creative) => getMediaFileUrl(creative));
|
|
42
43
|
};
|
|
43
44
|
/**
|
|
44
45
|
* Get the URL of the first media file in the creative
|
|
@@ -59,31 +60,10 @@ const getClickThrough = (creative) => {
|
|
|
59
60
|
// @ts-ignore — vast-client types `videoClickThroughURLTemplate` loosely
|
|
60
61
|
return creative?.videoClickThroughURLTemplate?.url || undefined;
|
|
61
62
|
};
|
|
62
|
-
/**
|
|
63
|
-
* Extract every `<Companion>` variation of the first `<CompanionAds>` creative that carries a
|
|
64
|
-
* `<StaticResource>`, in document order. `width`/`height` are XML attributes (strings at
|
|
65
|
-
* runtime), normalised to numbers so the size mapping can compare them.
|
|
66
|
-
*/
|
|
67
|
-
const getCompanions = (ad) => {
|
|
68
|
-
const creative = ad.creatives.find((item) => item.type === 'companion');
|
|
69
|
-
return (creative?.variations || []).reduce((acc, variation) => {
|
|
70
|
-
const imageUrl = variation?.staticResources?.[0]?.url;
|
|
71
|
-
if (!imageUrl) {
|
|
72
|
-
return acc;
|
|
73
|
-
}
|
|
74
|
-
return acc.concat({
|
|
75
|
-
imageUrl,
|
|
76
|
-
clickUrl: variation.companionClickThroughURLTemplate ?? undefined,
|
|
77
|
-
width: Number(variation.width) || undefined,
|
|
78
|
-
height: Number(variation.height) || undefined,
|
|
79
|
-
creative,
|
|
80
|
-
variation,
|
|
81
|
-
});
|
|
82
|
-
}, []);
|
|
83
|
-
};
|
|
84
63
|
/** Product companion mapping: the 300x75 companion is the logo, the banner-sized one the media. */
|
|
85
64
|
const LOGO_COMPANION_WIDTH = 300;
|
|
86
65
|
const LOGO_COMPANION_HEIGHT = 75;
|
|
66
|
+
const isLogoSized = (companion) => companion?.width === LOGO_COMPANION_WIDTH && companion?.height === LOGO_COMPANION_HEIGHT;
|
|
87
67
|
const companionArea = (companion) => (companion.width || 0) * (companion.height || 0);
|
|
88
68
|
/**
|
|
89
69
|
* Split a `<CompanionAds>` creative's variations into the logo and media slots.
|
|
@@ -91,11 +71,18 @@ const companionArea = (companion) => (companion.width || 0) * (companion.height
|
|
|
91
71
|
* The pair mapping activates only for a tag that ships an exact 300x75 companion next to at
|
|
92
72
|
* least one other. Deliberately conservative pending broader product sizes: on any other tag
|
|
93
73
|
* no companion is promoted to the logo slot and the first one stays the media/strip candidate.
|
|
74
|
+
*
|
|
75
|
+
* Both halves of that gate are load-bearing and neither is an aspect rule: a 320x80 companion is
|
|
76
|
+
* the same 4:1 shape as 300x75 and is still rejected, and a lone 300x75 companion is rejected too
|
|
77
|
+
* (the Standard Ad Sidebar recovers that one as `AdData.loneLogo` when a Linear / NonLinear is
|
|
78
|
+
* the media, so it is not needed here).
|
|
79
|
+
* This is **narrower than iOS**, which has no size rule at all and fills its logo slot from a
|
|
80
|
+
* single companion — see `docs/wiki/programmatic-ads-display-ios-parity.md`. Exported for
|
|
81
|
+
* `external-ad.spec.ts` only; `src/index.ts` re-exports names explicitly, so this stays out of the
|
|
82
|
+
* package's public API.
|
|
94
83
|
*/
|
|
95
|
-
const selectCompanions = (companions) => {
|
|
96
|
-
const logo = companions.length > 1
|
|
97
|
-
? companions.find((companion) => companion.width === LOGO_COMPANION_WIDTH && companion.height === LOGO_COMPANION_HEIGHT)
|
|
98
|
-
: undefined;
|
|
84
|
+
export const selectCompanions = (companions) => {
|
|
85
|
+
const logo = companions.length > 1 ? companions.find((companion) => isLogoSized(companion)) : undefined;
|
|
99
86
|
if (!logo) {
|
|
100
87
|
return { logo: undefined, media: companions[0] };
|
|
101
88
|
}
|
|
@@ -105,40 +92,26 @@ const selectCompanions = (companions) => {
|
|
|
105
92
|
.pop();
|
|
106
93
|
return { logo, media };
|
|
107
94
|
};
|
|
108
|
-
/**
|
|
109
|
-
* Extract the first `<NonLinearAds>` creative's static-image variation. Both the creative (which
|
|
110
|
-
* owns the `<TrackingEvents>`) and the variation (which owns the click-through) are kept —
|
|
111
|
-
* `VASTTracker` needs both.
|
|
112
|
-
*
|
|
113
|
-
* `@dailymotion/vast-client` 6.4.2 emits `nonlinearClickThroughURLTemplate` (lower-case `l`),
|
|
114
|
-
* which the `@types/vast-client` shim declares as `nonLinearClickThroughURLTemplate` — the
|
|
115
|
-
* runtime spelling is authoritative.
|
|
116
|
-
*/
|
|
117
|
-
const getNonLinear = (ad) => {
|
|
118
|
-
const creative = ad.creatives.find((item) => item.type === 'nonlinear');
|
|
119
|
-
const variation = creative?.variations?.[0];
|
|
120
|
-
const imageUrl = variation?.staticResource;
|
|
121
|
-
if (!creative || !variation || !imageUrl) {
|
|
122
|
-
return undefined;
|
|
123
|
-
}
|
|
124
|
-
return {
|
|
125
|
-
imageUrl,
|
|
126
|
-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
127
|
-
// @ts-ignore — see the runtime-vs-shim spelling note above
|
|
128
|
-
clickUrl: variation.nonlinearClickThroughURLTemplate ?? undefined,
|
|
129
|
-
width: Number(variation.width) || undefined,
|
|
130
|
-
height: Number(variation.height) || undefined,
|
|
131
|
-
creative,
|
|
132
|
-
variation,
|
|
133
|
-
};
|
|
134
|
-
};
|
|
135
95
|
/**
|
|
136
96
|
* Map a parsed VAST response into our `AdData` shape. Shared by both acquisition
|
|
137
97
|
* paths — the GAM/VMAP tag flow and the Prebid auction flow — so the two always
|
|
138
|
-
* produce identical downstream data.
|
|
139
|
-
*
|
|
98
|
+
* produce identical downstream data.
|
|
99
|
+
*
|
|
100
|
+
* Returns `undefined` for a no-ad, which is two distinct shapes: a response with no ad carrying
|
|
101
|
+
* creatives, and an ad whose creatives carry no RENDERABLE asset — no linear `<MediaFile>`, no
|
|
102
|
+
* `<NonLinear>` still and no `<Companion>` image. Both callers `filter(Boolean)` the result, so a
|
|
103
|
+
* no-ad leaves `ads: []` and the card keeps whatever the server configured for it.
|
|
104
|
+
*
|
|
105
|
+
* Exported for `vast-mapping.spec.ts`, which drives it over the same fixture corpus the iOS suite
|
|
106
|
+
* uses. `src/index.ts` re-exports names explicitly, so this stays out of the package's public API.
|
|
140
107
|
*/
|
|
141
|
-
const buildAdData = (parsedVAST, order) => {
|
|
108
|
+
export const buildAdData = (parsedVAST, order) => {
|
|
109
|
+
// Defensive, and unreachable through either production path: `@dailymotion/vast-client` discards
|
|
110
|
+
// an ad whose creatives all fail to parse (empty `<Linear>`, empty/missing `<Creatives>`, unknown
|
|
111
|
+
// creative element) before `parseVAST` resolves, so it never yields an Ad with zero creatives.
|
|
112
|
+
// Kept because it costs nothing and `buildAdData` is also reachable from tests with a hand-built
|
|
113
|
+
// response — see the `invalid_ad` case in `vast-mapping.spec.ts`, which pins the parser contract
|
|
114
|
+
// this relies on rather than pretending to exercise the guard.
|
|
142
115
|
const validAd = parsedVAST.ads.find((ads) => ads.creatives.length > 0);
|
|
143
116
|
if (!validAd) {
|
|
144
117
|
log.warn({ order, ads: parsedVAST.ads.length }, 'vast response carries no ad with creatives — treated as no-ad');
|
|
@@ -150,10 +123,47 @@ const buildAdData = (parsedVAST, order) => {
|
|
|
150
123
|
const parsedExtension = parseVastExtension(extension);
|
|
151
124
|
const creative = getCreative(validAd);
|
|
152
125
|
const mediaFileUrl = getMediaFileUrl(creative);
|
|
153
|
-
const
|
|
126
|
+
const images = collectAssets(validAd);
|
|
127
|
+
const companions = images.filter((asset) => asset.origin === 'companion');
|
|
128
|
+
const nonLinear = images.find((asset) => asset.origin === 'nonlinear');
|
|
129
|
+
// An ad the parser KEPT whose creatives carry nothing any shell can draw is a no-ad, exactly
|
|
130
|
+
// like a response with no ad at all. vast-client only discards a creative it cannot parse; a
|
|
131
|
+
// `<NonLinear>` or `<Companion>` whose `<StaticResource>` body is empty parses fine and just
|
|
132
|
+
// yields no url, so the guard above cannot catch it. Without this the fabricated promotion still
|
|
133
|
+
// took the slot and drew the ad's TEXT — title and description over an empty media box — which
|
|
134
|
+
// is a billable impression for a creative that does not exist.
|
|
135
|
+
//
|
|
136
|
+
// "Renderable" is the union of the three slots the shells can fill: a linear `<MediaFile>`, a
|
|
137
|
+
// `<NonLinear>` still, or a `<Companion>` image. `collectAssets` already drops the imageless
|
|
138
|
+
// ones, so a non-empty `companions` means at least one drawable image. A tag carrying only a
|
|
139
|
+
// `<Mezzanine>` never reaches here — the parser drops that Linear itself — but it lands in the
|
|
140
|
+
// same place either way.
|
|
141
|
+
if (!mediaFileUrl && !nonLinear && companions.length === 0) {
|
|
142
|
+
log.warn({ order, adId: validAd.id, creatives: validAd.creatives.length }, 'vast ad carries no renderable asset (no media file, no nonlinear, no companion) — treated as no-ad');
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
154
145
|
const { logo, media: mediaCompanion } = selectCompanions(companions);
|
|
155
|
-
const nonLinear = getNonLinear(validAd);
|
|
156
146
|
const clickThrough = getClickThrough(creative) || nonLinear?.clickUrl || mediaCompanion?.clickUrl;
|
|
147
|
+
// Mirror iOS's greedy `pool.select([hero, .cardLogo])`: the hero claims first and a claimed
|
|
148
|
+
// asset is gone for later roles. Web's hero precedence (`createActiveAdFromVast`) is linear
|
|
149
|
+
// video → pair media companion (unreachable here: a `logo` short-circuits the whole selection)
|
|
150
|
+
// → NonLinear still → lone companion, so a companion is only unavailable to the logo slot when
|
|
151
|
+
// it IS the hero, i.e. when the tag ships neither. `selectCardLogo` already filters to companion
|
|
152
|
+
// origins, so the NonLinear never needs excluding.
|
|
153
|
+
//
|
|
154
|
+
// Excluding `companions[0]` (which is what `mediaCompanion` is when there is no `logo`) rather
|
|
155
|
+
// than the largest-area asset is a deliberate difference from iOS's `areaDominant` hero: web's
|
|
156
|
+
// content slot draws the FIRST companion in document order, so document order is the correct
|
|
157
|
+
// criterion for "what the hero already claimed" on this platform.
|
|
158
|
+
const companionIsHero = !mediaFileUrl && !nonLinear;
|
|
159
|
+
const cardLogoAsset = logo ? undefined : selectCardLogo(validAd, companionIsHero ? [mediaCompanion] : []);
|
|
160
|
+
// Share the selected resource wrapper so drawn-companion identity remains stable.
|
|
161
|
+
const cardLogo = cardLogoAsset
|
|
162
|
+
? companions.find((item) => item.variation === cardLogoAsset.variation && item.imageUrl === cardLogoAsset.imageUrl)
|
|
163
|
+
: undefined;
|
|
164
|
+
// Same collected instance as `companion` (identity matters to `drawnCompanions`). A lone
|
|
165
|
+
// companion never satisfies the pair rule, so this can only fill a slot `logo` left empty.
|
|
166
|
+
const loneLogo = companions.length === 1 && !companionIsHero && isLogoSized(companions[0]) ? companions[0] : undefined;
|
|
157
167
|
log.info({
|
|
158
168
|
order,
|
|
159
169
|
adId: validAd.id,
|
|
@@ -169,6 +179,8 @@ const buildAdData = (parsedVAST, order) => {
|
|
|
169
179
|
media: mediaCompanion
|
|
170
180
|
? { size: `${mediaCompanion.width}x${mediaCompanion.height}`, url: mediaCompanion.imageUrl }
|
|
171
181
|
: undefined,
|
|
182
|
+
cardLogo: cardLogo ? { size: `${cardLogo.width}x${cardLogo.height}`, url: cardLogo.imageUrl } : undefined,
|
|
183
|
+
loneLogo: loneLogo ? { size: `${loneLogo.width}x${loneLogo.height}`, url: loneLogo.imageUrl } : undefined,
|
|
172
184
|
clickThrough: !!clickThrough,
|
|
173
185
|
},
|
|
174
186
|
}, 'vast ad parsed');
|
|
@@ -178,6 +190,8 @@ const buildAdData = (parsedVAST, order) => {
|
|
|
178
190
|
mediaFileUrl,
|
|
179
191
|
companion: companions[0],
|
|
180
192
|
logo,
|
|
193
|
+
cardLogo,
|
|
194
|
+
loneLogo,
|
|
181
195
|
mediaCompanion,
|
|
182
196
|
nonLinear,
|
|
183
197
|
clickThrough,
|
|
@@ -206,8 +220,12 @@ const createRequestId = () => `sl-web-${Date.now().toString(36)}-${Math.random()
|
|
|
206
220
|
* - `storedauctionresponse` → `imp.ext.prebid.storedauctionresponse.id` (staging mock).
|
|
207
221
|
* - `test=1` → top-level `test` flag.
|
|
208
222
|
* - `w` / `h` → video dimensions (default 1920×1080).
|
|
223
|
+
*
|
|
224
|
+
* The host's GDPR consent (`AdTagParams`) rides the OpenRTB 2.5 extension fields Prebid Server
|
|
225
|
+
* reads — `regs.ext.gdpr` and `user.ext.consent` — the same signal the `${GDPR}` /
|
|
226
|
+
* `${GDPR_CONSENT}` macros carry for a VAST tag. Each is sent only when the host passed it.
|
|
209
227
|
*/
|
|
210
|
-
const buildAuctionRequest = (url) => {
|
|
228
|
+
export const buildAuctionRequest = (url, adTagParams) => {
|
|
211
229
|
const params = url.searchParams;
|
|
212
230
|
const width = Number(params.get('w')) || 1920;
|
|
213
231
|
const height = Number(params.get('h')) || 1080;
|
|
@@ -245,6 +263,12 @@ const buildAuctionRequest = (url) => {
|
|
|
245
263
|
if (params.get('test') === '1') {
|
|
246
264
|
request.test = 1;
|
|
247
265
|
}
|
|
266
|
+
if (adTagParams?.gdprApplies !== undefined) {
|
|
267
|
+
request.regs = { ext: { gdpr: adTagParams.gdprApplies ? 1 : 0 } };
|
|
268
|
+
}
|
|
269
|
+
if (adTagParams?.tcString) {
|
|
270
|
+
request.user = { ext: { consent: adTagParams.tcString } };
|
|
271
|
+
}
|
|
248
272
|
return request;
|
|
249
273
|
};
|
|
250
274
|
/**
|
|
@@ -252,11 +276,11 @@ const buildAuctionRequest = (url) => {
|
|
|
252
276
|
* Every bid's `adm` is inline VAST XML (no URL to fetch); bids are ordered by price
|
|
253
277
|
* so the winner plays first (RFC §2). A single bad bid must not drop the batch.
|
|
254
278
|
*/
|
|
255
|
-
const fetchPrebidAds = async (prebidUrl) => {
|
|
279
|
+
const fetchPrebidAds = async (prebidUrl, adTagParams) => {
|
|
256
280
|
const response = await fetch(prebidUrl.toString(), {
|
|
257
281
|
method: 'POST',
|
|
258
282
|
headers: { 'Content-Type': 'application/json' },
|
|
259
|
-
body: JSON.stringify(buildAuctionRequest(prebidUrl)),
|
|
283
|
+
body: JSON.stringify(buildAuctionRequest(prebidUrl, adTagParams)),
|
|
260
284
|
});
|
|
261
285
|
if (!response.ok) {
|
|
262
286
|
throw new Error(`prebid auction failed with status ${response.status}`);
|
|
@@ -309,13 +333,17 @@ export const externalAd = (instance) => {
|
|
|
309
333
|
return undefined;
|
|
310
334
|
}
|
|
311
335
|
})();
|
|
336
|
+
// The host's CURRENT macro values, read per request rather than baked into the store: the
|
|
337
|
+
// store is cached by url for the session, and consent routinely arrives after the SDK
|
|
338
|
+
// starts (a CMP answers asynchronously) or changes mid-session.
|
|
339
|
+
const adTagParams = instance.sdk.options.get().adTagParams;
|
|
312
340
|
// Prebid links hit our Prebid Server auction endpoint instead of a VAST tag.
|
|
313
341
|
// Run the OpenRTB auction and parse the winning bid's inline VAST (`adm`) into
|
|
314
342
|
// the same `AdStore` shape the GAM/VMAP path returns. See rfcs/prebid.
|
|
315
343
|
if (parsedAdUrl && isPrebidUrl(parsedAdUrl)) {
|
|
316
|
-
return fetchPrebidAds(parsedAdUrl);
|
|
344
|
+
return fetchPrebidAds(parsedAdUrl, adTagParams);
|
|
317
345
|
}
|
|
318
|
-
const generateUrl = gam(adUrl);
|
|
346
|
+
const generateUrl = gam(adUrl, undefined, adTagParams);
|
|
319
347
|
const generatedUrl = await generateUrl();
|
|
320
348
|
const noncedLink = generatedUrl?.url;
|
|
321
349
|
if (!noncedLink) {
|
|
@@ -7,6 +7,7 @@ import { eventBus } from '@streamlayer/sdk-web-interfaces';
|
|
|
7
7
|
import { createLogger } from '@streamlayer/sdk-web-logger';
|
|
8
8
|
import '@streamlayer/sdk-web-api';
|
|
9
9
|
import { VASTClient } from '@dailymotion/vast-client';
|
|
10
|
+
import { substituteAdTagMacros } from './ad-macros.js';
|
|
10
11
|
const FULLBLEED_VIDEO_TEMPLATES = [
|
|
11
12
|
'fullbleed-video',
|
|
12
13
|
'fullbleed-video-center',
|
|
@@ -115,7 +116,12 @@ export const externalPauseAd = (instance) => {
|
|
|
115
116
|
try {
|
|
116
117
|
// `resolveAll: true` follows every `VASTAdTagURI` wrapper chain and resolves all
|
|
117
118
|
// Ads (the default only resolves the first standalone Ad and stops at the wrapper).
|
|
118
|
-
|
|
119
|
+
// Pause-ad tags do not go through `gam()`, so fill the macros here — with the host's values
|
|
120
|
+
// as they are NOW, since this fetcher re-runs on every pause. The store key stays the
|
|
121
|
+
// configured url: `ExposedPauseAd` compares against it.
|
|
122
|
+
const parsedVAST = await vastClient.get(substituteAdTagMacros(adUrl, instance.sdk.options.get().adTagParams), {
|
|
123
|
+
resolveAll: true,
|
|
124
|
+
});
|
|
119
125
|
const template = options.template ?? 'default';
|
|
120
126
|
// `fullbleed-video` needs an ad that actually carries a usable linear MP4. With
|
|
121
127
|
// `resolveAll: true` the response can be a pod/wrapper chain, so prefer the first
|
package/lib/units/gam.d.ts
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google PAL nonce manager + ad-tag resolution for programmatic (GAM) VAST requests.
|
|
3
|
+
*
|
|
4
|
+
* Lives here because the VAST fetcher (`units/external-ad.ts`) is its only consumer: this is the
|
|
5
|
+
* request side of an ad tag, not the promotion side. The *composition* of a tag URL from the proto
|
|
6
|
+
* (`processGamAdvertisement` / `selectGamOptions`) stays in `feature-gamification`, next to the
|
|
7
|
+
* `$store` bridge that is its only consumer — so the two feature packages share no code and
|
|
8
|
+
* neither depends on the other.
|
|
9
|
+
*/
|
|
10
|
+
import type { AdTagParams } from '@streamlayer/sdk-web-core';
|
|
1
11
|
export type PalNonceManager = {
|
|
2
12
|
getNonce: () => string;
|
|
3
13
|
sendAdClick: () => void;
|
|
@@ -8,7 +18,7 @@ export type GamNonceResult = {
|
|
|
8
18
|
url: URL;
|
|
9
19
|
manager?: PalNonceManager;
|
|
10
20
|
};
|
|
11
|
-
export declare const gam: (
|
|
21
|
+
export declare const gam: (rawUrl: string, adContainer?: HTMLDivElement, adTagParams?: AdTagParams) => (() => Promise<{
|
|
12
22
|
url: URL;
|
|
13
23
|
}>) | (() => Promise<{
|
|
14
24
|
url: URL;
|
package/lib/units/gam.js
CHANGED
|
@@ -1,18 +1,67 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Google PAL nonce manager + ad-tag resolution for programmatic (GAM) VAST requests.
|
|
3
|
-
*
|
|
4
|
-
* Lives here because the VAST fetcher (`units/external-ad.ts`) is its only consumer: this is the
|
|
5
|
-
* request side of an ad tag, not the promotion side. The *composition* of a tag URL from the proto
|
|
6
|
-
* (`processGamAdvertisement` / `selectGamOptions`) stays in `feature-gamification`, next to the
|
|
7
|
-
* `$store` bridge that is its only consumer — so the two feature packages share no code and
|
|
8
|
-
* neither depends on the other.
|
|
9
|
-
*/
|
|
10
1
|
import { safeLocalStorage, safeSessionStorage } from '@streamlayer/sdk-web-storage';
|
|
11
2
|
import { validate as uuidValidate, v5 as uuid5, v4 as uuid4 } from 'uuid';
|
|
3
|
+
import { substituteAdTagMacros } from './ad-macros.js';
|
|
12
4
|
const urlIsNotGam = (url) => {
|
|
13
5
|
return url.origin !== 'https://pubads.g.doubleclick.net';
|
|
14
6
|
};
|
|
15
|
-
|
|
7
|
+
const palScriptUrl = 'https://imasdk.googleapis.com/pal/sdkloader/pal.js';
|
|
8
|
+
const getPal = () => {
|
|
9
|
+
// @ts-expect-error Google PAL is supplied by the host or the script loader below.
|
|
10
|
+
return typeof goog === 'undefined' ? undefined : goog.pal;
|
|
11
|
+
};
|
|
12
|
+
let palReady;
|
|
13
|
+
const ensurePal = async () => {
|
|
14
|
+
if (getPal()) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (!palReady) {
|
|
18
|
+
palReady = new Promise((resolve, reject) => {
|
|
19
|
+
if (typeof document === 'undefined') {
|
|
20
|
+
reject(new Error('Google PAL requires a browser document'));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const existing = document.querySelector(`script[src="${palScriptUrl}"]`);
|
|
24
|
+
const script = existing || document.createElement('script');
|
|
25
|
+
const finish = (error) => {
|
|
26
|
+
clearTimeout(timeout);
|
|
27
|
+
script.removeEventListener('load', onLoad);
|
|
28
|
+
script.removeEventListener('error', onError);
|
|
29
|
+
if (error) {
|
|
30
|
+
if (!existing) {
|
|
31
|
+
script.remove();
|
|
32
|
+
}
|
|
33
|
+
reject(error);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
resolve();
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const onLoad = () => finish(getPal() ? undefined : new Error('Google PAL did not initialize'));
|
|
40
|
+
const onError = () => finish(new Error('Google PAL failed to load'));
|
|
41
|
+
const timeout = setTimeout(() => finish(new Error('Google PAL loading timed out')), 10000);
|
|
42
|
+
script.addEventListener('load', onLoad);
|
|
43
|
+
script.addEventListener('error', onError);
|
|
44
|
+
if (!existing) {
|
|
45
|
+
script.async = true;
|
|
46
|
+
script.src = palScriptUrl;
|
|
47
|
+
document.head.appendChild(script);
|
|
48
|
+
}
|
|
49
|
+
}).catch((error) => {
|
|
50
|
+
palReady = undefined;
|
|
51
|
+
throw error;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
await palReady;
|
|
55
|
+
palReady = undefined;
|
|
56
|
+
};
|
|
57
|
+
export const gam = (rawUrl, adContainer, adTagParams) => {
|
|
58
|
+
// Every VAST/VMAP tag funnels through here, so this is where the macros get filled — for GAM and
|
|
59
|
+
// for any other vendor alike. Studio stores a programmatic tag verbatim, so an unexpanded
|
|
60
|
+
// `[timestamp]` / `[clickmacro]` / `${GDPR}` would otherwise reach the ad server literal, and a
|
|
61
|
+
// literal macro comes back copied into the vendor's click-through. `adTagParams` are the host's
|
|
62
|
+
// consent and click values, read by the caller at request time. The GAM `[placeholder]` tokens
|
|
63
|
+
// are left alone here and filled per parameter below.
|
|
64
|
+
const url = substituteAdTagMacros(rawUrl, adTagParams);
|
|
16
65
|
const parsedUrl = (() => {
|
|
17
66
|
try {
|
|
18
67
|
const parsedUrl = new URL(url);
|
|
@@ -32,20 +81,19 @@ export const gam = (url, adContainer) => {
|
|
|
32
81
|
return { url: parsedUrl };
|
|
33
82
|
};
|
|
34
83
|
}
|
|
35
|
-
|
|
36
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
-
const pal = (typeof goog === 'undefined' ? undefined : goog.pal);
|
|
38
|
-
if (!pal || !parsedUrl) {
|
|
84
|
+
if (!parsedUrl) {
|
|
39
85
|
// eslint-disable-next-line require-await
|
|
40
86
|
return async () => {
|
|
41
87
|
return undefined;
|
|
42
88
|
};
|
|
43
89
|
}
|
|
44
|
-
const consentSettings = new pal.ConsentSettings();
|
|
45
|
-
consentSettings.allowStorage = false;
|
|
46
|
-
const nonceLoader = new pal.NonceLoader(consentSettings);
|
|
47
90
|
const generateUrl = async () => {
|
|
48
91
|
try {
|
|
92
|
+
await ensurePal();
|
|
93
|
+
const pal = getPal();
|
|
94
|
+
const consentSettings = new pal.ConsentSettings();
|
|
95
|
+
consentSettings.allowStorage = false;
|
|
96
|
+
const nonceLoader = new pal.NonceLoader(consentSettings);
|
|
49
97
|
const parsedUrl = new URL(url);
|
|
50
98
|
const request = new pal.NonceRequest();
|
|
51
99
|
let deviceId = safeLocalStorage().getItem('sl-device-id') || uuid4();
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { AdType } from '@streamlayer/sl-eslib/interactive/interactive.common_pb';
|
|
2
2
|
export interface VastCreative {
|
|
3
3
|
adName: string;
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The placement this extension NAMES, or `undefined` when its root element is not one we map.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately not defaulted here. An unrecognised extension means "this tag says nothing about
|
|
8
|
+
* placement", which must not outrank a placement the promotion proto already stated — defaulting
|
|
9
|
+
* it to `SIDE_BY_SIDE` at this layer silently re-routed a configured Sidebar/L-Bar, because the
|
|
10
|
+
* consumers join with `extension?.adType ?? placement`. The default now lives in
|
|
11
|
+
* `resolveExternalAdType`, applied only after the proto placement has had its turn.
|
|
12
|
+
*/
|
|
13
|
+
adType?: AdType;
|
|
5
14
|
landscapeBackground?: string;
|
|
6
15
|
portraitBackground?: string;
|
|
7
16
|
sponsorLogo?: string;
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { AdType } from '@streamlayer/sl-eslib/interactive/interactive.common_pb';
|
|
2
|
+
/**
|
|
3
|
+
* Extension root element name → the placement it names. Only `DoubleBox` is defined by the
|
|
4
|
+
* StreamLayer extension schema today; anything else is a vendor extension we do not interpret.
|
|
5
|
+
*/
|
|
2
6
|
const MAP_EXTENSION_NAME_AD_TYPE = {
|
|
3
7
|
DoubleBox: AdType.SIDE_BY_SIDE,
|
|
4
8
|
};
|
|
@@ -38,7 +42,7 @@ export function parseVastExtension(root) {
|
|
|
38
42
|
};
|
|
39
43
|
return {
|
|
40
44
|
adName,
|
|
41
|
-
adType: MAP_EXTENSION_NAME_AD_TYPE[root.name]
|
|
45
|
+
adType: MAP_EXTENSION_NAME_AD_TYPE[root.name],
|
|
42
46
|
landscapeBackground: backgrounds.find((b) => b.orientation === 'landscape')?.url,
|
|
43
47
|
portraitBackground: backgrounds.find((b) => b.orientation === 'portrait')?.url,
|
|
44
48
|
sponsorLogo: logos.find((l) => l.role === 'header')?.url,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sponsor-logo slot selection for a **feature** ad unit (Insight, Prediction and Trivia today;
|
|
3
|
+
* Poll is the same shape and is a separate ticket).
|
|
4
|
+
*
|
|
5
|
+
* Deliberately NOT `selectCompanions` (`./external-ad.ts`). That rule is the *display*-unit rule:
|
|
6
|
+
* it promotes a companion to the logo slot only when the tag ships an exact 300x75 `<Companion>`
|
|
7
|
+
* next to at least one other. A feature ad-unit tag is typically a **single** creative — often a
|
|
8
|
+
* lone NonLinear `<StaticResource>` — where the ad IS the logo, which is exactly the shape that
|
|
9
|
+
* rule returns nothing for. `selectCompanions` stays untouched: SC-335's three shipped units
|
|
10
|
+
* depend on its exact behaviour and impression counts are billing-visible.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors iOS `SLRVASTSlotRole.logo` + `SLRVASTAssetPool`
|
|
13
|
+
* (`StreamLayerSDK/Frameworks/SLRGAMFramework/Sources/VASTSlotFiller/`):
|
|
14
|
+
*
|
|
15
|
+
* - **Images only.** A video-only tag yields no logo. `<Icon>` is never projected — icons hang off
|
|
16
|
+
* the `<Linear>` creative, which this selector does not look at. A `<StaticResource>` the tag
|
|
17
|
+
* declares as a non-image `creativeType` is skipped too, so a companion that lists a script
|
|
18
|
+
* before its image still resolves to the image (see `isImageResource`).
|
|
19
|
+
* - **Origin-agnostic.** A lone NonLinear `<StaticResource>` is as valid as a `<Companion>`.
|
|
20
|
+
* - **Aspect-dominant** toward 1:1: the smallest `|log(assetAspect) - log(1)|` wins, ties broken by
|
|
21
|
+
* larger area, then by higher pixel density. Aspect is a *preference, never a filter* — the only
|
|
22
|
+
* hard constraint is the asset kind, so a badly shaped asset still wins if it is the only one.
|
|
23
|
+
* - Assets whose intrinsic size the tag never declared rank last, but still beat nothing.
|
|
24
|
+
*
|
|
25
|
+
* The module also hosts iOS's second logo role, `SLRVASTSlotRole.cardLogo` (`selectCardLogo`) —
|
|
26
|
+
* the same ranking narrowed to `<Companion>` origins, for the hero-card display units.
|
|
27
|
+
*/
|
|
28
|
+
import { VastCompanionAd, VastCreativeCompanion, VastCreativeNonLinear, VastNonLinearAd } from '@dailymotion/vast-client';
|
|
29
|
+
/**
|
|
30
|
+
* One image asset that may fill a sponsor-logo slot. Carries the creative + variation pair so the
|
|
31
|
+
* caller can build a `VASTTracker` for it through `createVastTracker` without re-deriving them.
|
|
32
|
+
*/
|
|
33
|
+
export type SponsorLogoAsset = {
|
|
34
|
+
imageUrl: string;
|
|
35
|
+
clickUrl?: string;
|
|
36
|
+
width?: number;
|
|
37
|
+
height?: number;
|
|
38
|
+
/** Which VAST element the image came from. The slot does not care; the beacons do. */
|
|
39
|
+
origin: 'companion' | 'nonlinear';
|
|
40
|
+
/** The `<CompanionAds>` / `<NonLinearAds>` creative — the `VASTTracker` 3rd argument. */
|
|
41
|
+
creative: VastCreativeCompanion | VastCreativeNonLinear;
|
|
42
|
+
/** The `<Companion>` / `<NonLinear>` variation — the 4th argument; owns `creativeView`. */
|
|
43
|
+
variation: VastCompanionAd | VastNonLinearAd;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* What a caller hands the selector. A parsed `@dailymotion/vast-client` ad satisfies it.
|
|
47
|
+
* The published declarations must not name that package's types: its own `.d.ts` does not
|
|
48
|
+
* export them, and the local shim that does is not part of the tarball.
|
|
49
|
+
*/
|
|
50
|
+
export interface SponsorLogoSource {
|
|
51
|
+
creatives?: unknown;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Pick the image a parsed VAST ad offers for a sponsor-logo slot, or `undefined` when it offers
|
|
55
|
+
* none (a video-only tag, or an empty response). A caller that gets `undefined` must fall back to
|
|
56
|
+
* its own proto/Studio logo rather than suppressing the card.
|
|
57
|
+
*/
|
|
58
|
+
export declare const collectAssets: (ad: SponsorLogoSource) => SponsorLogoAsset[];
|
|
59
|
+
export declare const selectSponsorLogo: (ad: SponsorLogoSource) => SponsorLogoAsset | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* iOS `SLRVASTSlotRole.cardLogo` — the sponsor logo of a **hero card** (1:1 / 2:1 / Picture in
|
|
62
|
+
* Picture / Side-by-Side / Sponsor Frame). Same 1:1 aspect-dominant ranking as `.logo`, plus the
|
|
63
|
+
* one constraint that distinguishes the two roles:
|
|
64
|
+
*
|
|
65
|
+
* - **`requiredOrigin: .companion`.** A NonLinear image is a hero creative, never a logo. iOS's
|
|
66
|
+
* reason: a leftover hero-type asset — a second `<NonLinear>` overlay, or a stray image still in
|
|
67
|
+
* the pool after the hero role claimed its own — must never leak into the card's logo slot,
|
|
68
|
+
* where it would render at logo size as an unreadable smudge of the advertiser's key art.
|
|
69
|
+
* `nonlinear_full` is the fixture that pins it: `.logo` takes the 500x500 NonLinear, `.cardLogo`
|
|
70
|
+
* must take the 200x200 `<Companion>`.
|
|
71
|
+
* - **`exclude`** mirrors iOS's **greedy** `pool.select([hero, .cardLogo])`: roles resolve in array
|
|
72
|
+
* order and an asset a role claims is removed from the pool for every later role. We have no
|
|
73
|
+
* pool object, so the caller passes the assets the hero already claimed and they are filtered
|
|
74
|
+
* out here by `imageUrl`.
|
|
75
|
+
*/
|
|
76
|
+
export declare const selectCardLogo: (ad: SponsorLogoSource, exclude?: ({
|
|
77
|
+
imageUrl?: string;
|
|
78
|
+
} | undefined)[]) => SponsorLogoAsset | undefined;
|