@streamlayer/feature-advertisement 1.1.1-rc.1350 → 1.1.1-rc.3.pda7fd93b

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.
Files changed (60) hide show
  1. package/lib/advertisement.js +24 -9
  2. package/lib/cjs/advertisement.js +24 -9
  3. package/lib/cjs/index.d.ts +2 -0
  4. package/lib/cjs/index.js +2 -0
  5. package/lib/cjs/units/ad-macros.d.ts +7 -0
  6. package/lib/cjs/units/ad-macros.js +91 -0
  7. package/lib/cjs/units/external-ad.d.ts +69 -0
  8. package/lib/cjs/units/external-ad.js +93 -62
  9. package/lib/cjs/units/external-pause-ad.js +7 -1
  10. package/lib/cjs/units/gam.d.ts +11 -1
  11. package/lib/cjs/units/gam.js +65 -17
  12. package/lib/cjs/units/parse-vast-extensions.d.ts +10 -1
  13. package/lib/cjs/units/parse-vast-extensions.js +5 -1
  14. package/lib/cjs/units/sponsor-logo.d.ts +78 -0
  15. package/lib/cjs/units/sponsor-logo.js +194 -0
  16. package/lib/cjs/units/ssai-detector.js +27 -11
  17. package/lib/cjs/units/ssai-log.d.ts +16 -0
  18. package/lib/cjs/units/ssai-log.js +23 -0
  19. package/lib/cjs/units/ssai-providers.js +48 -11
  20. package/lib/classic/advertisement.js +34 -17
  21. package/lib/classic/cjs/advertisement.js +34 -17
  22. package/lib/classic/cjs/index.js +2 -0
  23. package/lib/classic/cjs/units/ad-macros.js +115 -0
  24. package/lib/classic/cjs/units/external-ad.js +111 -64
  25. package/lib/classic/cjs/units/external-pause-ad.js +5 -1
  26. package/lib/classic/cjs/units/gam.js +73 -10
  27. package/lib/classic/cjs/units/parse-vast-extensions.js +5 -1
  28. package/lib/classic/cjs/units/sponsor-logo.js +201 -0
  29. package/lib/classic/cjs/units/ssai-detector.js +33 -8
  30. package/lib/classic/cjs/units/ssai-log.js +23 -0
  31. package/lib/classic/cjs/units/ssai-providers.js +86 -24
  32. package/lib/classic/index.js +1 -0
  33. package/lib/classic/units/ad-macros.js +113 -0
  34. package/lib/classic/units/external-ad.js +109 -65
  35. package/lib/classic/units/external-pause-ad.js +5 -1
  36. package/lib/classic/units/gam.js +73 -10
  37. package/lib/classic/units/parse-vast-extensions.js +5 -1
  38. package/lib/classic/units/sponsor-logo.js +197 -0
  39. package/lib/classic/units/ssai-detector.js +33 -8
  40. package/lib/classic/units/ssai-log.js +21 -0
  41. package/lib/classic/units/ssai-providers.js +86 -24
  42. package/lib/index.d.ts +2 -0
  43. package/lib/index.js +1 -0
  44. package/lib/units/ad-macros.d.ts +7 -0
  45. package/lib/units/ad-macros.js +87 -0
  46. package/lib/units/external-ad.d.ts +69 -0
  47. package/lib/units/external-ad.js +92 -64
  48. package/lib/units/external-pause-ad.js +7 -1
  49. package/lib/units/gam.d.ts +11 -1
  50. package/lib/units/gam.js +65 -17
  51. package/lib/units/parse-vast-extensions.d.ts +10 -1
  52. package/lib/units/parse-vast-extensions.js +5 -1
  53. package/lib/units/sponsor-logo.d.ts +78 -0
  54. package/lib/units/sponsor-logo.js +188 -0
  55. package/lib/units/ssai-detector.js +27 -11
  56. package/lib/units/ssai-log.d.ts +16 -0
  57. package/lib/units/ssai-log.js +18 -0
  58. package/lib/units/ssai-providers.js +48 -11
  59. package/lib/vast-client.d.ts +9 -0
  60. package/package.json +8 -8
@@ -4,6 +4,7 @@ var sdkWebInterfaces = require('@streamlayer/sdk-web-interfaces');
4
4
  var sdkWebLogger = require('@streamlayer/sdk-web-logger');
5
5
  require('@streamlayer/sdk-web-api');
6
6
  var vastClient$1 = require('@dailymotion/vast-client');
7
+ var adMacros = require('./ad-macros.js');
7
8
 
8
9
  // Pins the `@dailymotion/vast-client` → `@types/vast-client` shim: TypeDoc
9
10
  // compiles this file through dependent packages' programs, whose tsconfig
@@ -118,7 +119,12 @@ const externalPauseAd = (instance) => {
118
119
  try {
119
120
  // `resolveAll: true` follows every `VASTAdTagURI` wrapper chain and resolves all
120
121
  // Ads (the default only resolves the first standalone Ad and stops at the wrapper).
121
- const parsedVAST = await vastClient.get(adUrl, { resolveAll: true });
122
+ // Pause-ad tags do not go through `gam()`, so fill the macros here — with the host's values
123
+ // as they are NOW, since this fetcher re-runs on every pause. The store key stays the
124
+ // configured url: `ExposedPauseAd` compares against it.
125
+ const parsedVAST = await vastClient.get(adMacros.substituteAdTagMacros(adUrl, instance.sdk.options.get().adTagParams), {
126
+ resolveAll: true,
127
+ });
122
128
  const template = options.template ?? 'default';
123
129
  // `fullbleed-video` needs an ad that actually carries a usable linear MP4. With
124
130
  // `resolveAll: true` the response can be a pod/wrapper chain, so prefer the first
@@ -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: (url: string, adContainer?: HTMLDivElement) => (() => Promise<{
21
+ export declare const gam: (rawUrl: string, adContainer?: HTMLDivElement, adTagParams?: AdTagParams) => (() => Promise<{
12
22
  url: URL;
13
23
  }>) | (() => Promise<{
14
24
  url: URL;
@@ -2,20 +2,69 @@
2
2
 
3
3
  var sdkWebStorage = require('@streamlayer/sdk-web-storage');
4
4
  var uuid = require('uuid');
5
+ var adMacros = require('./ad-macros.js');
5
6
 
6
- /**
7
- * Google PAL nonce manager + ad-tag resolution for programmatic (GAM) VAST requests.
8
- *
9
- * Lives here because the VAST fetcher (`units/external-ad.ts`) is its only consumer: this is the
10
- * request side of an ad tag, not the promotion side. The *composition* of a tag URL from the proto
11
- * (`processGamAdvertisement` / `selectGamOptions`) stays in `feature-gamification`, next to the
12
- * `$store` bridge that is its only consumer — so the two feature packages share no code and
13
- * neither depends on the other.
14
- */
15
7
  const urlIsNotGam = (url) => {
16
8
  return url.origin !== 'https://pubads.g.doubleclick.net';
17
9
  };
18
- const gam = (url, adContainer) => {
10
+ const palScriptUrl = 'https://imasdk.googleapis.com/pal/sdkloader/pal.js';
11
+ const getPal = () => {
12
+ // @ts-expect-error Google PAL is supplied by the host or the script loader below.
13
+ return typeof goog === 'undefined' ? undefined : goog.pal;
14
+ };
15
+ let palReady;
16
+ const ensurePal = async () => {
17
+ if (getPal()) {
18
+ return;
19
+ }
20
+ if (!palReady) {
21
+ palReady = new Promise((resolve, reject) => {
22
+ if (typeof document === 'undefined') {
23
+ reject(new Error('Google PAL requires a browser document'));
24
+ return;
25
+ }
26
+ const existing = document.querySelector(`script[src="${palScriptUrl}"]`);
27
+ const script = existing || document.createElement('script');
28
+ const finish = (error) => {
29
+ clearTimeout(timeout);
30
+ script.removeEventListener('load', onLoad);
31
+ script.removeEventListener('error', onError);
32
+ if (error) {
33
+ if (!existing) {
34
+ script.remove();
35
+ }
36
+ reject(error);
37
+ }
38
+ else {
39
+ resolve();
40
+ }
41
+ };
42
+ const onLoad = () => finish(getPal() ? undefined : new Error('Google PAL did not initialize'));
43
+ const onError = () => finish(new Error('Google PAL failed to load'));
44
+ const timeout = setTimeout(() => finish(new Error('Google PAL loading timed out')), 10000);
45
+ script.addEventListener('load', onLoad);
46
+ script.addEventListener('error', onError);
47
+ if (!existing) {
48
+ script.async = true;
49
+ script.src = palScriptUrl;
50
+ document.head.appendChild(script);
51
+ }
52
+ }).catch((error) => {
53
+ palReady = undefined;
54
+ throw error;
55
+ });
56
+ }
57
+ await palReady;
58
+ palReady = undefined;
59
+ };
60
+ const gam = (rawUrl, adContainer, adTagParams) => {
61
+ // Every VAST/VMAP tag funnels through here, so this is where the macros get filled — for GAM and
62
+ // for any other vendor alike. Studio stores a programmatic tag verbatim, so an unexpanded
63
+ // `[timestamp]` / `[clickmacro]` / `${GDPR}` would otherwise reach the ad server literal, and a
64
+ // literal macro comes back copied into the vendor's click-through. `adTagParams` are the host's
65
+ // consent and click values, read by the caller at request time. The GAM `[placeholder]` tokens
66
+ // are left alone here and filled per parameter below.
67
+ const url = adMacros.substituteAdTagMacros(rawUrl, adTagParams);
19
68
  const parsedUrl = (() => {
20
69
  try {
21
70
  const parsedUrl = new URL(url);
@@ -35,20 +84,19 @@ const gam = (url, adContainer) => {
35
84
  return { url: parsedUrl };
36
85
  };
37
86
  }
38
- // @ts-expect-error goog is injected by the externally-loaded Google PAL SDK
39
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
- const pal = (typeof goog === 'undefined' ? undefined : goog.pal);
41
- if (!pal || !parsedUrl) {
87
+ if (!parsedUrl) {
42
88
  // eslint-disable-next-line require-await
43
89
  return async () => {
44
90
  return undefined;
45
91
  };
46
92
  }
47
- const consentSettings = new pal.ConsentSettings();
48
- consentSettings.allowStorage = false;
49
- const nonceLoader = new pal.NonceLoader(consentSettings);
50
93
  const generateUrl = async () => {
51
94
  try {
95
+ await ensurePal();
96
+ const pal = getPal();
97
+ const consentSettings = new pal.ConsentSettings();
98
+ consentSettings.allowStorage = false;
99
+ const nonceLoader = new pal.NonceLoader(consentSettings);
52
100
  const parsedUrl = new URL(url);
53
101
  const request = new pal.NonceRequest();
54
102
  let deviceId = sdkWebStorage.safeLocalStorage().getItem('sl-device-id') || uuid.v4();
@@ -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
- adType: AdType;
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;
@@ -2,6 +2,10 @@
2
2
 
3
3
  var interactive_common_pb = require('@streamlayer/sl-eslib/interactive/interactive.common_pb');
4
4
 
5
+ /**
6
+ * Extension root element name → the placement it names. Only `DoubleBox` is defined by the
7
+ * StreamLayer extension schema today; anything else is a vendor extension we do not interpret.
8
+ */
5
9
  const MAP_EXTENSION_NAME_AD_TYPE = {
6
10
  DoubleBox: interactive_common_pb.AdType.SIDE_BY_SIDE,
7
11
  };
@@ -41,7 +45,7 @@ function parseVastExtension(root) {
41
45
  };
42
46
  return {
43
47
  adName,
44
- adType: MAP_EXTENSION_NAME_AD_TYPE[root.name] || interactive_common_pb.AdType.SIDE_BY_SIDE,
48
+ adType: MAP_EXTENSION_NAME_AD_TYPE[root.name],
45
49
  landscapeBackground: backgrounds.find((b) => b.orientation === 'landscape')?.url,
46
50
  portraitBackground: backgrounds.find((b) => b.orientation === 'portrait')?.url,
47
51
  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;
@@ -0,0 +1,194 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Sponsor-logo slot selection for a **feature** ad unit (Insight, Prediction and Trivia today;
5
+ * Poll is the same shape and is a separate ticket).
6
+ *
7
+ * Deliberately NOT `selectCompanions` (`./external-ad.ts`). That rule is the *display*-unit rule:
8
+ * it promotes a companion to the logo slot only when the tag ships an exact 300x75 `<Companion>`
9
+ * next to at least one other. A feature ad-unit tag is typically a **single** creative — often a
10
+ * lone NonLinear `<StaticResource>` — where the ad IS the logo, which is exactly the shape that
11
+ * rule returns nothing for. `selectCompanions` stays untouched: SC-335's three shipped units
12
+ * depend on its exact behaviour and impression counts are billing-visible.
13
+ *
14
+ * Mirrors iOS `SLRVASTSlotRole.logo` + `SLRVASTAssetPool`
15
+ * (`StreamLayerSDK/Frameworks/SLRGAMFramework/Sources/VASTSlotFiller/`):
16
+ *
17
+ * - **Images only.** A video-only tag yields no logo. `<Icon>` is never projected — icons hang off
18
+ * the `<Linear>` creative, which this selector does not look at. A `<StaticResource>` the tag
19
+ * declares as a non-image `creativeType` is skipped too, so a companion that lists a script
20
+ * before its image still resolves to the image (see `isImageResource`).
21
+ * - **Origin-agnostic.** A lone NonLinear `<StaticResource>` is as valid as a `<Companion>`.
22
+ * - **Aspect-dominant** toward 1:1: the smallest `|log(assetAspect) - log(1)|` wins, ties broken by
23
+ * larger area, then by higher pixel density. Aspect is a *preference, never a filter* — the only
24
+ * hard constraint is the asset kind, so a badly shaped asset still wins if it is the only one.
25
+ * - Assets whose intrinsic size the tag never declared rank last, but still beat nothing.
26
+ *
27
+ * The module also hosts iOS's second logo role, `SLRVASTSlotRole.cardLogo` (`selectCardLogo`) —
28
+ * the same ranking narrowed to `<Companion>` origins, for the hero-card display units.
29
+ */
30
+ // eslint-disable-next-line @typescript-eslint/triple-slash-reference
31
+ /// <reference path="../module.d.ts" />
32
+ /** `width`/`height` are XML attributes (strings at runtime); 0 and NaN both mean "not declared". */
33
+ const toSize = (value) => {
34
+ const size = Number(value);
35
+ return size > 0 ? size : undefined;
36
+ };
37
+ /**
38
+ * VAST 4 `<CompanionAds pxratio>`; `@dailymotion/vast-client` defaults it to `'1'` and the
39
+ * `@types/vast-client` shim does not declare it, hence the loose read.
40
+ */
41
+ const toPxRatio = (variation) => {
42
+ return Number(variation.pxratio) || 1;
43
+ };
44
+ /**
45
+ * `<StaticResource creativeType>`. The slot draws an `<img>`, so a resource the tag declares as
46
+ * anything else — the `application/x-shockwave-flash` or `application/javascript` creative a
47
+ * companion may list beside its image — must never fill it: it would swap the proto logo for a
48
+ * broken image AND still fire an impression for something nobody ever saw.
49
+ *
50
+ * An ABSENT type is not a rejection. VAST requires the attribute but real GAM image companions
51
+ * routinely omit it, and this module's only hard constraint is the asset kind, so an undeclared
52
+ * type stays a candidate. `@dailymotion/vast-client` writes `0` for a missing NonLinear
53
+ * `creativeType` and `null` for a missing companion one; both are falsy, hence the single guard.
54
+ */
55
+ const isImageResource = (creativeType) => {
56
+ if (!creativeType) {
57
+ return true;
58
+ }
59
+ return String(creativeType).toLowerCase().indexOf('image/') === 0;
60
+ };
61
+ /**
62
+ * The first resource a `<Companion>` declares as an image (or leaves undeclared). NOT
63
+ * `staticResources[0]`: the order in the tag is the advertiser's, and a non-image resource listed
64
+ * first must not hide a usable image behind it.
65
+ */
66
+ const pickCompanionImage = (variation) => {
67
+ const resources = variation.staticResources || [];
68
+ for (let i = 0; i < resources.length; i++) {
69
+ const resource = resources[i];
70
+ if (resource && resource.url && isImageResource(resource.creativeType)) {
71
+ return resource.url;
72
+ }
73
+ }
74
+ return undefined;
75
+ };
76
+ /** Distance from square on a log scale, so 2:1 and 1:2 are equally far from 1:1. */
77
+ const aspectDistance = (asset) => {
78
+ if (!asset.width || !asset.height) {
79
+ // Unknown intrinsic size — rank last, but still a candidate.
80
+ return Infinity;
81
+ }
82
+ return Math.abs(Math.log(asset.width / asset.height));
83
+ };
84
+ const collectAssetsFrom = (ad) => {
85
+ const assets = [];
86
+ for (let i = 0; i < ad.creatives.length; i++) {
87
+ const creative = ad.creatives[i];
88
+ if (creative.type === 'companion') {
89
+ const companionCreative = creative;
90
+ const variations = companionCreative.variations || [];
91
+ for (let j = 0; j < variations.length; j++) {
92
+ const variation = variations[j];
93
+ const imageUrl = variation && pickCompanionImage(variation);
94
+ if (imageUrl) {
95
+ assets.push({
96
+ imageUrl,
97
+ clickUrl: variation.companionClickThroughURLTemplate ?? undefined,
98
+ width: toSize(variation.width),
99
+ height: toSize(variation.height),
100
+ origin: 'companion',
101
+ creative: companionCreative,
102
+ variation,
103
+ });
104
+ }
105
+ }
106
+ continue;
107
+ }
108
+ if (creative.type === 'nonlinear') {
109
+ const nonLinearCreative = creative;
110
+ const variations = nonLinearCreative.variations || [];
111
+ for (let j = 0; j < variations.length; j++) {
112
+ const variation = variations[j];
113
+ // `type` is where the parser puts a `<NonLinear>`'s `creativeType`, and it is written last
114
+ // from the `<StaticResource>` element, so it describes `staticResource` whenever that is
115
+ // set. An HTML- or iframe-typed variation carries no `staticResource` and drops out here.
116
+ const imageUrl = variation && isImageResource(variation.type) ? variation.staticResource : undefined;
117
+ if (imageUrl) {
118
+ assets.push({
119
+ imageUrl,
120
+ // 6.4.2 emits `nonlinearClickThroughURLTemplate` (lower-case `l`); the shim declares
121
+ // the upper-case spelling. The runtime one is authoritative — see `getNonLinear`.
122
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
123
+ // @ts-ignore
124
+ clickUrl: variation.nonlinearClickThroughURLTemplate ?? undefined,
125
+ width: toSize(variation.width),
126
+ height: toSize(variation.height),
127
+ origin: 'nonlinear',
128
+ creative: nonLinearCreative,
129
+ variation,
130
+ });
131
+ }
132
+ }
133
+ }
134
+ }
135
+ return assets;
136
+ };
137
+ /**
138
+ * The shared ranking both roles use: aspect distance from 1:1, ties broken by larger area, then by
139
+ * higher pixel density. Aspect is a preference, never a filter — the best of a bad set still wins.
140
+ */
141
+ const rankBest = (assets) => {
142
+ if (!assets.length) {
143
+ return undefined;
144
+ }
145
+ let best = assets[0];
146
+ let bestDistance = aspectDistance(best);
147
+ let bestArea = (best.width || 0) * (best.height || 0);
148
+ let bestPxRatio = toPxRatio(best.variation);
149
+ for (let i = 1; i < assets.length; i++) {
150
+ const asset = assets[i];
151
+ const distance = aspectDistance(asset);
152
+ const area = (asset.width || 0) * (asset.height || 0);
153
+ const pxRatio = toPxRatio(asset.variation);
154
+ const wins = distance !== bestDistance ? distance < bestDistance : area !== bestArea ? area > bestArea : pxRatio > bestPxRatio;
155
+ if (wins) {
156
+ best = asset;
157
+ bestDistance = distance;
158
+ bestArea = area;
159
+ bestPxRatio = pxRatio;
160
+ }
161
+ }
162
+ return best;
163
+ };
164
+ /**
165
+ * Pick the image a parsed VAST ad offers for a sponsor-logo slot, or `undefined` when it offers
166
+ * none (a video-only tag, or an empty response). A caller that gets `undefined` must fall back to
167
+ * its own proto/Studio logo rather than suppressing the card.
168
+ */
169
+ const collectAssets = (ad) => collectAssetsFrom(ad);
170
+ const selectSponsorLogo = (ad) => rankBest(collectAssetsFrom(ad));
171
+ /**
172
+ * iOS `SLRVASTSlotRole.cardLogo` — the sponsor logo of a **hero card** (1:1 / 2:1 / Picture in
173
+ * Picture / Side-by-Side / Sponsor Frame). Same 1:1 aspect-dominant ranking as `.logo`, plus the
174
+ * one constraint that distinguishes the two roles:
175
+ *
176
+ * - **`requiredOrigin: .companion`.** A NonLinear image is a hero creative, never a logo. iOS's
177
+ * reason: a leftover hero-type asset — a second `<NonLinear>` overlay, or a stray image still in
178
+ * the pool after the hero role claimed its own — must never leak into the card's logo slot,
179
+ * where it would render at logo size as an unreadable smudge of the advertiser's key art.
180
+ * `nonlinear_full` is the fixture that pins it: `.logo` takes the 500x500 NonLinear, `.cardLogo`
181
+ * must take the 200x200 `<Companion>`.
182
+ * - **`exclude`** mirrors iOS's **greedy** `pool.select([hero, .cardLogo])`: roles resolve in array
183
+ * order and an asset a role claims is removed from the pool for every later role. We have no
184
+ * pool object, so the caller passes the assets the hero already claimed and they are filtered
185
+ * out here by `imageUrl`.
186
+ */
187
+ const selectCardLogo = (ad, exclude) => {
188
+ const claimed = (exclude || []).map((item) => item?.imageUrl).filter((url) => !!url);
189
+ return rankBest(collectAssetsFrom(ad).filter((asset) => asset.origin === 'companion' && claimed.indexOf(asset.imageUrl) === -1));
190
+ };
191
+
192
+ exports.collectAssets = collectAssets;
193
+ exports.selectCardLogo = selectCardLogo;
194
+ exports.selectSponsorLogo = selectSponsorLogo;
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  var sdkWebLogger = require('@streamlayer/sdk-web-logger');
4
+ var ssaiLog = require('./ssai-log.js');
4
5
 
5
6
  const log = sdkWebLogger.createLogger('ssai:detector');
6
7
  const RANGE_EXPIRY_MS = 10 * 60 * 1000;
@@ -491,16 +492,32 @@ const createSsaiDetector = (options) => {
491
492
  : videoRef && manifestUrl
492
493
  ? 'manifest-poll'
493
494
  : 'none';
494
- const recordDebugEvent = (type, detail, programMs) => {
495
- debugEvents.push({
496
- wallMs: Date.now(),
497
- programMs: programMs !== undefined ? programMs : currentProgramTimeMs(),
498
- type,
499
- detail,
500
- });
495
+ /**
496
+ * The `[label]` a detector event's log line opens with — what KIND of thing
497
+ * happened, for a reader scanning a window: a manifest tag came in (`hls`), a
498
+ * break edge fired (`break`), or playback crossed a tracking marker (`beacon`).
499
+ */
500
+ const eventLabel = (type) => {
501
+ if (type.indexOf('progress:') === 0)
502
+ return 'beacon';
503
+ if (type.indexOf('discovered') !== -1 || type === 'range-dropped')
504
+ return 'hls';
505
+ return 'break';
506
+ };
507
+ /**
508
+ * One call per detector event, feeding both consumers at once: the capped
509
+ * ring buffer behind `getDebugSnapshot()` and the `ssaiq` narrative logger.
510
+ * `data` is whatever the detector already holds for the event — the parsed
511
+ * range, marker or break window — so a log reader sees exactly what the
512
+ * detector acted on, with no second parse of the manifest for its benefit.
513
+ */
514
+ const recordDebugEvent = (type, detail, programMs, data) => {
515
+ const at = programMs !== undefined ? programMs : currentProgramTimeMs();
516
+ debugEvents.push({ wallMs: Date.now(), programMs: at, type, detail });
501
517
  if (debugEvents.length > DEBUG_EVENTS_MAX) {
502
518
  debugEvents.splice(0, debugEvents.length - DEBUG_EVENTS_MAX);
503
519
  }
520
+ ssaiLog.ssaiq.info({ ...data, programMs: at }, `[${eventLabel(type)}] ${type}${detail ? ` ${detail}` : ''}`);
504
521
  };
505
522
  const getVideo = () => player?.media ?? video ?? null;
506
523
  // Memoized view over the three marker maps. `updateActiveAd` polls 4×/s and
@@ -550,7 +567,6 @@ const createSsaiDetector = (options) => {
550
567
  }
551
568
  seenDroppedKeys.add(entry.key);
552
569
  recordDebugEvent('range-dropped', entry.detail);
553
- log.warn({ detail: entry.detail }, 'ssai DATERANGE dropped');
554
570
  }
555
571
  continue;
556
572
  }
@@ -558,7 +574,7 @@ const createSsaiDetector = (options) => {
558
574
  const existing = rangesById.get(entry.range.id);
559
575
  if (!existing) {
560
576
  changed = true;
561
- recordDebugEvent('range-discovered', `${entry.range.creativeId} ${debugClock(entry.range.startMs)}+${entry.range.durationSec}s`);
577
+ recordDebugEvent('range-discovered', `${entry.range.creativeId} ${debugClock(entry.range.startMs)}+${entry.range.durationSec}s`, undefined, { range: entry.range });
562
578
  notifyAdDiscovered(entry.range.creativeId);
563
579
  }
564
580
  else if (existing.startMs !== entry.range.startMs || existing.endMs !== entry.range.endMs) {
@@ -569,7 +585,7 @@ const createSsaiDetector = (options) => {
569
585
  else if (entry.kind === 'marker') {
570
586
  if (!seenMarkerAdIds.has(entry.marker.adId)) {
571
587
  seenMarkerAdIds.add(entry.marker.adId);
572
- recordDebugEvent('marker-ad-discovered', `${entry.marker.adId} ${entry.marker.event}@${debugClock(entry.marker.timeMs)}`);
588
+ recordDebugEvent('marker-ad-discovered', `${entry.marker.adId} ${entry.marker.event}@${debugClock(entry.marker.timeMs)}`, undefined, { marker: entry.marker });
573
589
  notifyAdDiscovered(entry.marker.adId);
574
590
  }
575
591
  const existing = markersById.get(entry.marker.id);
@@ -582,7 +598,7 @@ const createSsaiDetector = (options) => {
582
598
  const existing = adBreaksById.get(entry.window.id);
583
599
  if (!existing) {
584
600
  changed = true;
585
- recordDebugEvent('adbreak-discovered', `${debugClock(entry.window.startMs)}-${debugClock(entry.window.endMs)}`);
601
+ recordDebugEvent('adbreak-discovered', `${debugClock(entry.window.startMs)}-${debugClock(entry.window.endMs)}`, undefined, { window: entry.window });
586
602
  }
587
603
  else if (existing.startMs !== entry.window.startMs || existing.endMs !== entry.window.endMs) {
588
604
  changed = true;
@@ -0,0 +1,16 @@
1
+ import { type Logger } from '@streamlayer/sdk-web-logger';
2
+ /**
3
+ * `ssaiq` — the SSAI ad NARRATIVE log: one line per thing that happens to a
4
+ * server-stitched ad, in the order it happens. The manifest tag the playout
5
+ * published, the metadata round trip it triggers, the break the SDK opens from
6
+ * it, and every beacon fired against it.
7
+ *
8
+ * Named for the Quortex/Synamedia DAI integration it was built for, but it is
9
+ * the path itself that is logged, so a StreamLayer-dialect stream tells the same
10
+ * story. Detector internals — clock sources, poll ticks, fold bookkeeping — stay
11
+ * on `ssai:*`, where `SL_DEBUG` can reach them without filling a demo window.
12
+ *
13
+ * A host publishes it to the page with `SL_LOG_EVENTS=ssaiq` (see
14
+ * `sdk-web-logger`); the demo's log window renders exactly that.
15
+ */
16
+ export declare const ssaiq: Logger;
@@ -0,0 +1,23 @@
1
+ 'use strict';
2
+
3
+ var sdkWebLogger = require('@streamlayer/sdk-web-logger');
4
+
5
+ /**
6
+ * `ssaiq` — the SSAI ad NARRATIVE log: one line per thing that happens to a
7
+ * server-stitched ad, in the order it happens. The manifest tag the playout
8
+ * published, the metadata round trip it triggers, the break the SDK opens from
9
+ * it, and every beacon fired against it.
10
+ *
11
+ * Named for the Quortex/Synamedia DAI integration it was built for, but it is
12
+ * the path itself that is logged, so a StreamLayer-dialect stream tells the same
13
+ * story. Detector internals — clock sources, poll ticks, fold bookkeeping — stay
14
+ * on `ssai:*`, where `SL_DEBUG` can reach them without filling a demo window.
15
+ *
16
+ * A host publishes it to the page with `SL_LOG_EVENTS=ssaiq` (see
17
+ * `sdk-web-logger`); the demo's log window renders exactly that.
18
+ */
19
+ // Explicit type: the inferred pino type is not nameable across package
20
+ // boundaries (TS2742) when this file is compiled on its own.
21
+ const ssaiq = sdkWebLogger.createLogger('ssaiq');
22
+
23
+ exports.ssaiq = ssaiq;