@amazon/vinyl 3.2.0 → 3.2.1

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 (29) hide show
  1. package/dist/index.cjs +30 -24
  2. package/dist/index.cjs.map +2 -2
  3. package/dist/index.js +30 -24
  4. package/dist/index.js.map +2 -2
  5. package/dist/test/integ.es5.js +167 -155
  6. package/dist/test/integ.js +260 -50
  7. package/dist/test/integ.js.map +2 -2
  8. package/dist/types/current/ad/AdControllerImpl.d.ts +18 -2
  9. package/dist/types/current/ad/AdControllerImpl.d.ts.map +1 -1
  10. package/dist/types/current/ad/AdControllerImpl.js +42 -20
  11. package/dist/types/current/playback/createStallMonitor.d.ts.map +1 -1
  12. package/dist/types/current/playback/createStallMonitor.js +19 -14
  13. package/dist/types/current/tsconfig.tsbuildinfo +1 -1
  14. package/dist/types/ts3.7/ad/AdControllerImpl.d.ts +18 -2
  15. package/dist/types/ts4.1/ad/AdControllerImpl.d.ts +18 -2
  16. package/dist/types/ts4.7/ad/AdControllerImpl.d.ts +18 -2
  17. package/dist/unit/types/current/ad/AdControllerImpl.test.js +110 -4
  18. package/dist/unit/types/current/playback/createStallMonitor.test.js +38 -0
  19. package/dist/unit/types/current/tsconfig.tsbuildinfo +1 -1
  20. package/dist/vinylTestUtil/index.js +10 -1
  21. package/dist/vinylTestUtil/index.js.map +2 -2
  22. package/dist/vinylTestUtil/types/current/assets/vinylTestAssets.d.ts +5 -0
  23. package/dist/vinylTestUtil/types/current/assets/vinylTestAssets.d.ts.map +1 -1
  24. package/dist/vinylTestUtil/types/current/assets/vinylTestAssets.js +9 -0
  25. package/dist/vinylTestUtil/types/current/tsconfig.tsbuildinfo +1 -1
  26. package/dist/vinylTestUtil/types/ts3.7/assets/vinylTestAssets.d.ts +5 -0
  27. package/dist/vinylTestUtil/types/ts4.1/assets/vinylTestAssets.d.ts +5 -0
  28. package/dist/vinylTestUtil/types/ts4.7/assets/vinylTestAssets.d.ts +5 -0
  29. package/package.json +10 -10
@@ -7,6 +7,15 @@ import { AdsProvider } from './AdsProvider';
7
7
  * The % playhead start tolerance for playback progress when emitting ad progress events.
8
8
  */
9
9
  export declare const AD_START_TOLERANCE = 0.05;
10
+ /**
11
+ * When a break's X-PLAYOUT-LIMIT budget is reached, an ad whose real media end is
12
+ * within this many seconds is allowed to finish on its own `ended` rather than
13
+ * being cut. The limit is a coarse, often whole-second budget while the media's
14
+ * true length is fractional, so without this grace an ad effectively as long as
15
+ * its budget would be clipped a fraction short. The tolerance is measured against
16
+ * the playback-reported (media) duration, not the ad's declared duration.
17
+ */
18
+ export declare const PLAYOUT_LIMIT_TOLERANCE = 1;
10
19
  export interface AdControllerImplDeps {
11
20
  readonly playbackController: ReadonlyPlaybackController;
12
21
  }
@@ -129,8 +138,15 @@ export declare class AdControllerImpl extends EventHostImpl<AdEventMap> implemen
129
138
  private emitAdProgressEvents;
130
139
  private emitTimeUpdate;
131
140
  /**
132
- * Ads breaks can have a playout limit that may cut off ad playback.
133
- * End the ad if that limit is reached.
141
+ * Enforces the break-level playout limit (X-PLAYOUT-LIMIT): a budget shared
142
+ * across all of the break's ads. When cumulative playout reaches it, the
143
+ * whole break ends, dropping any ads that have not started.
144
+ *
145
+ * This is deliberately NOT a per-ad cap. Each ad asset is its own source and
146
+ * ends on its media `ended` (see the 'ended' handler), which is what advances
147
+ * a multi-ad break. Ending an ad at its declared per-ad DURATION instead would
148
+ * truncate the real asset whenever a packager under-declares it — e.g. rounds
149
+ * a 6.732s asset down to a whole-second 6 — cutting off the fractional tail.
134
150
  */
135
151
  private enforcePlayoutLimits;
136
152
  private setPendingBreaks;
@@ -7,6 +7,15 @@ import type { AdsProvider } from './AdsProvider';
7
7
  * The % playhead start tolerance for playback progress when emitting ad progress events.
8
8
  */
9
9
  export declare const AD_START_TOLERANCE = 0.05;
10
+ /**
11
+ * When a break's X-PLAYOUT-LIMIT budget is reached, an ad whose real media end is
12
+ * within this many seconds is allowed to finish on its own `ended` rather than
13
+ * being cut. The limit is a coarse, often whole-second budget while the media's
14
+ * true length is fractional, so without this grace an ad effectively as long as
15
+ * its budget would be clipped a fraction short. The tolerance is measured against
16
+ * the playback-reported (media) duration, not the ad's declared duration.
17
+ */
18
+ export declare const PLAYOUT_LIMIT_TOLERANCE = 1;
10
19
  export interface AdControllerImplDeps {
11
20
  readonly playbackController: ReadonlyPlaybackController;
12
21
  }
@@ -129,8 +138,15 @@ export declare class AdControllerImpl extends EventHostImpl<AdEventMap> implemen
129
138
  private emitAdProgressEvents;
130
139
  private emitTimeUpdate;
131
140
  /**
132
- * Ads breaks can have a playout limit that may cut off ad playback.
133
- * End the ad if that limit is reached.
141
+ * Enforces the break-level playout limit (X-PLAYOUT-LIMIT): a budget shared
142
+ * across all of the break's ads. When cumulative playout reaches it, the
143
+ * whole break ends, dropping any ads that have not started.
144
+ *
145
+ * This is deliberately NOT a per-ad cap. Each ad asset is its own source and
146
+ * ends on its media `ended` (see the 'ended' handler), which is what advances
147
+ * a multi-ad break. Ending an ad at its declared per-ad DURATION instead would
148
+ * truncate the real asset whenever a packager under-declares it — e.g. rounds
149
+ * a 6.732s asset down to a whole-second 6 — cutting off the fractional tail.
134
150
  */
135
151
  private enforcePlayoutLimits;
136
152
  private setPendingBreaks;
@@ -7,6 +7,15 @@ import type { AdsProvider } from './AdsProvider';
7
7
  * The % playhead start tolerance for playback progress when emitting ad progress events.
8
8
  */
9
9
  export declare const AD_START_TOLERANCE = 0.05;
10
+ /**
11
+ * When a break's X-PLAYOUT-LIMIT budget is reached, an ad whose real media end is
12
+ * within this many seconds is allowed to finish on its own `ended` rather than
13
+ * being cut. The limit is a coarse, often whole-second budget while the media's
14
+ * true length is fractional, so without this grace an ad effectively as long as
15
+ * its budget would be clipped a fraction short. The tolerance is measured against
16
+ * the playback-reported (media) duration, not the ad's declared duration.
17
+ */
18
+ export declare const PLAYOUT_LIMIT_TOLERANCE = 1;
10
19
  export interface AdControllerImplDeps {
11
20
  readonly playbackController: ReadonlyPlaybackController;
12
21
  }
@@ -129,8 +138,15 @@ export declare class AdControllerImpl extends EventHostImpl<AdEventMap> implemen
129
138
  private emitAdProgressEvents;
130
139
  private emitTimeUpdate;
131
140
  /**
132
- * Ads breaks can have a playout limit that may cut off ad playback.
133
- * End the ad if that limit is reached.
141
+ * Enforces the break-level playout limit (X-PLAYOUT-LIMIT): a budget shared
142
+ * across all of the break's ads. When cumulative playout reaches it, the
143
+ * whole break ends, dropping any ads that have not started.
144
+ *
145
+ * This is deliberately NOT a per-ad cap. Each ad asset is its own source and
146
+ * ends on its media `ended` (see the 'ended' handler), which is what advances
147
+ * a multi-ad break. Ending an ad at its declared per-ad DURATION instead would
148
+ * truncate the real asset whenever a packager under-declares it — e.g. rounds
149
+ * a 6.732s asset down to a whole-second 6 — cutting off the fractional tail.
134
150
  */
135
151
  private enforcePlayoutLimits;
136
152
  private setPendingBreaks;
@@ -896,7 +896,6 @@ describe('AdControllerImpl', () => {
896
896
  await setContent(c, trackAds(makeBreak({
897
897
  startTime: 10,
898
898
  duration: 20,
899
- // Null per-ad duration so the per-ad cap does not end it.
900
899
  ads: [
901
900
  {
902
901
  id: 'a1',
@@ -990,7 +989,11 @@ describe('AdControllerImpl', () => {
990
989
  });
991
990
  });
992
991
  describe('ad duration and playout limits', () => {
993
- it('advances to the next ad when one reaches its declared duration', async () => {
992
+ it('advances to the next ad on the ad media ended, not its declared duration', async () => {
993
+ // Each ad asset is its own source; a non-final ad advances on its own
994
+ // media `ended`. The declared per-ad duration must NOT end it early —
995
+ // a packager may under-declare it (whole-second rounding), which would
996
+ // otherwise truncate the real asset.
994
997
  const c = createController();
995
998
  await setContent(c, trackAds(makeBreak({
996
999
  startTime: 0,
@@ -999,7 +1002,7 @@ describe('AdControllerImpl', () => {
999
1002
  {
1000
1003
  id: 'a1',
1001
1004
  startTime: 0,
1002
- duration: 5,
1005
+ duration: 5, // declared, server-rounded
1003
1006
  uri: 'ad1.m3u8',
1004
1007
  },
1005
1008
  {
@@ -1014,10 +1017,54 @@ describe('AdControllerImpl', () => {
1014
1017
  await flush();
1015
1018
  expect(c.currentAd?.id).toBe('a1');
1016
1019
  playbackController.dispatch('playing', {}); // timeStart = 0
1017
- updateTime(6); // elapsed 6 >= a1.duration 5 → advance
1020
+ // Past the declared 5s, still within a1's real content — must NOT advance.
1021
+ updateTime(6);
1022
+ await flush();
1023
+ expect(c.currentAd?.id).toBe('a1');
1024
+ // a1's media ends -> advance to a2.
1025
+ playbackController.dispatch('ended', {
1026
+ previous: false,
1027
+ current: true,
1028
+ });
1018
1029
  await flush();
1019
1030
  expect(c.currentAd?.id).toBe('a2');
1020
1031
  });
1032
+ it('does not cut the final/only ad at its declared duration; it ends on media ended', async () => {
1033
+ // The ad server declares DURATION in whole seconds, but the asset's
1034
+ // real content is longer (6.732s here). The only ad must play to its
1035
+ // media `ended`, not be truncated at the rounded declared duration.
1036
+ const c = createController();
1037
+ const ended = [];
1038
+ c.on('adEnded', (e) => ended.push(e.ad.id));
1039
+ await setContent(c, trackAds(makeBreak({
1040
+ startTime: 0,
1041
+ duration: 30,
1042
+ ads: [
1043
+ {
1044
+ id: 'a1',
1045
+ startTime: 0,
1046
+ duration: 6, // declared, server-rounded
1047
+ uri: 'ad1.m3u8',
1048
+ },
1049
+ ],
1050
+ })));
1051
+ updateTime(0);
1052
+ await flush();
1053
+ expect(c.currentAd?.id).toBe('a1');
1054
+ playbackController.dispatch('playing', {}); // timeStart = 0
1055
+ // Past the declared 6s, into the real content — must NOT end yet.
1056
+ updateTime(6.5);
1057
+ await flush();
1058
+ expect(c.currentAd?.id).toBe('a1');
1059
+ expect(ended).toEqual([]);
1060
+ // Real media end at 6.732 ends the ad.
1061
+ playbackController.dispatch('ended', {
1062
+ previous: false,
1063
+ current: true,
1064
+ });
1065
+ expect(ended).toEqual(['a1']);
1066
+ expect(c.currentAdBreak).toBeNull();
1067
+ });
1021
1068
  it('ends the whole break when the playout limit is reached, dropping remaining ads', async () => {
1022
1069
  const c = createController();
1023
1070
  const resumes = [];
@@ -1053,6 +1100,65 @@ describe('AdControllerImpl', () => {
1053
1100
  // content still resumes at the cue point (0), not startTime + playout.
1054
1101
  expect(resumes.at(-1)).toBe(0);
1055
1102
  });
1103
+ it('does not cut at the playout limit when the ad is within tolerance of its media end', async () => {
1104
+ // The budget is reached, but the ad's real media end is <1s away, so
1105
+ // it should finish on its own `ended` rather than be clipped. The
1106
+ // tolerance keys off the playback-reported duration, not ad.duration.
1107
+ const c = createController();
1108
+ await setContent(c, trackAds(makeBreak({
1109
+ startTime: 0,
1110
+ duration: null,
1111
+ playoutLimit: 8,
1112
+ ads: [
1113
+ {
1114
+ id: 'a1',
1115
+ startTime: 0,
1116
+ duration: 20,
1117
+ uri: 'ad1.m3u8',
1118
+ },
1119
+ ],
1120
+ })));
1121
+ updateTime(0);
1122
+ await flush();
1123
+ playbackController.dispatch('playing', {}); // timeStart = 0
1124
+ // Media is 8.5s long; at the 8s budget only 0.5s of media remains.
1125
+ playbackController.duration = 8.5;
1126
+ updateTime(8); // cumulative 8 >= limit 8, but within 1s of media end
1127
+ await flush();
1128
+ expect(c.currentAdBreak).not.toBeNull();
1129
+ expect(c.currentAd?.id).toBe('a1');
1130
+ // It finishes on its own media `ended`.
1131
+ playbackController.dispatch('ended', {
1132
+ previous: false,
1133
+ current: true,
1134
+ });
1135
+ await flush();
1136
+ expect(c.currentAdBreak).toBeNull();
1137
+ });
1138
+ it('cuts at the playout limit when the ad has more than the tolerance of media left', async () => {
1139
+ const c = createController();
1140
+ await setContent(c, trackAds(makeBreak({
1141
+ startTime: 0,
1142
+ duration: null,
1143
+ playoutLimit: 8,
1144
+ ads: [
1145
+ {
1146
+ id: 'a1',
1147
+ startTime: 0,
1148
+ duration: 20,
1149
+ uri: 'ad1.m3u8',
1150
+ },
1151
+ ],
1152
+ })));
1153
+ updateTime(0);
1154
+ await flush();
1155
+ playbackController.dispatch('playing', {}); // timeStart = 0
1156
+ // Media is 20s long; at the 8s budget 12s of media remains → cut.
1157
+ playbackController.duration = 20;
1158
+ updateTime(8);
1159
+ await flush();
1160
+ expect(c.currentAdBreak).toBeNull();
1161
+ });
1056
1162
  });
1057
1163
  describe('CUE=ONCE and replay', () => {
1058
1164
  it('replays a non-ONCE break after the playhead seeks back before it', async () => {
@@ -115,10 +115,48 @@ describe('createStallMonitor', () => {
115
115
  expect(onStallEntered).toHaveBeenCalledTimes(2);
116
116
  expect(onStallEnded).toHaveBeenCalledTimes(2);
117
117
  });
118
+ it('does not end and re-detect a stall on a non-advancing timeUpdate', async () => {
119
+ // Browsers emit `timeUpdate` even while the play head is frozen, with an unchanged (or
120
+ // briefly backward) currentTime. These must not be treated as progress, or the stall is
121
+ // ended and immediately re-detected, doubling the reported events.
122
+ monitor();
123
+ host.dispatch('playing', {});
124
+ host.dispatch('timeUpdate', { previous: 4, current: 5 });
125
+ await poll(4); // freeze 1s -> stallEntered
126
+ expect(onStallEntered).toHaveBeenCalledTimes(1);
127
+ // Still frozen: currentTime hasn't advanced (unchanged, backward, or sub-epsilon jitter).
128
+ host.dispatch('timeUpdate', { previous: 5, current: 5 });
129
+ host.dispatch('timeUpdate', { previous: 5, current: 4.99 });
130
+ host.dispatch('timeUpdate', { previous: 5, current: 5 + 1e-9 });
131
+ await poll(4);
132
+ expect(onStallEnded).not.toHaveBeenCalled();
133
+ expect(onStallEntered).toHaveBeenCalledTimes(1);
134
+ // A real forward advance ends the stall exactly once.
135
+ host.dispatch('timeUpdate', { previous: 5, current: 6 });
136
+ expect(onStallEnded).toHaveBeenCalledTimes(1);
137
+ expect(onStallEntered).toHaveBeenCalledTimes(1);
138
+ });
139
+ it('does not re-detect a stall immediately after resuming via a playing event', async () => {
140
+ // Resuming via `playing` must restart the freeze clock. Otherwise the poll, still seeing
141
+ // the stale pre-freeze progress time, immediately re-enters a stall before the first
142
+ // post-resume timeUpdate arrives — an extra, spurious stall.
143
+ monitor();
144
+ host.dispatch('playing', {});
145
+ host.dispatch('timeUpdate', { previous: 0, current: 1 });
146
+ await poll(4); // freeze 1s -> stallEntered
147
+ expect(onStallEntered).toHaveBeenCalledTimes(1);
148
+ // Resume with a 'playing' event and no timeUpdate yet.
149
+ host.dispatch('playing', {});
150
+ expect(onStallEnded).toHaveBeenCalledTimes(1);
151
+ // The freeze clock reset on resume, so a sub-threshold gap is not a new stall.
152
+ await poll(3); // 0.75s < 1s threshold
153
+ expect(onStallEntered).toHaveBeenCalledTimes(1);
154
+ });
118
155
  it('stops reporting after disposal', async () => {
119
156
  monitor();
120
157
  host.dispatch('playing', {});
121
158
  stop();
159
+ stop = undefined; // disposal is single-shot; don't dispose again in afterEach
122
160
  await poll(8);
123
161
  expect(onStallEntered).not.toHaveBeenCalled();
124
162
  });