@streamlayer/feature-advertisement 1.0.0 → 1.1.1-rc.10.p6eb7304a

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 (105) hide show
  1. package/classic/package.json +5 -0
  2. package/lib/advertisement.d.ts +6 -6
  3. package/lib/advertisement.js +32 -17
  4. package/lib/cjs/advertisement.d.ts +64 -0
  5. package/lib/cjs/advertisement.js +322 -0
  6. package/lib/cjs/index.d.ts +21 -0
  7. package/lib/cjs/index.js +41 -0
  8. package/lib/cjs/package.json +1 -0
  9. package/lib/cjs/units/ad-macros.d.ts +7 -0
  10. package/lib/cjs/units/ad-macros.js +91 -0
  11. package/lib/cjs/units/external-ad.d.ts +127 -0
  12. package/lib/cjs/units/external-ad.js +421 -0
  13. package/lib/cjs/units/external-pause-ad.d.ts +50 -0
  14. package/lib/cjs/units/external-pause-ad.js +259 -0
  15. package/lib/cjs/units/gam.d.ts +26 -0
  16. package/lib/cjs/units/gam.js +152 -0
  17. package/lib/cjs/units/parse-vast-extensions.d.ts +26 -0
  18. package/lib/cjs/units/parse-vast-extensions.js +56 -0
  19. package/lib/cjs/units/pause-ad-card.d.ts +49 -0
  20. package/lib/cjs/units/pause-ad-card.js +136 -0
  21. package/lib/cjs/units/pause-ad.d.ts +16 -0
  22. package/lib/cjs/units/pause-ad.js +52 -0
  23. package/lib/cjs/units/sponsor-logo.d.ts +78 -0
  24. package/lib/cjs/units/sponsor-logo.js +194 -0
  25. package/lib/cjs/units/ssai-ad-break.d.ts +21 -0
  26. package/lib/cjs/units/ssai-ad-break.js +11 -0
  27. package/lib/cjs/units/ssai-creative.d.ts +89 -0
  28. package/lib/cjs/units/ssai-creative.js +136 -0
  29. package/lib/cjs/units/ssai-detector.d.ts +180 -0
  30. package/lib/cjs/units/ssai-detector.js +1172 -0
  31. package/lib/cjs/units/ssai-log.d.ts +16 -0
  32. package/lib/cjs/units/ssai-log.js +23 -0
  33. package/lib/cjs/units/ssai-preload.d.ts +20 -0
  34. package/lib/cjs/units/ssai-preload.js +62 -0
  35. package/lib/cjs/units/ssai-providers.d.ts +117 -0
  36. package/lib/cjs/units/ssai-providers.js +300 -0
  37. package/lib/cjs/units/vast-tracker.d.ts +45 -0
  38. package/lib/cjs/units/vast-tracker.js +64 -0
  39. package/lib/classic/_virtual/_rollupPluginBabelHelpers.js +71 -0
  40. package/lib/classic/advertisement.js +348 -0
  41. package/lib/classic/cjs/_virtual/_rollupPluginBabelHelpers.js +77 -0
  42. package/lib/classic/cjs/advertisement.js +350 -0
  43. package/lib/classic/cjs/index.js +49 -0
  44. package/lib/classic/cjs/package.json +1 -0
  45. package/lib/classic/cjs/units/ad-macros.js +115 -0
  46. package/lib/classic/cjs/units/external-ad.js +500 -0
  47. package/lib/classic/cjs/units/external-pause-ad.js +278 -0
  48. package/lib/classic/cjs/units/gam.js +173 -0
  49. package/lib/classic/cjs/units/parse-vast-extensions.js +60 -0
  50. package/lib/classic/cjs/units/pause-ad-card.js +134 -0
  51. package/lib/classic/cjs/units/pause-ad.js +72 -0
  52. package/lib/classic/cjs/units/sponsor-logo.js +201 -0
  53. package/lib/classic/cjs/units/ssai-ad-break.js +11 -0
  54. package/lib/classic/cjs/units/ssai-creative.js +142 -0
  55. package/lib/classic/cjs/units/ssai-detector.js +1195 -0
  56. package/lib/classic/cjs/units/ssai-log.js +23 -0
  57. package/lib/classic/cjs/units/ssai-preload.js +72 -0
  58. package/lib/classic/cjs/units/ssai-providers.js +361 -0
  59. package/lib/classic/cjs/units/vast-tracker.js +81 -0
  60. package/lib/classic/index.js +29 -0
  61. package/lib/classic/units/ad-macros.js +113 -0
  62. package/lib/classic/units/external-ad.js +491 -0
  63. package/lib/classic/units/external-pause-ad.js +275 -0
  64. package/lib/classic/units/gam.js +171 -0
  65. package/lib/classic/units/parse-vast-extensions.js +58 -0
  66. package/lib/classic/units/pause-ad-card.js +131 -0
  67. package/lib/classic/units/pause-ad.js +70 -0
  68. package/lib/classic/units/sponsor-logo.js +197 -0
  69. package/lib/classic/units/ssai-ad-break.js +9 -0
  70. package/lib/classic/units/ssai-creative.js +140 -0
  71. package/lib/classic/units/ssai-detector.js +1192 -0
  72. package/lib/classic/units/ssai-log.js +21 -0
  73. package/lib/classic/units/ssai-preload.js +68 -0
  74. package/lib/classic/units/ssai-providers.js +351 -0
  75. package/lib/classic/units/vast-tracker.js +77 -0
  76. package/lib/index.d.ts +15 -12
  77. package/lib/index.js +14 -7
  78. package/lib/units/ad-macros.d.ts +7 -0
  79. package/lib/units/ad-macros.js +87 -0
  80. package/lib/units/external-ad.d.ts +71 -2
  81. package/lib/units/external-ad.js +94 -66
  82. package/lib/units/external-pause-ad.js +7 -1
  83. package/lib/units/gam.d.ts +11 -1
  84. package/lib/units/gam.js +65 -17
  85. package/lib/units/parse-vast-extensions.d.ts +10 -1
  86. package/lib/units/parse-vast-extensions.js +5 -1
  87. package/lib/units/pause-ad-card.d.ts +49 -0
  88. package/lib/units/pause-ad-card.js +130 -0
  89. package/lib/units/pause-ad.d.ts +1 -18
  90. package/lib/units/pause-ad.js +2 -49
  91. package/lib/units/sponsor-logo.d.ts +78 -0
  92. package/lib/units/sponsor-logo.js +188 -0
  93. package/lib/units/ssai-ad-break.d.ts +1 -1
  94. package/lib/units/ssai-creative.d.ts +1 -1
  95. package/lib/units/ssai-creative.js +1 -1
  96. package/lib/units/ssai-detector.d.ts +1 -1
  97. package/lib/units/ssai-detector.js +27 -11
  98. package/lib/units/ssai-log.d.ts +16 -0
  99. package/lib/units/ssai-log.js +18 -0
  100. package/lib/units/ssai-preload.d.ts +1 -1
  101. package/lib/units/ssai-providers.d.ts +1 -1
  102. package/lib/units/ssai-providers.js +48 -11
  103. package/lib/units/vast-tracker.d.ts +1 -1
  104. package/lib/vast-client.d.ts +9 -0
  105. package/package.json +43 -10
@@ -0,0 +1,49 @@
1
+ import { type Question } from '@streamlayer/sl-eslib/interactive/interactive.common_pb';
2
+ export type PauseAdLayout = 'left' | 'center' | 'image';
3
+ /**
4
+ * Which full-bleed renderer draws the card. Absent === `'standard'`.
5
+ *
6
+ * - `'standard'` — the Standard/Image pause ad (promotion card).
7
+ * - `'insight'` — the Insight Pause Ad (factoid card): sponsor logo, heading and a
8
+ * single-column body beside an optional media image (SC-353).
9
+ */
10
+ export type PauseAdKind = 'standard' | 'insight';
11
+ /** How the Insight media image is cropped — mirrors Studio's Media switcher. */
12
+ export type PauseAdMediaMode = 'image' | 'circle' | 'none';
13
+ /**
14
+ * Insight body cap (SC-353). Studio enforces it on the Additional Content field; the
15
+ * SDK clamps as well so a card authored before the limit landed cannot overflow the
16
+ * screen — the format is explicitly designed to fit without scrolling.
17
+ */
18
+ export declare const INSIGHT_BODY_MAX_LENGTH = 600;
19
+ export type PauseAdCard = {
20
+ id: string;
21
+ parentId: string;
22
+ /** Optional: a video-only card (programmatic VAST, SC-322) has no image. */
23
+ backgroundImageSrc?: string;
24
+ /** Linear MP4 from VAST → renders full-bleed `<video>` instead of the image (SC-322). */
25
+ backgroundVideoSrc?: string;
26
+ layout: PauseAdLayout;
27
+ isInteractive: boolean;
28
+ logoSrc?: string;
29
+ title?: string;
30
+ description?: string;
31
+ ctaLabel?: string;
32
+ ctaUrl?: string;
33
+ qrCodeUrl?: string;
34
+ qrCodeLabel?: string;
35
+ /** Absent === `'standard'`. */
36
+ kind?: PauseAdKind;
37
+ /** Insight only: the Media image (`FactoidOptions.image`). */
38
+ mediaSrc?: string;
39
+ /** Insight only: how `mediaSrc` is cropped. */
40
+ mediaMode?: PauseAdMediaMode;
41
+ };
42
+ /**
43
+ * The `pauseAdList` response, shaped for the renderers: keep the cards the SDK has chrome for,
44
+ * turn each into a `PauseAdCard`, drop the ones that cannot be drawn.
45
+ *
46
+ * Pure on purpose — `pause-ad.ts` owns the store and the transport, this owns the card shapes,
47
+ * and only this half needs unit tests.
48
+ */
49
+ export declare const parsePauseAdCards: (questions: Question[]) => PauseAdCard[];
@@ -0,0 +1,130 @@
1
+ import { CardFormat, FactoidMediaMode, NotificationSponsorLogoMode, PauseAdLayout as PauseAdLayoutProto, PromotionButtonType, PromotionFormat, QRCode, UseGeneralLogo, UserAccountInfo, } from '@streamlayer/sl-eslib/interactive/interactive.common_pb';
2
+ /**
3
+ * Insight body cap (SC-353). Studio enforces it on the Additional Content field; the
4
+ * SDK clamps as well so a card authored before the limit landed cannot overflow the
5
+ * screen — the format is explicitly designed to fit without scrolling.
6
+ */
7
+ export const INSIGHT_BODY_MAX_LENGTH = 600;
8
+ /**
9
+ * Sponsor logo for a factoid card. Same General-tab opt-in as the promotion path below,
10
+ * but the logo lives on `question.promotion.sponsor` (a factoid card carries no
11
+ * `PromotionOptions`) and Studio writes the uploaded file to `logo`, not `centerLogo`.
12
+ *
13
+ * `logo` therefore WINS over `centerLogo`: the latter is an optional field carried over from the
14
+ * notification/header surfaces, so a card that kept a stale `centerLogo` from an earlier revision
15
+ * would otherwise render it instead of the Insight logo the author just uploaded.
16
+ */
17
+ const resolveInsightSponsorLogo = (question) => {
18
+ const sponsor = question.promotion?.sponsor;
19
+ if (sponsor?.logoPosition === NotificationSponsorLogoMode.NOTIFICATION_SPONSOR_LOGO_NONE) {
20
+ return undefined;
21
+ }
22
+ return sponsor?.useGeneralLogo === UseGeneralLogo.ENABLED
23
+ ? question.promotion?.generalTabMetadata?.advertiserTransparentLogo || undefined
24
+ : sponsor?.logo || sponsor?.centerLogo || undefined;
25
+ };
26
+ const MEDIA_MODE_BY_PROTO = {
27
+ [FactoidMediaMode.IMAGE]: 'image',
28
+ [FactoidMediaMode.CIRCLE]: 'circle',
29
+ };
30
+ /**
31
+ * Insight Pause Ad, Full Bleed (SC-353). A factoid card, so none of the promotion
32
+ * plumbing above applies: the heading is the question subject, the body is
33
+ * `FactoidOptions.body`, and the sponsor logo comes off `question.promotion.sponsor`.
34
+ *
35
+ * `FACTOID_MEDIA_MODE_VIDEO` is deliberately treated as "no media": the full-bleed
36
+ * format has no player chrome and no way to start playback, unlike the sidebar variant.
37
+ */
38
+ const parseInsightPauseAdCard = (question) => {
39
+ const factoidOptions = question.options?.options.case === 'factoid' ? question.options.options.value : undefined;
40
+ if (!factoidOptions) {
41
+ return null;
42
+ }
43
+ const mediaMode = MEDIA_MODE_BY_PROTO[factoidOptions.mode] ?? 'none';
44
+ const body = factoidOptions.body.slice(0, INSIGHT_BODY_MAX_LENGTH);
45
+ return {
46
+ id: question.id,
47
+ parentId: question.eventId || question.id,
48
+ // `landscapeUrl` ONLY. `portraitUrl` keeps stale artwork from earlier revisions of the
49
+ // card that Studio no longer shows in its Background Image slot, so falling back to it
50
+ // paints a background the author has already removed. Unlike the standard card, an
51
+ // Insight one renders fine with no background at all — its copy is the ad.
52
+ backgroundImageSrc: question.backgroundImage?.landscapeUrl || undefined,
53
+ kind: 'insight',
54
+ layout: 'left',
55
+ isInteractive: false,
56
+ logoSrc: resolveInsightSponsorLogo(question),
57
+ title: question.subject || undefined,
58
+ description: body || undefined,
59
+ mediaSrc: mediaMode === 'none' ? undefined : factoidOptions.image || undefined,
60
+ mediaMode,
61
+ };
62
+ };
63
+ const parsePauseAdCard = (question) => {
64
+ if (question.options?.options.case === 'factoid') {
65
+ return parseInsightPauseAdCard(question);
66
+ }
67
+ const backgroundImageSrc = question.backgroundImage?.landscapeUrl;
68
+ if (!backgroundImageSrc) {
69
+ return null;
70
+ }
71
+ const promotionOptions = question.options?.options.case === 'promotion' ? question.options.options.value : undefined;
72
+ const pauseAdOptions = promotionOptions?.pauseAdOptions;
73
+ const optionsSponsor = promotionOptions?.sponsor;
74
+ const layoutProto = pauseAdOptions?.layout ?? PauseAdLayoutProto.UNSET;
75
+ const layout = layoutProto === PauseAdLayoutProto.CENTER ? 'center' : layoutProto === PauseAdLayoutProto.IMAGE ? 'image' : 'left';
76
+ const isInteractive = promotionOptions?.buttonType === PromotionButtonType.SEND_TO_USER;
77
+ const hasQr = pauseAdOptions?.qrCode === QRCode.QR_CODE_ENABLED && !!pauseAdOptions?.qrCodeUrl;
78
+ let ctaLabel;
79
+ let ctaUrl;
80
+ if (isInteractive && promotionOptions?.buttonOptions.case === 'sendToUserOptions') {
81
+ const accountInfo = promotionOptions.buttonOptions.value.userAccountInfo;
82
+ const isPhone = accountInfo === UserAccountInfo.PHONE;
83
+ ctaUrl = isPhone ? 'sendToPhone:' : 'sendToEmail:';
84
+ ctaLabel = isPhone ? 'Send info to account phone' : 'Send info to account email';
85
+ }
86
+ const logoSrc = optionsSponsor?.logoPosition === NotificationSponsorLogoMode.NOTIFICATION_SPONSOR_LOGO_NONE
87
+ ? undefined
88
+ : optionsSponsor?.useGeneralLogo === UseGeneralLogo.ENABLED
89
+ ? question.promotion?.generalTabMetadata?.advertiserTransparentLogo || undefined
90
+ : optionsSponsor?.centerLogo || undefined;
91
+ return {
92
+ id: question.id,
93
+ parentId: question.eventId || question.id,
94
+ backgroundImageSrc,
95
+ layout,
96
+ logoSrc,
97
+ title: promotionOptions?.banner?.title || undefined,
98
+ description: promotionOptions?.banner?.body || undefined,
99
+ isInteractive,
100
+ ctaLabel: ctaLabel || undefined,
101
+ ctaUrl: ctaUrl || undefined,
102
+ qrCodeUrl: hasQr ? (pauseAdOptions?.qrCodeUrl ?? undefined) : undefined,
103
+ qrCodeLabel: hasQr ? (pauseAdOptions?.qrCodeLabel ?? undefined) : undefined,
104
+ };
105
+ };
106
+ /**
107
+ * Which cards from `pauseAdList` the SDK can actually draw. Both renderers are full bleed; a
108
+ * Sidebar-format pause ad has no chrome yet and is skipped.
109
+ */
110
+ const isFullBleedPauseAd = (question) => {
111
+ if (question.options?.options.case === 'factoid') {
112
+ // Insight Pause Ad (SC-353). Studio does not write `CardFormat` on these cards yet
113
+ // (SLW-7062), so an UNSET format is taken as Full Bleed — the only Insight pause-ad chrome
114
+ // the SDK has. An explicit Sidebar stays out. Drop the UNSET arm once Studio sets the field.
115
+ return question.format === CardFormat.FULL_BLEED || question.format === CardFormat.UNSET;
116
+ }
117
+ const promotionOptions = question.options?.options.case === 'promotion' ? question.options.options.value : undefined;
118
+ return promotionOptions?.format === PromotionFormat.FULL_BLEED || question.format === CardFormat.FULL_BLEED;
119
+ };
120
+ /**
121
+ * The `pauseAdList` response, shaped for the renderers: keep the cards the SDK has chrome for,
122
+ * turn each into a `PauseAdCard`, drop the ones that cannot be drawn.
123
+ *
124
+ * Pure on purpose — `pause-ad.ts` owns the store and the transport, this owns the card shapes,
125
+ * and only this half needs unit tests.
126
+ */
127
+ export const parsePauseAdCards = (questions) => questions
128
+ .filter(isFullBleedPauseAd)
129
+ .map(parsePauseAdCard)
130
+ .filter((card) => card !== null);
@@ -1,24 +1,7 @@
1
1
  import { createSingleStore, StreamLayerContext } from '@streamlayer/sdk-web-interfaces';
2
2
  import '@streamlayer/sdk-web-api';
3
3
  import '@streamlayer/sdk-web-core/store';
4
- export type PauseAdLayout = 'left' | 'center' | 'image';
5
- export type PauseAdCard = {
6
- id: string;
7
- parentId: string;
8
- /** Optional: a video-only card (programmatic VAST, SC-322) has no image. */
9
- backgroundImageSrc?: string;
10
- /** Linear MP4 from VAST → renders full-bleed `<video>` instead of the image (SC-322). */
11
- backgroundVideoSrc?: string;
12
- layout: PauseAdLayout;
13
- isInteractive: boolean;
14
- logoSrc?: string;
15
- title?: string;
16
- description?: string;
17
- ctaLabel?: string;
18
- ctaUrl?: string;
19
- qrCodeUrl?: string;
20
- qrCodeLabel?: string;
21
- };
4
+ import { type PauseAdCard } from './pause-ad-card.js';
22
5
  export type PauseAdListOptions = {
23
6
  refetchInterval: number;
24
7
  $enabled: boolean;
@@ -2,49 +2,9 @@ import { createLogger } from '@streamlayer/sdk-web-logger';
2
2
  import { atom } from 'nanostores';
3
3
  import '@streamlayer/sdk-web-api';
4
4
  import '@streamlayer/sdk-web-core/store';
5
- import { CardFormat, NotificationSponsorLogoMode, PauseAdLayout as PauseAdLayoutProto, PromotionButtonType, PromotionFormat, QRCode, UseGeneralLogo, UserAccountInfo, } from '@streamlayer/sl-eslib/interactive/interactive.common_pb';
6
5
  import { Feed } from '@streamlayer/sl-eslib/interactive/feed/interactive.feed_pb';
6
+ import { parsePauseAdCards } from './pause-ad-card.js';
7
7
  const log = createLogger('advertisement:pause-ad');
8
- const parsePauseAdCard = (question) => {
9
- const backgroundImageSrc = question.backgroundImage?.landscapeUrl;
10
- if (!backgroundImageSrc) {
11
- return null;
12
- }
13
- const promotionOptions = question.options?.options.case === 'promotion' ? question.options.options.value : undefined;
14
- const pauseAdOptions = promotionOptions?.pauseAdOptions;
15
- const optionsSponsor = promotionOptions?.sponsor;
16
- const layoutProto = pauseAdOptions?.layout ?? PauseAdLayoutProto.UNSET;
17
- const layout = layoutProto === PauseAdLayoutProto.CENTER ? 'center' : layoutProto === PauseAdLayoutProto.IMAGE ? 'image' : 'left';
18
- const isInteractive = promotionOptions?.buttonType === PromotionButtonType.SEND_TO_USER;
19
- const hasQr = pauseAdOptions?.qrCode === QRCode.QR_CODE_ENABLED && !!pauseAdOptions?.qrCodeUrl;
20
- let ctaLabel;
21
- let ctaUrl;
22
- if (isInteractive && promotionOptions?.buttonOptions.case === 'sendToUserOptions') {
23
- const accountInfo = promotionOptions.buttonOptions.value.userAccountInfo;
24
- const isPhone = accountInfo === UserAccountInfo.PHONE;
25
- ctaUrl = isPhone ? 'sendToPhone:' : 'sendToEmail:';
26
- ctaLabel = isPhone ? 'Send info to account phone' : 'Send info to account email';
27
- }
28
- const logoSrc = optionsSponsor?.logoPosition === NotificationSponsorLogoMode.NOTIFICATION_SPONSOR_LOGO_NONE
29
- ? undefined
30
- : optionsSponsor?.useGeneralLogo === UseGeneralLogo.ENABLED
31
- ? question.promotion?.generalTabMetadata?.advertiserTransparentLogo || undefined
32
- : optionsSponsor?.centerLogo || undefined;
33
- return {
34
- id: question.id,
35
- parentId: question.eventId || question.id,
36
- backgroundImageSrc,
37
- layout,
38
- logoSrc,
39
- title: promotionOptions?.banner?.title || undefined,
40
- description: promotionOptions?.banner?.body || undefined,
41
- isInteractive,
42
- ctaLabel: ctaLabel || undefined,
43
- ctaUrl: ctaUrl || undefined,
44
- qrCodeUrl: hasQr ? (pauseAdOptions?.qrCodeUrl ?? undefined) : undefined,
45
- qrCodeLabel: hasQr ? (pauseAdOptions?.qrCodeLabel ?? undefined) : undefined,
46
- };
47
- };
48
8
  export const pauseAdListStore = (instance) => {
49
9
  const $slStreamId = instance.stores.slStreamId.getAtomStore();
50
10
  return ($enabled, options) => {
@@ -61,14 +21,7 @@ export const pauseAdListStore = (instance) => {
61
21
  }
62
22
  log.debug({ eventId }, 'fetching pause ad list');
63
23
  const res = await client.pauseAdList({ eventId });
64
- const questions = res.data ?? [];
65
- const cards = questions
66
- .filter((q) => {
67
- const opts = q.options?.options.case === 'promotion' ? q.options.options.value : undefined;
68
- return opts?.format === PromotionFormat.FULL_BLEED || q.format === CardFormat.FULL_BLEED;
69
- })
70
- .map((q) => parsePauseAdCard(q))
71
- .filter((c) => c !== null);
24
+ const cards = parsePauseAdCards(res.data ?? []);
72
25
  log.debug({ count: cards.length }, 'parsed %d fullbleed pause ad cards');
73
26
  return cards;
74
27
  },
@@ -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,188 @@
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
+ // eslint-disable-next-line @typescript-eslint/triple-slash-reference
29
+ /// <reference path="../module.d.ts" />
30
+ /** `width`/`height` are XML attributes (strings at runtime); 0 and NaN both mean "not declared". */
31
+ const toSize = (value) => {
32
+ const size = Number(value);
33
+ return size > 0 ? size : undefined;
34
+ };
35
+ /**
36
+ * VAST 4 `<CompanionAds pxratio>`; `@dailymotion/vast-client` defaults it to `'1'` and the
37
+ * `@types/vast-client` shim does not declare it, hence the loose read.
38
+ */
39
+ const toPxRatio = (variation) => {
40
+ return Number(variation.pxratio) || 1;
41
+ };
42
+ /**
43
+ * `<StaticResource creativeType>`. The slot draws an `<img>`, so a resource the tag declares as
44
+ * anything else — the `application/x-shockwave-flash` or `application/javascript` creative a
45
+ * companion may list beside its image — must never fill it: it would swap the proto logo for a
46
+ * broken image AND still fire an impression for something nobody ever saw.
47
+ *
48
+ * An ABSENT type is not a rejection. VAST requires the attribute but real GAM image companions
49
+ * routinely omit it, and this module's only hard constraint is the asset kind, so an undeclared
50
+ * type stays a candidate. `@dailymotion/vast-client` writes `0` for a missing NonLinear
51
+ * `creativeType` and `null` for a missing companion one; both are falsy, hence the single guard.
52
+ */
53
+ const isImageResource = (creativeType) => {
54
+ if (!creativeType) {
55
+ return true;
56
+ }
57
+ return String(creativeType).toLowerCase().indexOf('image/') === 0;
58
+ };
59
+ /**
60
+ * The first resource a `<Companion>` declares as an image (or leaves undeclared). NOT
61
+ * `staticResources[0]`: the order in the tag is the advertiser's, and a non-image resource listed
62
+ * first must not hide a usable image behind it.
63
+ */
64
+ const pickCompanionImage = (variation) => {
65
+ const resources = variation.staticResources || [];
66
+ for (let i = 0; i < resources.length; i++) {
67
+ const resource = resources[i];
68
+ if (resource && resource.url && isImageResource(resource.creativeType)) {
69
+ return resource.url;
70
+ }
71
+ }
72
+ return undefined;
73
+ };
74
+ /** Distance from square on a log scale, so 2:1 and 1:2 are equally far from 1:1. */
75
+ const aspectDistance = (asset) => {
76
+ if (!asset.width || !asset.height) {
77
+ // Unknown intrinsic size — rank last, but still a candidate.
78
+ return Infinity;
79
+ }
80
+ return Math.abs(Math.log(asset.width / asset.height));
81
+ };
82
+ const collectAssetsFrom = (ad) => {
83
+ const assets = [];
84
+ for (let i = 0; i < ad.creatives.length; i++) {
85
+ const creative = ad.creatives[i];
86
+ if (creative.type === 'companion') {
87
+ const companionCreative = creative;
88
+ const variations = companionCreative.variations || [];
89
+ for (let j = 0; j < variations.length; j++) {
90
+ const variation = variations[j];
91
+ const imageUrl = variation && pickCompanionImage(variation);
92
+ if (imageUrl) {
93
+ assets.push({
94
+ imageUrl,
95
+ clickUrl: variation.companionClickThroughURLTemplate ?? undefined,
96
+ width: toSize(variation.width),
97
+ height: toSize(variation.height),
98
+ origin: 'companion',
99
+ creative: companionCreative,
100
+ variation,
101
+ });
102
+ }
103
+ }
104
+ continue;
105
+ }
106
+ if (creative.type === 'nonlinear') {
107
+ const nonLinearCreative = creative;
108
+ const variations = nonLinearCreative.variations || [];
109
+ for (let j = 0; j < variations.length; j++) {
110
+ const variation = variations[j];
111
+ // `type` is where the parser puts a `<NonLinear>`'s `creativeType`, and it is written last
112
+ // from the `<StaticResource>` element, so it describes `staticResource` whenever that is
113
+ // set. An HTML- or iframe-typed variation carries no `staticResource` and drops out here.
114
+ const imageUrl = variation && isImageResource(variation.type) ? variation.staticResource : undefined;
115
+ if (imageUrl) {
116
+ assets.push({
117
+ imageUrl,
118
+ // 6.4.2 emits `nonlinearClickThroughURLTemplate` (lower-case `l`); the shim declares
119
+ // the upper-case spelling. The runtime one is authoritative — see `getNonLinear`.
120
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
121
+ // @ts-ignore
122
+ clickUrl: variation.nonlinearClickThroughURLTemplate ?? undefined,
123
+ width: toSize(variation.width),
124
+ height: toSize(variation.height),
125
+ origin: 'nonlinear',
126
+ creative: nonLinearCreative,
127
+ variation,
128
+ });
129
+ }
130
+ }
131
+ }
132
+ }
133
+ return assets;
134
+ };
135
+ /**
136
+ * The shared ranking both roles use: aspect distance from 1:1, ties broken by larger area, then by
137
+ * higher pixel density. Aspect is a preference, never a filter — the best of a bad set still wins.
138
+ */
139
+ const rankBest = (assets) => {
140
+ if (!assets.length) {
141
+ return undefined;
142
+ }
143
+ let best = assets[0];
144
+ let bestDistance = aspectDistance(best);
145
+ let bestArea = (best.width || 0) * (best.height || 0);
146
+ let bestPxRatio = toPxRatio(best.variation);
147
+ for (let i = 1; i < assets.length; i++) {
148
+ const asset = assets[i];
149
+ const distance = aspectDistance(asset);
150
+ const area = (asset.width || 0) * (asset.height || 0);
151
+ const pxRatio = toPxRatio(asset.variation);
152
+ const wins = distance !== bestDistance ? distance < bestDistance : area !== bestArea ? area > bestArea : pxRatio > bestPxRatio;
153
+ if (wins) {
154
+ best = asset;
155
+ bestDistance = distance;
156
+ bestArea = area;
157
+ bestPxRatio = pxRatio;
158
+ }
159
+ }
160
+ return best;
161
+ };
162
+ /**
163
+ * Pick the image a parsed VAST ad offers for a sponsor-logo slot, or `undefined` when it offers
164
+ * none (a video-only tag, or an empty response). A caller that gets `undefined` must fall back to
165
+ * its own proto/Studio logo rather than suppressing the card.
166
+ */
167
+ export const collectAssets = (ad) => collectAssetsFrom(ad);
168
+ export const selectSponsorLogo = (ad) => rankBest(collectAssetsFrom(ad));
169
+ /**
170
+ * iOS `SLRVASTSlotRole.cardLogo` — the sponsor logo of a **hero card** (1:1 / 2:1 / Picture in
171
+ * Picture / Side-by-Side / Sponsor Frame). Same 1:1 aspect-dominant ranking as `.logo`, plus the
172
+ * one constraint that distinguishes the two roles:
173
+ *
174
+ * - **`requiredOrigin: .companion`.** A NonLinear image is a hero creative, never a logo. iOS's
175
+ * reason: a leftover hero-type asset — a second `<NonLinear>` overlay, or a stray image still in
176
+ * the pool after the hero role claimed its own — must never leak into the card's logo slot,
177
+ * where it would render at logo size as an unreadable smudge of the advertiser's key art.
178
+ * `nonlinear_full` is the fixture that pins it: `.logo` takes the 500x500 NonLinear, `.cardLogo`
179
+ * must take the 200x200 `<Companion>`.
180
+ * - **`exclude`** mirrors iOS's **greedy** `pool.select([hero, .cardLogo])`: roles resolve in array
181
+ * order and an asset a role claims is removed from the pool for every later role. We have no
182
+ * pool object, so the caller passes the assets the hero already claimed and they are filtered
183
+ * out here by `imageUrl`.
184
+ */
185
+ export const selectCardLogo = (ad, exclude) => {
186
+ const claimed = (exclude || []).map((item) => item?.imageUrl).filter((url) => !!url);
187
+ return rankBest(collectAssetsFrom(ad).filter((asset) => asset.origin === 'companion' && claimed.indexOf(asset.imageUrl) === -1));
188
+ };
@@ -1,5 +1,5 @@
1
1
  import { createSingleStore } from '@streamlayer/sdk-web-interfaces';
2
- import type { SsaiCreative } from './ssai-creative';
2
+ import type { SsaiCreative } from './ssai-creative.js';
3
3
  /**
4
4
  * 'ending' = the ad's window closes within the detector's lead (~0.5s): the
5
5
  * overlay's cue to play its closing animation so it completes AT the boundary
@@ -1,4 +1,4 @@
1
- import { DEFAULT_CREATIVE_API_BASE, type SsaiCreativeProvider } from './ssai-providers';
1
+ import { DEFAULT_CREATIVE_API_BASE, type SsaiCreativeProvider } from './ssai-providers.js';
2
2
  export { DEFAULT_CREATIVE_API_BASE };
3
3
  export type SsaiTrackingEvent = {
4
4
  event: string;
@@ -1,4 +1,4 @@
1
- import { fireBeacon, fireDefaultImpression, fireTrackingEvents, DEFAULT_CREATIVE_API_BASE, } from './ssai-providers';
1
+ import { fireBeacon, fireDefaultImpression, fireTrackingEvents, DEFAULT_CREATIVE_API_BASE, } from './ssai-providers.js';
2
2
  export { DEFAULT_CREATIVE_API_BASE };
3
3
  /**
4
4
  * Per-instance cache + impression-firing wrapper around a SsaiCreativeProvider.
@@ -1,4 +1,4 @@
1
- import type { SsaiProgressEvent } from './ssai-creative';
1
+ import type { SsaiProgressEvent } from './ssai-creative.js';
2
2
  export type SsaiDateRange = {
3
3
  id: string;
4
4
  creativeId: string;
@@ -1,4 +1,5 @@
1
1
  import { createLogger } from '@streamlayer/sdk-web-logger';
2
+ import { ssaiq } from './ssai-log.js';
2
3
  const log = createLogger('ssai:detector');
3
4
  const RANGE_EXPIRY_MS = 10 * 60 * 1000;
4
5
  const ACTIVE_POLL_MS = 250;
@@ -488,16 +489,32 @@ export const createSsaiDetector = (options) => {
488
489
  : videoRef && manifestUrl
489
490
  ? 'manifest-poll'
490
491
  : 'none';
491
- const recordDebugEvent = (type, detail, programMs) => {
492
- debugEvents.push({
493
- wallMs: Date.now(),
494
- programMs: programMs !== undefined ? programMs : currentProgramTimeMs(),
495
- type,
496
- detail,
497
- });
492
+ /**
493
+ * The `[label]` a detector event's log line opens with — what KIND of thing
494
+ * happened, for a reader scanning a window: a manifest tag came in (`hls`), a
495
+ * break edge fired (`break`), or playback crossed a tracking marker (`beacon`).
496
+ */
497
+ const eventLabel = (type) => {
498
+ if (type.indexOf('progress:') === 0)
499
+ return 'beacon';
500
+ if (type.indexOf('discovered') !== -1 || type === 'range-dropped')
501
+ return 'hls';
502
+ return 'break';
503
+ };
504
+ /**
505
+ * One call per detector event, feeding both consumers at once: the capped
506
+ * ring buffer behind `getDebugSnapshot()` and the `ssaiq` narrative logger.
507
+ * `data` is whatever the detector already holds for the event — the parsed
508
+ * range, marker or break window — so a log reader sees exactly what the
509
+ * detector acted on, with no second parse of the manifest for its benefit.
510
+ */
511
+ const recordDebugEvent = (type, detail, programMs, data) => {
512
+ const at = programMs !== undefined ? programMs : currentProgramTimeMs();
513
+ debugEvents.push({ wallMs: Date.now(), programMs: at, type, detail });
498
514
  if (debugEvents.length > DEBUG_EVENTS_MAX) {
499
515
  debugEvents.splice(0, debugEvents.length - DEBUG_EVENTS_MAX);
500
516
  }
517
+ ssaiq.info({ ...data, programMs: at }, `[${eventLabel(type)}] ${type}${detail ? ` ${detail}` : ''}`);
501
518
  };
502
519
  const getVideo = () => player?.media ?? video ?? null;
503
520
  // Memoized view over the three marker maps. `updateActiveAd` polls 4×/s and
@@ -547,7 +564,6 @@ export const createSsaiDetector = (options) => {
547
564
  }
548
565
  seenDroppedKeys.add(entry.key);
549
566
  recordDebugEvent('range-dropped', entry.detail);
550
- log.warn({ detail: entry.detail }, 'ssai DATERANGE dropped');
551
567
  }
552
568
  continue;
553
569
  }
@@ -555,7 +571,7 @@ export const createSsaiDetector = (options) => {
555
571
  const existing = rangesById.get(entry.range.id);
556
572
  if (!existing) {
557
573
  changed = true;
558
- recordDebugEvent('range-discovered', `${entry.range.creativeId} ${debugClock(entry.range.startMs)}+${entry.range.durationSec}s`);
574
+ recordDebugEvent('range-discovered', `${entry.range.creativeId} ${debugClock(entry.range.startMs)}+${entry.range.durationSec}s`, undefined, { range: entry.range });
559
575
  notifyAdDiscovered(entry.range.creativeId);
560
576
  }
561
577
  else if (existing.startMs !== entry.range.startMs || existing.endMs !== entry.range.endMs) {
@@ -566,7 +582,7 @@ export const createSsaiDetector = (options) => {
566
582
  else if (entry.kind === 'marker') {
567
583
  if (!seenMarkerAdIds.has(entry.marker.adId)) {
568
584
  seenMarkerAdIds.add(entry.marker.adId);
569
- recordDebugEvent('marker-ad-discovered', `${entry.marker.adId} ${entry.marker.event}@${debugClock(entry.marker.timeMs)}`);
585
+ recordDebugEvent('marker-ad-discovered', `${entry.marker.adId} ${entry.marker.event}@${debugClock(entry.marker.timeMs)}`, undefined, { marker: entry.marker });
570
586
  notifyAdDiscovered(entry.marker.adId);
571
587
  }
572
588
  const existing = markersById.get(entry.marker.id);
@@ -579,7 +595,7 @@ export const createSsaiDetector = (options) => {
579
595
  const existing = adBreaksById.get(entry.window.id);
580
596
  if (!existing) {
581
597
  changed = true;
582
- recordDebugEvent('adbreak-discovered', `${debugClock(entry.window.startMs)}-${debugClock(entry.window.endMs)}`);
598
+ recordDebugEvent('adbreak-discovered', `${debugClock(entry.window.startMs)}-${debugClock(entry.window.endMs)}`, undefined, { window: entry.window });
583
599
  }
584
600
  else if (existing.startMs !== entry.window.startMs || existing.endMs !== entry.window.endMs) {
585
601
  changed = true;