@blockcast/mmt-transport 0.2.2 → 0.3.0-main.1cd3cdea89da

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 (123) hide show
  1. package/README.md +44 -58
  2. package/dist/abr-controller.d.ts +34 -4
  3. package/dist/abr-controller.d.ts.map +1 -1
  4. package/dist/abr-controller.js +66 -17
  5. package/dist/abr-controller.js.map +1 -1
  6. package/dist/amt-gateway.d.ts.map +1 -1
  7. package/dist/amt-gateway.js +3 -4
  8. package/dist/amt-gateway.js.map +1 -1
  9. package/dist/driad-discovery.d.ts.map +1 -1
  10. package/dist/driad-discovery.js +3 -6
  11. package/dist/driad-discovery.js.map +1 -1
  12. package/dist/endpoint-config.d.ts +68 -0
  13. package/dist/endpoint-config.d.ts.map +1 -1
  14. package/dist/endpoint-config.js +118 -3
  15. package/dist/endpoint-config.js.map +1 -1
  16. package/dist/index.d.ts +10 -8
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +8 -11
  19. package/dist/index.js.map +1 -1
  20. package/dist/mmt-transport.no-modules.js +896 -61
  21. package/dist/mmt-transport.no-modules.js.map +4 -4
  22. package/dist/moqt-priority.d.ts +39 -0
  23. package/dist/moqt-priority.d.ts.map +1 -0
  24. package/dist/moqt-priority.js +41 -0
  25. package/dist/moqt-priority.js.map +1 -0
  26. package/dist/multicast-catalog-policy.d.ts +96 -0
  27. package/dist/multicast-catalog-policy.d.ts.map +1 -0
  28. package/dist/multicast-catalog-policy.js +660 -0
  29. package/dist/multicast-catalog-policy.js.map +1 -0
  30. package/dist/multicast-orchestrator.d.ts +35 -0
  31. package/dist/multicast-orchestrator.d.ts.map +1 -1
  32. package/dist/multicast-orchestrator.js +128 -10
  33. package/dist/multicast-orchestrator.js.map +1 -1
  34. package/dist/multicast-signaling-reconciler.d.ts +37 -0
  35. package/dist/multicast-signaling-reconciler.d.ts.map +1 -0
  36. package/dist/multicast-signaling-reconciler.js +161 -0
  37. package/dist/multicast-signaling-reconciler.js.map +1 -0
  38. package/dist/multicast-subscription-pool.d.ts.map +1 -1
  39. package/dist/multicast-subscription-pool.js +37 -10
  40. package/dist/multicast-subscription-pool.js.map +1 -1
  41. package/dist/multicast-tier.d.ts +19 -0
  42. package/dist/multicast-tier.d.ts.map +1 -1
  43. package/dist/multicast-tier.js +21 -4
  44. package/dist/multicast-tier.js.map +1 -1
  45. package/dist/no-modules.d.ts +40 -3
  46. package/dist/no-modules.d.ts.map +1 -1
  47. package/dist/no-modules.js +30 -3
  48. package/dist/no-modules.js.map +1 -1
  49. package/dist/packet-batch.d.ts +13 -0
  50. package/dist/packet-batch.d.ts.map +1 -0
  51. package/dist/packet-batch.js +14 -0
  52. package/dist/packet-batch.js.map +1 -0
  53. package/dist/stall-detector.d.ts +10 -0
  54. package/dist/stall-detector.d.ts.map +1 -1
  55. package/dist/stall-detector.js +21 -0
  56. package/dist/stall-detector.js.map +1 -1
  57. package/dist/takeover.d.ts +3 -0
  58. package/dist/takeover.d.ts.map +1 -1
  59. package/dist/takeover.js +35 -2
  60. package/dist/takeover.js.map +1 -1
  61. package/dist/transport-manager.d.ts +21 -17
  62. package/dist/transport-manager.d.ts.map +1 -1
  63. package/dist/transport-manager.js +63 -29
  64. package/dist/transport-manager.js.map +1 -1
  65. package/dist/types.d.ts +98 -123
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/types.js +12 -50
  68. package/dist/types.js.map +1 -1
  69. package/package.json +13 -7
  70. package/src/abr-controller.ts +81 -17
  71. package/src/amt-gateway.ts +3 -5
  72. package/src/driad-discovery.ts +3 -6
  73. package/src/endpoint-config.ts +157 -3
  74. package/src/index.ts +54 -33
  75. package/src/moqt-priority.ts +51 -0
  76. package/src/multicast-catalog-policy.ts +753 -0
  77. package/src/multicast-orchestrator.ts +138 -11
  78. package/src/multicast-signaling-reconciler.ts +156 -0
  79. package/src/multicast-subscription-pool.ts +46 -11
  80. package/src/multicast-tier.ts +30 -3
  81. package/src/no-modules.ts +54 -2
  82. package/src/packet-batch.ts +17 -0
  83. package/src/stall-detector.ts +37 -0
  84. package/src/takeover.ts +41 -3
  85. package/src/transport-manager.ts +64 -34
  86. package/src/types.ts +103 -160
  87. package/vectors/multicast-tier-switch/README.md +18 -0
  88. package/vectors/multicast-tier-switch/cases.json +646 -0
  89. package/dist/endpoint-config.test.d.ts +0 -2
  90. package/dist/endpoint-config.test.d.ts.map +0 -1
  91. package/dist/endpoint-config.test.js +0 -39
  92. package/dist/endpoint-config.test.js.map +0 -1
  93. package/dist/fec-client.d.ts +0 -442
  94. package/dist/fec-client.d.ts.map +0 -1
  95. package/dist/fec-client.js +0 -789
  96. package/dist/fec-client.js.map +0 -1
  97. package/dist/fec-client.test.d.ts +0 -8
  98. package/dist/fec-client.test.d.ts.map +0 -1
  99. package/dist/fec-client.test.js +0 -112
  100. package/dist/fec-client.test.js.map +0 -1
  101. package/dist/multicast-subscription-pool.test.d.ts +0 -2
  102. package/dist/multicast-subscription-pool.test.d.ts.map +0 -1
  103. package/dist/multicast-subscription-pool.test.js +0 -108
  104. package/dist/multicast-subscription-pool.test.js.map +0 -1
  105. package/dist/multicast-tier.test.d.ts +0 -2
  106. package/dist/multicast-tier.test.d.ts.map +0 -1
  107. package/dist/multicast-tier.test.js +0 -75
  108. package/dist/multicast-tier.test.js.map +0 -1
  109. package/dist/stall-detector.test.d.ts +0 -2
  110. package/dist/stall-detector.test.d.ts.map +0 -1
  111. package/dist/stall-detector.test.js +0 -33
  112. package/dist/stall-detector.test.js.map +0 -1
  113. package/dist/transport-manager.test.d.ts +0 -2
  114. package/dist/transport-manager.test.d.ts.map +0 -1
  115. package/dist/transport-manager.test.js +0 -155
  116. package/dist/transport-manager.test.js.map +0 -1
  117. package/src/endpoint-config.test.ts +0 -54
  118. package/src/fec-client.test.ts +0 -140
  119. package/src/fec-client.ts +0 -1103
  120. package/src/multicast-subscription-pool.test.ts +0 -115
  121. package/src/multicast-tier.test.ts +0 -80
  122. package/src/stall-detector.test.ts +0 -34
  123. package/src/transport-manager.test.ts +0 -192
@@ -4,10 +4,14 @@
4
4
  function endpointKey(endpoint) {
5
5
  return `${endpoint.sourceAddress ?? ""}|${endpoint.groupAddress}|${endpoint.port}`;
6
6
  }
7
+ function carriesMedia(endpoint) {
8
+ return endpoint.carriesMedia !== false;
9
+ }
7
10
  function selectMulticastTier(endpoints, estimate) {
8
- if (endpoints.length <= 1) return endpoints[0];
9
- const ranked = endpoints.filter((endpoint) => typeof endpoint.bandwidth === "number" && endpoint.bandwidth > 0).sort((a, b) => a.bandwidth - b.bandwidth);
10
- if (ranked.length < 2) return endpoints[0];
11
+ const media = endpoints.filter(carriesMedia);
12
+ if (media.length <= 1) return media[0];
13
+ const ranked = media.filter((endpoint) => typeof endpoint.bandwidth === "number" && endpoint.bandwidth > 0).sort((a, b) => a.bandwidth - b.bandwidth);
14
+ if (ranked.length < 2) return media[0];
11
15
  if (estimate === void 0) return ranked[0];
12
16
  const budget = estimate * 0.8;
13
17
  let selected;
@@ -18,7 +22,7 @@
18
22
  }
19
23
 
20
24
  // src/abr-controller.ts
21
- var QUALITY_LEVELS = [
25
+ var QUALITY_LEVELS = Object.freeze([
22
26
  { name: "240p", bitrate: 3e5, width: 426, height: 240, fps: 15 },
23
27
  { name: "360p", bitrate: 5e5, width: 640, height: 360, fps: 24 },
24
28
  { name: "480p", bitrate: 1e6, width: 854, height: 480, fps: 30 },
@@ -26,8 +30,37 @@
26
30
  { name: "1080p", bitrate: 5e6, width: 1920, height: 1080, fps: 30 },
27
31
  { name: "1440p", bitrate: 8e6, width: 2560, height: 1440, fps: 30 },
28
32
  { name: "4K", bitrate: 15e6, width: 3840, height: 2160, fps: 30 }
29
- ];
33
+ ].map((level) => Object.freeze(level)));
34
+ function positiveFinite(value) {
35
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
36
+ }
37
+ function validateQualityLadder(ladder) {
38
+ if (!Array.isArray(ladder) || ladder.length === 0) {
39
+ throw new TypeError("[ABR] ladder must list at least one rendition (e.g. the catalog's video tracks)");
40
+ }
41
+ const names = /* @__PURE__ */ new Set();
42
+ for (const level of ladder) {
43
+ if (typeof level?.name !== "string" || level.name.length === 0) {
44
+ throw new TypeError("[ABR] every ladder rendition needs a name");
45
+ }
46
+ if (!positiveFinite(level.bitrate)) {
47
+ throw new RangeError(`[ABR] rendition ${level.name}: bitrate must be a positive finite number, got ${String(level.bitrate)}`);
48
+ }
49
+ for (const field of ["width", "height", "fps"]) {
50
+ if (level[field] !== void 0 && !positiveFinite(level[field])) {
51
+ throw new RangeError(`[ABR] rendition ${level.name}: ${field} must be a positive finite number when stated, got ${String(level[field])}`);
52
+ }
53
+ }
54
+ if (names.has(level.name)) throw new RangeError(`[ABR] rendition ${level.name} appears twice in the ladder`);
55
+ names.add(level.name);
56
+ }
57
+ return Object.freeze(
58
+ [...ladder].sort((a, b) => a.bitrate - b.bitrate).map((level) => Object.freeze({ ...level }))
59
+ );
60
+ }
30
61
  var AdaptiveBitrateController = class {
62
+ /** The caller's renditions, ascending by bitrate (`validateQualityLadder`). */
63
+ ladder;
31
64
  currentQualityIndex;
32
65
  history = [];
33
66
  historyWindow = 1e4;
@@ -47,15 +80,21 @@
47
80
  recoveryRate: 1,
48
81
  fecEnabled: false
49
82
  };
50
- constructor(initialQuality) {
51
- this.currentQualityIndex = initialQuality ? QUALITY_LEVELS.findIndex((q) => q.name === initialQuality.name) : 0;
52
- if (this.currentQualityIndex < 0) this.currentQualityIndex = 0;
83
+ constructor(options) {
84
+ if (!options || typeof options !== "object") {
85
+ throw new TypeError("[ABR] AdaptiveBitrateController needs { ladder, initialQuality }");
86
+ }
87
+ this.ladder = validateQualityLadder(options.ladder);
88
+ this.currentQualityIndex = options.initialQuality === void 0 ? 0 : this.ladder.findIndex((q) => q.name === options.initialQuality);
89
+ if (this.currentQualityIndex < 0) {
90
+ throw new RangeError(`[ABR] initial rendition ${String(options.initialQuality)} is not in the ladder`);
91
+ }
53
92
  }
54
93
  setCallback(callback) {
55
94
  this.onQualityChange = callback;
56
95
  }
57
96
  getCurrentQuality() {
58
- return QUALITY_LEVELS[this.currentQualityIndex];
97
+ return this.ladder[this.currentQualityIndex];
59
98
  }
60
99
  /** Selects the multicast (S,G) tier without replacing a player's unicast ABR manager. */
61
100
  selectMulticastTier(endpoints, estimate = this.getMetrics().measuredBitrate || void 0) {
@@ -81,7 +120,7 @@
81
120
  this.downgrade();
82
121
  return;
83
122
  }
84
- if (measuredBitrate > currentQuality.bitrate * this.upgradeThreshold && effectiveLossRate < 0.01 && this.currentQualityIndex < QUALITY_LEVELS.length - 1) {
123
+ if (measuredBitrate > currentQuality.bitrate * this.upgradeThreshold && effectiveLossRate < 0.01 && this.currentQualityIndex < this.ladder.length - 1) {
85
124
  this.upgrade();
86
125
  }
87
126
  }
@@ -114,7 +153,7 @@
114
153
  return { measuredBitrate, lossRate };
115
154
  }
116
155
  upgrade() {
117
- if (this.currentQualityIndex >= QUALITY_LEVELS.length - 1) return;
156
+ if (this.currentQualityIndex >= this.ladder.length - 1) return;
118
157
  this.currentQualityIndex++;
119
158
  this.lastQualityChange = Date.now();
120
159
  const newQuality = this.getCurrentQuality();
@@ -130,15 +169,15 @@
130
169
  this.onQualityChange?.(newQuality);
131
170
  }
132
171
  /**
133
- * Force quality level (for testing)
172
+ * Select a ladder rendition: the caller switched to it (a manual override,
173
+ * or a switch the caller made). Throws for a name the ladder does not carry.
134
174
  */
135
175
  setQuality(name) {
136
- const index = QUALITY_LEVELS.findIndex((q) => q.name === name);
137
- if (index >= 0) {
138
- this.currentQualityIndex = index;
139
- this.lastQualityChange = Date.now();
140
- this.onQualityChange?.(this.getCurrentQuality());
141
- }
176
+ const index = this.ladder.findIndex((q) => q.name === name);
177
+ if (index < 0) throw new RangeError(`[ABR] rendition ${name} is not in the ladder`);
178
+ this.currentQualityIndex = index;
179
+ this.lastQualityChange = Date.now();
180
+ this.onQualityChange?.(this.getCurrentQuality());
142
181
  }
143
182
  /**
144
183
  * Get current metrics including both raw and effective loss rates
@@ -162,7 +201,85 @@
162
201
  }
163
202
  };
164
203
 
204
+ // node_modules/@blockcast/mmt-base/dist/mmtp.js
205
+ var MPU_HEADER_SIZE = 8;
206
+ var MPU_LENGTH_FIELD_SIZE = 2;
207
+ var MPU_HEADER_REMAINDER_SIZE = MPU_HEADER_SIZE - MPU_LENGTH_FIELD_SIZE;
208
+ var MMTP_SIGNALING_PACKET_ID = 0;
209
+ var MMTP_PACKET_ID_MAX = 65535;
210
+
211
+ // node_modules/@blockcast/mmt-base/dist/net-constants.js
212
+ var DEFAULT_AMT_RELAY_PORT = 2268;
213
+
214
+ // node_modules/@blockcast/mmt-base/dist/ntp-short.js
215
+ var NTP_SHORT_BITS = 32;
216
+ var NTP_SHORT_MODULUS = 2 ** NTP_SHORT_BITS;
217
+ var NTP_SHORT_HALF_RANGE = 2 ** (NTP_SHORT_BITS - 1);
218
+ function isNtpShort(value) {
219
+ return Number.isInteger(value) && value >= 0 && value < NTP_SHORT_MODULUS;
220
+ }
221
+ function compareNtpShort(a, b) {
222
+ if (!isNtpShort(a) || !isNtpShort(b)) {
223
+ throw new RangeError(`NTP-short values must be integers in [0, 2^${NTP_SHORT_BITS}), got ${String(a)} and ${String(b)}`);
224
+ }
225
+ if (a === b)
226
+ return "equal";
227
+ const forward = (a - b + NTP_SHORT_MODULUS) % NTP_SHORT_MODULUS;
228
+ if (forward < NTP_SHORT_HALF_RANGE)
229
+ return "after";
230
+ if (forward > NTP_SHORT_HALF_RANGE)
231
+ return "before";
232
+ return "unordered";
233
+ }
234
+
235
+ // node_modules/@blockcast/mmt-base/dist/fec-block-profile.js
236
+ var FEC_BLOCK_PROFILES = Object.freeze([
237
+ "iso-ssbg1-v1"
238
+ ]);
239
+
165
240
  // src/endpoint-config.ts
241
+ function toWindowMulticastSubscribeConfig(input) {
242
+ if (input.mode !== void 0 && !["auto", "native", "tunnel"].includes(input.mode)) {
243
+ throw new TypeError(`Unsupported multicast mode: ${String(input.mode)}`);
244
+ }
245
+ if (input.packetIds !== void 0 && !Array.isArray(input.packetIds)) {
246
+ throw new TypeError(
247
+ `multicast packetIds must be an array, got ${input.packetIds === null ? "null" : typeof input.packetIds}`
248
+ );
249
+ }
250
+ if (input.packetIds?.some((id) => !isMulticastTrackPacketId(id)) || input.tracks?.some((track) => !isMulticastTrackPacketId(track.packetId))) {
251
+ throw new RangeError(
252
+ `multicast packetId must be an integer from 1 to ${MMTP_PACKET_ID_MAX}; packetId 0 is the MMTP signaling flow and is never a track's`
253
+ );
254
+ }
255
+ const base = {
256
+ group: input.group,
257
+ port: input.port,
258
+ ...input.source?.trim() ? { source: input.source.trim() } : {},
259
+ consume: "bytes",
260
+ ...input.packetIds?.length ? { packetIds: [...input.packetIds] } : {}
261
+ };
262
+ const relay = input.amtRelay?.trim();
263
+ const wantsAmt = input.mode === "tunnel" || input.mode !== "native" && relay !== void 0 && relay !== "";
264
+ if (!wantsAmt) {
265
+ const native = {
266
+ ...base,
267
+ origin: "ip-multicast",
268
+ ...input.tracks?.length ? { tracks: input.tracks.map(({ name, packetId }) => ({ name, packetId })) } : {}
269
+ };
270
+ return native;
271
+ }
272
+ const relayPort = input.amtRelayPort;
273
+ if (!relay || relayPort === void 0 || !isPositivePort(relayPort)) {
274
+ throw new TypeError("AMT requires a resolved relay and a valid relay port");
275
+ }
276
+ return {
277
+ ...base,
278
+ origin: "amt",
279
+ relay,
280
+ relayPort
281
+ };
282
+ }
166
283
  function isValidMulticastGroupAddress(address) {
167
284
  const value = address?.trim().toLowerCase();
168
285
  if (!value) return false;
@@ -204,20 +321,36 @@
204
321
  normalized.amtRelayPort ?? ""
205
322
  ]);
206
323
  }
324
+ function isMulticastTrackPacketId(value) {
325
+ return Number.isInteger(value) && value > MMTP_SIGNALING_PACKET_ID && value <= MMTP_PACKET_ID_MAX;
326
+ }
207
327
  function isUsableMulticastEndpoint(endpoint) {
208
328
  const port = Number(endpoint?.port);
209
329
  return isValidMulticastGroupAddress(endpoint?.groupAddress) && isPositivePort(port);
210
330
  }
331
+ var RETIRED_MULTICAST_URL_PARAMS = [
332
+ ["amt_relay", "mc_amt_relay"],
333
+ ["amt_relay_port", "mc_amt_relay_port"]
334
+ ];
335
+ function assertNoRetiredMulticastUrlParams(params) {
336
+ for (const [retired, replacement] of RETIRED_MULTICAST_URL_PARAMS) {
337
+ if (params.has(retired)) {
338
+ throw new Error(
339
+ `multicast URL override: query parameter "${retired}" is retired; use "${replacement}"`
340
+ );
341
+ }
342
+ }
343
+ }
211
344
  function readUrlMulticastConfig(search = typeof location === "undefined" ? "" : location.search) {
212
345
  const params = typeof search === "string" ? new URLSearchParams(search) : search;
213
346
  const groupAddress = params.get("mc_group") || "";
214
347
  const port = positivePort(params.get("mc_port"));
215
348
  if (!groupAddress || !port) return void 0;
216
- const amtRelay = params.get("mc_amt_relay") || params.get("amt_relay") || void 0;
217
- const amtRelayPort = positivePort(params.get("mc_amt_relay_port") || params.get("amt_relay_port"));
349
+ const amtRelay = params.get("mc_amt_relay") || void 0;
350
+ const amtRelayPort = positivePort(params.get("mc_amt_relay_port"));
218
351
  const requestedMode = params.get("mc_mode");
219
352
  const mode = amtRelay ? "tunnel" : multicastMode(requestedMode);
220
- return normalizeMulticastEndpoint({
353
+ const endpoint = normalizeMulticastEndpoint({
221
354
  groupAddress,
222
355
  sourceAddress: params.get("mc_source") || void 0,
223
356
  port,
@@ -225,6 +358,8 @@
225
358
  ...amtRelay ? { amtRelay } : {},
226
359
  ...amtRelayPort ? { amtRelayPort } : {}
227
360
  });
361
+ if (endpoint) assertNoRetiredMulticastUrlParams(params);
362
+ return endpoint;
228
363
  }
229
364
  function isMulticastExplicitlyDisabled(search = typeof location === "undefined" ? "" : location.search) {
230
365
  const params = typeof search === "string" ? new URLSearchParams(search) : search;
@@ -270,7 +405,12 @@
270
405
  #estimate;
271
406
  #favored;
272
407
  #joinStartedAt = 0;
273
- #lastCommittedTimestamp = Number.NEGATIVE_INFINITY;
408
+ /**
409
+ * Latest active-tier send time (NTP short), or undefined before the first.
410
+ * "Latest" is serial order, not the numerically largest value: after the
411
+ * field wraps, the largest value is the stale one.
412
+ */
413
+ #highWater;
274
414
  #dwellMs;
275
415
  #joinDeadlineMs;
276
416
  get state() {
@@ -291,19 +431,58 @@
291
431
  }
292
432
  onGoodput(sample) {
293
433
  const bitrate = sample.bitrate;
294
- this.#estimate = bitrate;
295
- if (!this.#current || this.options.adaptive === false) return void 0;
434
+ if (!this.#current) return void 0;
296
435
  if (this.#state === "joining") return this.poll();
436
+ if (this.options.adaptive === false) return void 0;
437
+ if (bitrate === void 0 || !Number.isFinite(bitrate)) return void 0;
438
+ this.#estimate = bitrate;
297
439
  const desired = selectMulticastTier(this.options.endpoints, bitrate);
298
440
  if (!desired) return void 0;
299
441
  const desiredKey = endpointKey(desired);
442
+ if (desiredKey === endpointKey(this.#current)) {
443
+ if (this.#favored?.source === "goodput") this.#favored = void 0;
444
+ return void 0;
445
+ }
446
+ if (typeof desired.bandwidth !== "number" || typeof this.#current.bandwidth !== "number" || desired.bandwidth >= this.#current.bandwidth) {
447
+ if (this.#favored?.source === "goodput") this.#favored = void 0;
448
+ return void 0;
449
+ }
450
+ const now = this.#now();
451
+ if (!this.#favored || this.#favored.key !== desiredKey || this.#favored.source !== "goodput") {
452
+ this.#favored = { key: desiredKey, since: now, source: "goodput" };
453
+ return void 0;
454
+ }
455
+ if (now - this.#favored.since < this.#dwellMs) return void 0;
456
+ this.#state = "joining";
457
+ this.#target = desired;
458
+ this.#joinStartedAt = now;
459
+ this.#favored = void 0;
460
+ return { kind: "join-target", current: this.#current, target: desired };
461
+ }
462
+ onHeadroom(sample) {
463
+ if (!this.#current || this.options.adaptive === false || this.#state === "joining") return void 0;
464
+ if (!sample.healthy) {
465
+ if (this.#favored?.source === "headroom") this.#favored = void 0;
466
+ return void 0;
467
+ }
468
+ if (this.#favored?.source === "goodput") return void 0;
469
+ const currentBandwidth = this.#current.bandwidth;
470
+ if (typeof currentBandwidth !== "number") return void 0;
471
+ const desired = this.options.endpoints.filter(
472
+ (endpoint) => carriesMedia(endpoint) && typeof endpoint.bandwidth === "number" && endpoint.bandwidth > currentBandwidth
473
+ ).sort((a, b) => a.bandwidth - b.bandwidth)[0];
474
+ if (!desired) {
475
+ this.#favored = void 0;
476
+ return void 0;
477
+ }
478
+ const desiredKey = endpointKey(desired);
300
479
  if (desiredKey === endpointKey(this.#current)) {
301
480
  this.#favored = void 0;
302
481
  return void 0;
303
482
  }
304
483
  const now = this.#now();
305
484
  if (!this.#favored || this.#favored.key !== desiredKey) {
306
- this.#favored = { key: desiredKey, since: now };
485
+ this.#favored = { key: desiredKey, since: now, source: "headroom" };
307
486
  return void 0;
308
487
  }
309
488
  if (now - this.#favored.since < this.#dwellMs) return void 0;
@@ -313,21 +492,51 @@
313
492
  this.#favored = void 0;
314
493
  return { kind: "join-target", current: this.#current, target: desired };
315
494
  }
495
+ /**
496
+ * Join `target` because the viewer selected it, not because goodput or
497
+ * headroom ranked it. It runs whether or not the orchestrator adapts, and
498
+ * takes the path an adaptive join takes: commit on the target's RAP at or
499
+ * beyond the high-water mark, abandon at the join deadline. While it is
500
+ * pending nothing re-ranks the ladder; goodput only advances the deadline.
501
+ *
502
+ * Returns undefined, changing nothing, while another join is pending (cancel
503
+ * it first), when `target` is the current tier, or when it carries no media.
504
+ */
505
+ select(target) {
506
+ if (!this.#current || this.#state === "joining" || !carriesMedia(target)) return void 0;
507
+ if (endpointKey(target) === endpointKey(this.#current)) return void 0;
508
+ this.#state = "joining";
509
+ this.#target = target;
510
+ this.#joinStartedAt = this.#now();
511
+ this.#favored = void 0;
512
+ return { kind: "join-target", current: this.#current, target };
513
+ }
514
+ /**
515
+ * Feed a packet from the active tier (to advance the high-water mark) or
516
+ * from the in-flight target (to find a committable RAP).
517
+ *
518
+ * Serial order is only the true order while the compared instants are less
519
+ * than `NTP_SHORT_HALF_RANGE` (2^15 s, ~9.1 h) apart. Feeding every
520
+ * active-tier packet keeps the mark that close to the live stream. A mark
521
+ * refreshed only by commits can fall further behind than that, and a
522
+ * target RAP may then compare as older, so the join is abandoned.
523
+ */
316
524
  onTierPacket(packet) {
317
525
  if (!this.#current) return void 0;
318
- if (packet.endpointKey === endpointKey(this.#current) && Number.isFinite(packet.timestamp)) {
319
- this.#lastCommittedTimestamp = Math.max(this.#lastCommittedTimestamp, packet.timestamp);
526
+ const sentAt = tierPacketSendTime(packet);
527
+ if (packet.endpointKey === endpointKey(this.#current) && sentAt !== void 0 && (this.#highWater === void 0 || compareNtpShort(sentAt, this.#highWater) === "after")) {
528
+ this.#highWater = sentAt;
320
529
  }
321
530
  if (this.#state !== "joining" || !this.#target) return void 0;
322
531
  const expired = this.poll();
323
532
  if (expired) return expired;
324
- if (packet.endpointKey !== endpointKey(this.#target) || !packet.keyframe || !packet.hasValidCodecConfig || !Number.isFinite(packet.timestamp) || packet.timestamp < this.#lastCommittedTimestamp) return void 0;
533
+ if (packet.endpointKey !== endpointKey(this.#target) || !packet.keyframe || !packet.hasValidCodecConfig || sentAt === void 0 || !this.#reachesHighWater(sentAt)) return void 0;
325
534
  const command = {
326
535
  kind: "commit-swap",
327
536
  target: this.#target,
328
537
  old: this.#current
329
538
  };
330
- this.#lastCommittedTimestamp = packet.timestamp;
539
+ this.#highWater = sentAt;
331
540
  this.#current = this.#target;
332
541
  this.#target = void 0;
333
542
  this.#state = "stable";
@@ -349,12 +558,481 @@
349
558
  this.#state = "stable";
350
559
  return command;
351
560
  }
561
+ /**
562
+ * Whether a target RAP sent at `sentAt` is at or beyond the active tier's
563
+ * high-water mark. A pair exactly half the NTP-short space apart has no
564
+ * serial order, so it cannot show that and does not commit.
565
+ */
566
+ #reachesHighWater(sentAt) {
567
+ if (this.#highWater === void 0) return true;
568
+ const order = compareNtpShort(sentAt, this.#highWater);
569
+ return order === "after" || order === "equal";
570
+ }
352
571
  #now() {
353
572
  const now = this.options.now();
354
573
  if (!Number.isFinite(now)) throw new Error("Multicast orchestrator clock must be finite");
355
574
  return now;
356
575
  }
357
576
  };
577
+ function tierPacketSendTime(packet) {
578
+ if (!Number.isFinite(packet.timestamp)) return void 0;
579
+ if (!isNtpShort(packet.timestamp)) {
580
+ throw new RangeError(
581
+ `Multicast tier packet timestamp must be an MMTP NTP-short send time, got ${packet.timestamp}`
582
+ );
583
+ }
584
+ return packet.timestamp;
585
+ }
586
+
587
+ // src/multicast-signaling-reconciler.ts
588
+ function createMulticastSignalingReconciler(catalog, options = {}) {
589
+ const initial = normalizeCatalogEndpoints(catalog);
590
+ const endpoints = new Map(initial.map((endpoint) => [endpoint.endpointId, copyEndpoint(endpoint)]));
591
+ const versions = new Map(initial.map((endpoint) => [endpoint.endpointId, endpoint.endpointVersion]));
592
+ const seen = /* @__PURE__ */ new Set();
593
+ const dormant = /* @__PURE__ */ new Set();
594
+ let catalogVersion = Number(catalog?.multicast?.version ?? catalog?.version ?? 0) || 0;
595
+ const snapshot = (now = Date.now()) => ({
596
+ catalogVersion,
597
+ endpoints: [...endpoints.values()].filter((endpoint) => !dormant.has(endpoint.endpointId) && !isExpired(endpoint, now)).map(copyEndpoint),
598
+ dormantEndpointIds: [...dormant].sort()
599
+ });
600
+ return {
601
+ apply(signal, now = Date.now()) {
602
+ if (!isObject(signal) || typeof signal.type !== "string" || typeof signal.msgId !== "string") return { applied: false, reason: "invalid-message" };
603
+ if (seen.has(signal.msgId)) return { applied: false, reason: "duplicate-message" };
604
+ if (signal.expiresAt != null && Number(signal.expiresAt) < now) return { applied: false, reason: "expired" };
605
+ const signalCatalogVersion = Number(signal.catalogVersion ?? 0);
606
+ if (!Number.isFinite(signalCatalogVersion)) return { applied: false, reason: "invalid-message" };
607
+ if (signalCatalogVersion < catalogVersion) return { applied: false, reason: "stale-catalog" };
608
+ const result = applyFresh(signal, now);
609
+ if (result.applied) {
610
+ seen.add(signal.msgId);
611
+ if (signal.type !== "heartbeat") {
612
+ catalogVersion = Math.max(catalogVersion, signalCatalogVersion);
613
+ }
614
+ }
615
+ return result;
616
+ },
617
+ snapshot,
618
+ toCatalog(now = Date.now()) {
619
+ const current = snapshot(now);
620
+ return { ...catalog, multicast: { ...catalog.multicast, version: current.catalogVersion, endpoints: current.endpoints } };
621
+ }
622
+ };
623
+ function applyFresh(signal, now) {
624
+ switch (signal.type) {
625
+ case "snapshot": {
626
+ const next = signal.payload?.endpoints;
627
+ if (!Array.isArray(next)) return { applied: false, reason: "invalid-message" };
628
+ const normalized = next.map((endpoint, index) => normalizeEndpoint(endpoint, index));
629
+ if (normalized.some((endpoint) => !endpoint || !isValidEndpoint(endpoint, options.catalog))) return { applied: false, reason: "invalid-endpoint" };
630
+ const ids = new Set(normalized.map((endpoint) => endpoint.endpointId));
631
+ if (ids.size !== normalized.length) return { applied: false, reason: "duplicate-endpoint" };
632
+ endpoints.clear();
633
+ versions.clear();
634
+ dormant.clear();
635
+ for (const endpoint of normalized) {
636
+ versions.set(endpoint.endpointId, endpoint.endpointVersion);
637
+ if (!isExpired(endpoint, now)) endpoints.set(endpoint.endpointId, copyEndpoint(endpoint));
638
+ }
639
+ return { applied: true, action: "snapshot" };
640
+ }
641
+ case "add":
642
+ return upsert(signal.payload?.endpoint, now);
643
+ case "update": {
644
+ const id = signal.payload?.endpointId;
645
+ const fields = signal.payload?.fields;
646
+ if (!endpoints.has(id)) return { applied: false, reason: "unknown-endpoint" };
647
+ if (isStale(id, signal.payload?.endpointVersion)) return { applied: false, reason: "stale-endpoint" };
648
+ if (!isObject(fields) || ["protocol", "sourceAddress", "groupAddress", "port"].some((key) => Object.hasOwn(fields, key))) return { applied: false, reason: "invalid-update" };
649
+ return upsert({ ...endpoints.get(id), ...fields, endpointVersion: signal.payload.endpointVersion }, now);
650
+ }
651
+ case "remove": {
652
+ const id = signal.payload?.endpointId;
653
+ if (!endpoints.has(id)) return { applied: false, reason: "unknown-endpoint" };
654
+ const endpointVersion = Number(signal.payload?.endpointVersion);
655
+ if (!Number.isFinite(endpointVersion)) return { applied: false, reason: "invalid-message" };
656
+ if (isStale(id, endpointVersion)) return { applied: false, reason: "stale-endpoint" };
657
+ endpoints.delete(id);
658
+ dormant.delete(id);
659
+ versions.set(id, endpointVersion);
660
+ return { applied: true, action: "drop" };
661
+ }
662
+ case "invalidate": {
663
+ const id = signal.payload?.endpointId;
664
+ if (!endpoints.has(id)) return { applied: false, reason: "unknown-endpoint" };
665
+ dormant.add(id);
666
+ return { applied: true, action: "drop" };
667
+ }
668
+ case "heartbeat":
669
+ return { applied: true, action: "heartbeat" };
670
+ default:
671
+ return { applied: false, reason: "unknown-message-type" };
672
+ }
673
+ }
674
+ function upsert(value, now) {
675
+ const endpoint = normalizeEndpoint(value, endpoints.size);
676
+ if (!endpoint || !isValidEndpoint(endpoint, options.catalog)) return { applied: false, reason: "invalid-endpoint" };
677
+ if (isStale(endpoint.endpointId, endpoint.endpointVersion)) return { applied: false, reason: "stale-endpoint" };
678
+ versions.set(endpoint.endpointId, endpoint.endpointVersion);
679
+ dormant.delete(endpoint.endpointId);
680
+ if (isExpired(endpoint, now)) endpoints.delete(endpoint.endpointId);
681
+ else endpoints.set(endpoint.endpointId, copyEndpoint(endpoint));
682
+ return { applied: true, action: isExpired(endpoint, now) ? "drop" : "candidate" };
683
+ }
684
+ function isStale(id, version) {
685
+ const current = versions.get(id);
686
+ return current !== void 0 && Number(version) <= current;
687
+ }
688
+ }
689
+ function normalizeCatalogEndpoints(catalog) {
690
+ const endpoints = catalog?.multicast?.endpoints;
691
+ if (!Array.isArray(endpoints)) return [];
692
+ return endpoints.map(normalizeEndpoint).filter((endpoint) => !!endpoint && isValidEndpoint(endpoint, catalog));
693
+ }
694
+ function normalizeEndpoint(value, index) {
695
+ if (!isObject(value)) return void 0;
696
+ const endpoint = value;
697
+ const tracks = Array.isArray(endpoint.tracks) ? endpoint.tracks.map((track) => typeof track === "string" ? { name: track } : { ...track }).filter((track) => track.name) : [];
698
+ return { ...endpoint, endpointId: endpoint.endpointId || `${endpoint.sourceAddress || ""}|${endpoint.groupAddress || ""}|${endpoint.port || ""}|${index}`, endpointVersion: Number(endpoint.endpointVersion ?? 0), tracks };
699
+ }
700
+ function isValidEndpoint(endpoint, catalog) {
701
+ if (!endpoint.endpointId || !Number.isFinite(endpoint.endpointVersion)) return false;
702
+ if (!["ssm", "asm", "amt"].includes(endpoint.protocol)) return false;
703
+ if (endpoint.protocol === "ssm" && (typeof endpoint.sourceAddress !== "string" || endpoint.sourceAddress.trim().length === 0)) return false;
704
+ if (!isValidMulticastGroupAddress(endpoint.groupAddress) || !Number.isInteger(Number(endpoint.port)) || endpoint.port <= 0 || endpoint.port > 65535 || !endpoint.tracks.length) return false;
705
+ const catalogTracks = /* @__PURE__ */ new Set([...Object.keys(catalog?.video?.renditions ?? {}), ...Object.keys(catalog?.audio?.renditions ?? {})]);
706
+ return endpoint.tracks.every((track) => !!track.name && isMulticastTrackPacketId(track.packetId) && (catalogTracks.size === 0 || catalogTracks.has(track.name) || catalogTracks.has(track.name.replace(/\/repair$/, ""))));
707
+ }
708
+ function copyEndpoint(endpoint) {
709
+ return { ...endpoint, tracks: endpoint.tracks.map((track) => ({ ...track })) };
710
+ }
711
+ function isExpired(endpoint, now) {
712
+ return endpoint.validUntil != null && Number(endpoint.validUntil) < now;
713
+ }
714
+ function isObject(value) {
715
+ return value !== null && typeof value === "object";
716
+ }
717
+
718
+ // src/multicast-catalog-policy.ts
719
+ function hasMulticastCatalogBaseline(catalog) {
720
+ const multicast = catalog?.multicast;
721
+ return normalizeCatalogEndpoints(catalog).length > 0 || Boolean(multicast?.groupAddress && multicast?.port);
722
+ }
723
+ function hasUnicastMediaRenditions(catalog) {
724
+ return Object.keys(catalog?.video?.renditions ?? {}).length > 0 || Object.keys(catalog?.audio?.renditions ?? {}).length > 0;
725
+ }
726
+ function isMulticastOnlyCatalog(catalog) {
727
+ return hasMulticastCatalogBaseline(catalog) && !hasUnicastMediaRenditions(catalog);
728
+ }
729
+ function requiresMulticastCapability(catalog, capabilities = {}) {
730
+ return isMulticastOnlyCatalog(catalog) && !capabilities.multicast;
731
+ }
732
+ function isRecoverableMulticastSignalReadError(error) {
733
+ const candidate = error;
734
+ const name = String(candidate?.name ?? "");
735
+ const message = String(candidate?.message ?? error ?? "");
736
+ return name === "SyntaxError" || /not valid JSON|unexpected token|JSON\.parse|encoded data was not valid|malformed/i.test(message);
737
+ }
738
+ function withMulticastEndpoints(catalog, endpoints, version) {
739
+ if (!catalog?.multicast) return catalog;
740
+ return {
741
+ ...catalog,
742
+ multicast: {
743
+ ...catalog.multicast,
744
+ version,
745
+ endpoints: endpoints.map(copyEndpoint2)
746
+ }
747
+ };
748
+ }
749
+ function selectMulticastCandidate(catalog, endpoints = normalizeCatalogEndpoints(catalog), search = currentSearchParams()) {
750
+ const params = search instanceof URLSearchParams ? search : new URLSearchParams(search);
751
+ return selectMulticastCandidateForTracks(catalog, endpoints, {
752
+ containerKind: params.get("packaging") || params.get("container") || "mmtp",
753
+ rendition: params.get("rendition") || void 0
754
+ });
755
+ }
756
+ function selectMulticastCandidateForTracks(catalog, endpoints = normalizeCatalogEndpoints(catalog), selection = {}) {
757
+ const packaging = selection.containerKind || "mmtp";
758
+ const renditionTrack = selection.rendition || "";
759
+ const preferredTrack = packaging === "mmtp" ? "video" : `video.${packaging}`;
760
+ const usableEndpoints = endpoints.filter(isUsableMulticastEndpoint);
761
+ const defaultRenditionTrack = renditionTrack ? "" : lowestServedVideoRendition(catalog, usableEndpoints);
762
+ return renditionTrack && usableEndpoints.find((endpoint) => endpoint.tracks?.some((track) => matchesPreferredTrack(track.name, renditionTrack))) || defaultRenditionTrack && usableEndpoints.find((endpoint) => endpoint.tracks?.some((track) => matchesPreferredTrack(track.name, defaultRenditionTrack))) || usableEndpoints.find((endpoint) => endpoint.tracks?.some((track) => matchesPreferredTrack(track.name, preferredTrack))) || usableEndpoints[0] || null;
763
+ }
764
+ function endpointToMulticastConfig(endpoint, catalog, selection) {
765
+ if (!endpoint || !isUsableMulticastEndpoint(endpoint) || advertisesInvalidPacketId(endpoint)) return null;
766
+ const config = {
767
+ group: endpoint.groupAddress,
768
+ source: endpoint.sourceAddress || "",
769
+ port: Number(endpoint.port)
770
+ };
771
+ const networkSource = catalog?.multicast?.networkSource;
772
+ const amt = findAmtRelay(networkSource);
773
+ if (amt) {
774
+ config.mode = "tunnel";
775
+ config.amtRelay = amt.relay;
776
+ config.amtRelayPort = amt.port;
777
+ } else if (endpoint.protocol === "amt") {
778
+ const amtEntries = amtNetworkSourceEntries(networkSource);
779
+ if (amtEntries.length > 0) config.amtNetworkSource = amtEntries;
780
+ }
781
+ const packetIds = selection === void 0 ? selectEndpointPacketIds(endpoint, catalog) : selectEndpointPacketIdsForTracks(endpoint, catalog, selection);
782
+ if (packetIds === null) return null;
783
+ if (packetIds.length > 0) config.packetIds = packetIds;
784
+ return config;
785
+ }
786
+ function selectEndpointPacketIds(endpoint, catalog, search = currentSearchParams()) {
787
+ const params = search instanceof URLSearchParams ? search : new URLSearchParams(search);
788
+ return selectEndpointPacketIdsForTracks(endpoint, catalog, {
789
+ containerKind: params.get("packaging") || params.get("container") || "mmtp",
790
+ rendition: params.get("rendition") || void 0
791
+ });
792
+ }
793
+ function selectEndpointPacketIdsForTracks(endpoint, catalog, selection = {}) {
794
+ if (!endpoint || !Array.isArray(endpoint.tracks)) return [];
795
+ if (advertisesInvalidPacketId(endpoint)) return null;
796
+ const tracks = endpoint.tracks.map((track) => ({
797
+ name: typeof track?.name === "string" ? track.name : "",
798
+ packetId: validPacketId(track?.packetId)
799
+ })).filter((track) => track.name.length > 0 && track.packetId !== void 0);
800
+ if (tracks.length === 0) return [];
801
+ const packaging = selection.containerKind || "mmtp";
802
+ const renditionTrack = selection.rendition || "";
803
+ const videoRenditions = catalogRenditions(catalog, "video");
804
+ const audioRenditions = catalogRenditions(catalog, "audio");
805
+ const videoTracks = tracks.filter((track) => Object.hasOwn(videoRenditions, track.name));
806
+ const audioTracks = tracks.filter((track) => Object.hasOwn(audioRenditions, track.name));
807
+ const preferredVideo = packaging === "mmtp" ? "video" : `video.${packaging}`;
808
+ const preferredAudio = packaging === "mmtp" ? "audio" : `audio.${packaging}`;
809
+ const video = (renditionTrack ? videoTracks.find((track) => track.name === renditionTrack) : void 0) ?? videoTracks.find((track) => track.name === preferredVideo) ?? [...videoTracks].sort((a, b) => videoRenditionRank(videoRenditions, a.name) - videoRenditionRank(videoRenditions, b.name))[0];
810
+ const audio = audioTracks.find((track) => track.name === preferredAudio) ?? audioTracks[0];
811
+ const selected = [video, audio].filter(
812
+ (track) => track !== void 0
813
+ );
814
+ if (selected.length === 0) return normalizePacketIds(tracks.map((track) => track.packetId));
815
+ const selectedRepairNames = new Set(selected.flatMap((track) => catalogRepairTracks(catalog, track.name)));
816
+ const selectedRepairTracks = tracks.filter((track) => selectedRepairNames.has(track.name));
817
+ return normalizePacketIds([...selected, ...selectedRepairTracks].map((track) => track.packetId));
818
+ }
819
+ function readPlayerMulticastConfig(search = currentSearchParams()) {
820
+ const params = search instanceof URLSearchParams ? search : new URLSearchParams(search || "");
821
+ const group = params.get("mc_group") || "";
822
+ if (!isValidMulticastGroupAddress(group)) return null;
823
+ const port = positivePort2(params.get("mc_port"));
824
+ if (!port) return null;
825
+ assertNoRetiredMulticastUrlParams(params);
826
+ const mode = params.get("mc_mode") || "";
827
+ const amtRelay = params.get("mc_amt_relay") || "";
828
+ const amtRelayPort = positivePort2(params.get("mc_amt_relay_port"));
829
+ const config = {
830
+ group,
831
+ source: params.get("mc_source") || "",
832
+ port
833
+ };
834
+ if (mode) config.mode = mode;
835
+ if (amtRelay) {
836
+ config.amtRelay = amtRelay;
837
+ if (amtRelay.trim()) config.mode = "tunnel";
838
+ }
839
+ if (amtRelayPort) config.amtRelayPort = amtRelayPort;
840
+ return config;
841
+ }
842
+ function normalizeMulticastConfig(config) {
843
+ if (!config?.group) return null;
844
+ if (Array.isArray(config.packetIds) && !config.packetIds.every(isMulticastTrackPacketId)) return null;
845
+ const amt = findAmtRelay(config.amtNetworkSource);
846
+ const amtRelay = config.amtRelay || amt?.relay || "";
847
+ const dialableRelay = amtRelay.trim();
848
+ const amtRelayPort = positivePort2(String(config.amtRelayPort ?? "")) ?? (dialableRelay ? amt?.port ?? DEFAULT_AMT_RELAY_PORT : void 0);
849
+ const packetIds = normalizePacketIds(config.packetIds);
850
+ const pendingAmt = dialableRelay ? [] : amtNetworkSourceEntries(config.amtNetworkSource);
851
+ return {
852
+ group: config.group,
853
+ source: config.source || "",
854
+ port: Number(config.port),
855
+ mode: dialableRelay ? "tunnel" : config.mode || "auto",
856
+ amtRelay,
857
+ ...amtRelayPort ? { amtRelayPort } : {},
858
+ // Relay-pending DRIAD survives normalization while it is still pending:
859
+ // dropping it here would erase, one hop before the join, the only thing
860
+ // telling the call site "AMT was asked for and is unresolved" apart from
861
+ // "this endpoint was always native" (BLO-38184). Once a relay resolves
862
+ // the entries have done their job and are not carried. `amtNetworkSource`
863
+ // is deliberately not part of `sameMulticastConfig`: it is provenance for
864
+ // the relay, not endpoint identity, so it must not report a change.
865
+ //
866
+ // That holds only while no caller acts on the field — today a pending
867
+ // DRIAD entry and a native endpoint both join `ip-multicast`, so
868
+ // comparing equal reports the truth. Once a caller resolves DRIAD and
869
+ // tunnels, a catalog swapping native for DRIAD-AMT becomes a real
870
+ // join-behaviour change that would compare equal and never re-join:
871
+ // whoever wires that resolution adds `amtNetworkSource` to
872
+ // `sameMulticastConfig` in the same change.
873
+ ...pendingAmt.length > 0 ? { amtNetworkSource: pendingAmt } : {},
874
+ ...packetIds.length > 0 ? { packetIds } : {}
875
+ };
876
+ }
877
+ function sameMulticastConfig(a, b) {
878
+ const left = normalizeMulticastConfig(a);
879
+ const right = normalizeMulticastConfig(b);
880
+ return Boolean(left && right && left.group === right.group && left.source === right.source && left.port === right.port && left.mode === right.mode && left.amtRelay === right.amtRelay && (left.amtRelayPort ?? 0) === (right.amtRelayPort ?? 0) && JSON.stringify(left.packetIds ?? []) === JSON.stringify(right.packetIds ?? []));
881
+ }
882
+ function urlOverrideBlocksMulticastCandidate(candidateConfig, urlConfig = readPlayerMulticastConfig()) {
883
+ if (!urlConfig) return false;
884
+ const candidate = normalizeMulticastConfig(candidateConfig);
885
+ const explicit = normalizeMulticastConfig(urlConfig);
886
+ return !candidate || !explicit || candidate.group !== explicit.group || candidate.source !== explicit.source || candidate.port !== explicit.port;
887
+ }
888
+ function mergeUrlMulticastPolicy(candidateConfig, urlConfig = readPlayerMulticastConfig()) {
889
+ const candidate = normalizeMulticastConfig(candidateConfig);
890
+ if (!candidate || !urlConfig || urlOverrideBlocksMulticastCandidate(candidate, urlConfig)) return candidate;
891
+ const merged = { ...candidate };
892
+ if (urlConfig.mode !== void 0) {
893
+ merged.mode = urlConfig.mode;
894
+ if (urlConfig.mode !== "tunnel" && urlConfig.amtRelay === void 0) {
895
+ merged.amtRelay = "";
896
+ delete merged.amtRelayPort;
897
+ delete merged.amtNetworkSource;
898
+ }
899
+ }
900
+ if (urlConfig.amtRelay !== void 0) {
901
+ merged.amtRelay = urlConfig.amtRelay;
902
+ delete merged.amtRelayPort;
903
+ if (urlConfig.amtRelay.trim()) {
904
+ merged.mode = "tunnel";
905
+ delete merged.amtNetworkSource;
906
+ } else if (urlConfig.mode === void 0 && candidate.amtRelay?.trim()) {
907
+ merged.mode = "auto";
908
+ }
909
+ }
910
+ if (urlConfig.amtRelayPort !== void 0) merged.amtRelayPort = urlConfig.amtRelayPort;
911
+ return normalizeMulticastConfig(merged);
912
+ }
913
+ function currentSearchParams() {
914
+ return typeof location === "undefined" ? new URLSearchParams() : new URLSearchParams(location.search);
915
+ }
916
+ function positivePort2(value) {
917
+ const port = Number(value);
918
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
919
+ }
920
+ function copyEndpoint2(endpoint) {
921
+ return { ...endpoint, tracks: endpoint.tracks.map((track) => ({ ...track })) };
922
+ }
923
+ function amtNetworkSourceEntries(networkSource) {
924
+ const sources = Array.isArray(networkSource) ? networkSource : networkSource ? [networkSource] : [];
925
+ return sources.filter((entry) => entry?.type === "amt").map((entry) => ({ ...entry }));
926
+ }
927
+ function findAmtRelay(networkSource) {
928
+ const source = amtNetworkSourceEntries(networkSource).find((candidate) => candidate?.discovery !== "driad" && typeof candidate?.relay === "string" && candidate.relay.trim());
929
+ if (!source) return void 0;
930
+ const port = positivePort2(String(source.port ?? ""));
931
+ return { relay: source.relay.trim(), port: port ?? DEFAULT_AMT_RELAY_PORT };
932
+ }
933
+ function validPacketId(value) {
934
+ return isMulticastTrackPacketId(value) ? value : void 0;
935
+ }
936
+ function advertisesInvalidPacketId(endpoint) {
937
+ return Array.isArray(endpoint.tracks) && endpoint.tracks.some((track) => track?.packetId !== void 0 && !isMulticastTrackPacketId(track.packetId));
938
+ }
939
+ function normalizePacketIds(packetIds) {
940
+ if (!Array.isArray(packetIds)) return [];
941
+ return [...new Set(packetIds.map(validPacketId).filter((id) => id !== void 0))].sort(
942
+ (a, b) => a - b
943
+ );
944
+ }
945
+ function matchesPreferredTrack(trackName, preferredTrack) {
946
+ return trackName === preferredTrack || trackName.startsWith(`${preferredTrack}/`);
947
+ }
948
+ function catalogRenditions(catalog, role) {
949
+ const renditions = { ...catalog?.[role]?.renditions ?? {} };
950
+ for (const track of Array.isArray(catalog?.tracks) ? catalog.tracks : []) {
951
+ if (track?.role === role && typeof track.name === "string" && track.name.length > 0) {
952
+ renditions[track.name] = track;
953
+ }
954
+ }
955
+ return renditions;
956
+ }
957
+ function assertNoRetiredTrackFields(catalog) {
958
+ if (catalog && typeof catalog === "object" && "commonTrackFields" in catalog) {
959
+ throw new Error("multicast catalog policy: $.commonTrackFields is retired; every track must declare its own packaging");
960
+ }
961
+ const tracks = Array.isArray(catalog?.tracks) ? catalog.tracks : [];
962
+ tracks.forEach((track, index) => {
963
+ if (track && typeof track === "object" && "selectionParams" in track) {
964
+ throw new Error(`multicast catalog policy: $.tracks[${index}].selectionParams is retired; selection fields are flat on the track`);
965
+ }
966
+ });
967
+ }
968
+ function catalogRepairTracks(catalog, name) {
969
+ assertNoRetiredTrackFields(catalog);
970
+ const tracks = Array.isArray(catalog?.tracks) ? catalog.tracks : [];
971
+ const track = tracks.find((candidate) => candidate?.name === name);
972
+ const rendition = catalog?.video?.renditions?.[name] ?? catalog?.audio?.renditions?.[name];
973
+ const fec = track?.fec ?? rendition?.fec;
974
+ if (!fec || fec.algorithm === "none") return [];
975
+ const names = /* @__PURE__ */ new Set();
976
+ if (typeof fec.repairTrack === "string" && fec.repairTrack.length > 0) names.add(fec.repairTrack);
977
+ const dependsOn = (entry) => Array.isArray(entry?.depends) && entry.depends.length === 1 && entry.depends[0] === name;
978
+ for (const candidate of tracks) {
979
+ const packaging = candidate?.packaging;
980
+ if (packaging === "fec-repair" && typeof candidate?.name === "string" && candidate.name.length > 0 && dependsOn(candidate)) {
981
+ names.add(candidate.name);
982
+ }
983
+ }
984
+ const repairRenditions = catalog?.repair?.renditions;
985
+ if (repairRenditions && typeof repairRenditions === "object") {
986
+ for (const [repairName, entry] of Object.entries(repairRenditions)) {
987
+ if (repairName.length > 0 && dependsOn(entry)) names.add(repairName);
988
+ }
989
+ }
990
+ return [...names];
991
+ }
992
+ function catalogQualityLadder(catalog, role = "video") {
993
+ return Object.entries(catalogRenditions(catalog, role)).map(([name, config]) => {
994
+ const { bitrate, codedWidth, codedHeight, framerate } = config ?? {};
995
+ if (!isPositiveFinite(bitrate)) {
996
+ throw new RangeError(`[ABR] catalog ${role} rendition ${name} states no positive bitrate to cost it by`);
997
+ }
998
+ const rung = { name, bitrate };
999
+ if (isPositiveFinite(codedWidth)) rung.width = codedWidth;
1000
+ if (isPositiveFinite(codedHeight)) rung.height = codedHeight;
1001
+ if (isPositiveFinite(framerate)) rung.fps = framerate;
1002
+ return rung;
1003
+ });
1004
+ }
1005
+ function isPositiveFinite(value) {
1006
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
1007
+ }
1008
+ function videoRenditionRank(config, name) {
1009
+ if (typeof config?.bitrate === "number" && config.bitrate > 0) return config.bitrate;
1010
+ const width = typeof config?.codedWidth === "number" ? config.codedWidth : 0;
1011
+ const height = typeof config?.codedHeight === "number" ? config.codedHeight : 0;
1012
+ if (width > 0 && height > 0) return width * height;
1013
+ const match = /(?:^|[/_.-])(\d{3,4})p(?:$|[/_.-])/i.exec(name);
1014
+ return match ? Number(match[1]) : Number.POSITIVE_INFINITY;
1015
+ }
1016
+ function lowestServedVideoRendition(catalog, endpoints) {
1017
+ const renditions = catalog?.video?.renditions ?? {};
1018
+ const servedTracks = new Set(endpoints.flatMap((endpoint) => endpoint.tracks?.map((track) => track.name) ?? []));
1019
+ let selected = "";
1020
+ let selectedRank = Number.POSITIVE_INFINITY;
1021
+ for (const [name, config] of Object.entries(renditions)) {
1022
+ if (!name || !servedTracks.has(name)) continue;
1023
+ const rank = videoRenditionRank(config, name);
1024
+ if (!selected || rank < selectedRank) {
1025
+ selected = name;
1026
+ selectedRank = rank;
1027
+ }
1028
+ }
1029
+ return selected;
1030
+ }
1031
+
1032
+ // src/packet-batch.ts
1033
+ function subscriptionDeliversPacketBatches(subscription) {
1034
+ return subscription.eventTypes?.includes("packets") === true;
1035
+ }
358
1036
 
359
1037
  // src/multicast-subscription-pool.ts
360
1038
  var MulticastSubscriptionPool = class {
@@ -420,19 +1098,28 @@
420
1098
  return this.#entries.size;
421
1099
  }
422
1100
  async #createEntry(gateway, endpoint, onClosed) {
423
- const subscription = await gateway.subscribe({
1101
+ const subscription = await gateway.subscribe(toWindowMulticastSubscribeConfig({
424
1102
  group: endpoint.groupAddress,
425
1103
  ...endpoint.sourceAddress ? { source: endpoint.sourceAddress } : {},
426
1104
  port: endpoint.port,
427
- consume: "bytes",
428
1105
  ...endpoint.mode ? { mode: endpoint.mode } : {},
429
1106
  ...endpoint.amtRelay ? { amtRelay: endpoint.amtRelay } : {},
430
1107
  ...endpoint.amtRelayPort ? { amtRelayPort: endpoint.amtRelayPort } : {}
431
- });
1108
+ }));
432
1109
  const consumers = /* @__PURE__ */ new Map();
433
1110
  let entry;
434
- const bytesListener = (event) => {
435
- this.#dispatch(consumers, (consumer) => consumer.onBytes(event.detail));
1111
+ const packetListener = subscriptionDeliversPacketBatches(subscription) ? {
1112
+ type: "packets",
1113
+ listener: (event) => {
1114
+ for (const data of event.detail) {
1115
+ this.#dispatch(consumers, (consumer) => consumer.onBytes(data));
1116
+ }
1117
+ }
1118
+ } : {
1119
+ type: "bytes",
1120
+ listener: (event) => {
1121
+ this.#dispatch(consumers, (consumer) => consumer.onBytes(event.detail));
1122
+ }
436
1123
  };
437
1124
  const stateListener = (event) => {
438
1125
  this.#dispatch(consumers, (consumer) => consumer.onState?.(event.detail));
@@ -445,8 +1132,12 @@
445
1132
  const error = event.detail instanceof Error ? event.detail : new Error(String(event.detail));
446
1133
  this.#dispatch(consumers, (consumer) => consumer.onError?.(error));
447
1134
  };
448
- entry = { subscription, consumers, bytesListener, stateListener, errorListener, closed: false };
449
- subscription.addEventListener("bytes", bytesListener);
1135
+ entry = { subscription, consumers, packetListener, stateListener, errorListener, closed: false };
1136
+ if (packetListener.type === "packets") {
1137
+ subscription.addEventListener("packets", packetListener.listener);
1138
+ } else {
1139
+ subscription.addEventListener("bytes", packetListener.listener);
1140
+ }
450
1141
  subscription.addEventListener("state", stateListener);
451
1142
  subscription.addEventListener("error", errorListener);
452
1143
  return entry;
@@ -461,7 +1152,12 @@
461
1152
  return id;
462
1153
  }
463
1154
  #detach(entry) {
464
- entry.subscription.removeEventListener("bytes", entry.bytesListener);
1155
+ const { packetListener } = entry;
1156
+ if (packetListener.type === "packets") {
1157
+ entry.subscription.removeEventListener("packets", packetListener.listener);
1158
+ } else {
1159
+ entry.subscription.removeEventListener("bytes", packetListener.listener);
1160
+ }
465
1161
  entry.subscription.removeEventListener("state", entry.stateListener);
466
1162
  entry.subscription.removeEventListener("error", entry.errorListener);
467
1163
  }
@@ -477,7 +1173,70 @@
477
1173
  };
478
1174
  var multicastSubscriptionPool = new MulticastSubscriptionPool();
479
1175
 
1176
+ // src/moqt-priority.ts
1177
+ var MAX_MOQT_PRIORITY = 255;
1178
+ function moqtPriority(value, field) {
1179
+ if (!Number.isInteger(value) || value < 0 || value > MAX_MOQT_PRIORITY) {
1180
+ throw new RangeError(`${field} must be an integer in [0, ${MAX_MOQT_PRIORITY}], got ${value}`);
1181
+ }
1182
+ return value;
1183
+ }
1184
+ function moqtSubscriberPriority(value) {
1185
+ return moqtPriority(value, "MOQT subscriber priority");
1186
+ }
1187
+ function moqtPublisherPriority(value) {
1188
+ return moqtPriority(value, "MOQT publisher priority");
1189
+ }
1190
+ var MOQT_SUBSCRIBER_PRIORITY = Object.freeze({
1191
+ catalog: moqtSubscriberPriority(0),
1192
+ /**
1193
+ * @deprecated A repair FETCH's SUBSCRIBER_PRIORITY is derived per request
1194
+ * from the catalog source and repair priorities (draft-ramadan-moq-fec
1195
+ * §11.4.5), not a player constant. Use the `subscriberPriority` that
1196
+ * `@blockcast/mmt-container`'s `wireRepairFetch` puts on each
1197
+ * `RepairFetchRequest` (`repairFetchSubscriberPriority`).
1198
+ */
1199
+ deadlineRepair: moqtSubscriberPriority(0),
1200
+ audio: moqtSubscriberPriority(1),
1201
+ video: moqtSubscriberPriority(2),
1202
+ /**
1203
+ * @deprecated See `deadlineRepair`: use the request's catalog-derived
1204
+ * `RepairFetchRequest.subscriberPriority` (draft-ramadan-moq-fec §11.4.5).
1205
+ */
1206
+ backgroundRepair: moqtSubscriberPriority(3)
1207
+ });
1208
+ var MOQT_PUBLISHER_PRIORITY = Object.freeze({
1209
+ catalog: moqtPublisherPriority(32),
1210
+ source: moqtPublisherPriority(128),
1211
+ repair: moqtPublisherPriority(240)
1212
+ });
1213
+
480
1214
  // src/stall-detector.ts
1215
+ function classifyStall({
1216
+ liveQ = null,
1217
+ decodeCallsDelta = null,
1218
+ fps = null
1219
+ } = {}) {
1220
+ if (liveQ != null && liveQ >= 20) {
1221
+ return {
1222
+ verdict: "DECODE-BOUND",
1223
+ reason: `decode queue backlogged now (liveQ=${liveQ})`
1224
+ };
1225
+ }
1226
+ if (liveQ != null && liveQ <= 2 && decodeCallsDelta != null && decodeCallsDelta <= 1) {
1227
+ return {
1228
+ verdict: "PIPELINE-STARVED",
1229
+ reason: `queue empty (liveQ=${liveQ}) and decode flatlined (\u0394calls=${decodeCallsDelta}) \u2014 nothing reaching the decoder; suspect delivery/FEC/failback, not decode perf`
1230
+ };
1231
+ }
1232
+ if (fps != null && fps < 45) {
1233
+ return {
1234
+ verdict: "STARVED-OR-SLOW",
1235
+ reason: `low decode rate (fps=${fps}) without a queue/flatline signal to disambiguate`
1236
+ };
1237
+ }
1238
+ return { verdict: "INDETERMINATE", reason: "no signal supports a verdict" };
1239
+ }
481
1240
  var TransportStallDetector = class {
482
1241
  constructor(config) {
483
1242
  this.config = config;
@@ -553,13 +1312,39 @@
553
1312
  }
554
1313
  async function wireStarvationDetector(args) {
555
1314
  if (!args.session || args.fecInterleaveMs <= 0 || args.fecK <= 0) return null;
1315
+ const windowMs = args.deliveryWindowMs ?? Math.ceil(args.fecInterleaveMs * 3);
1316
+ if (!Number.isFinite(windowMs) || windowMs <= 0) {
1317
+ args.warn?.(`Unable to create starvation detector for ${args.trackName}: invalid delivery window ${windowMs}`);
1318
+ return null;
1319
+ }
556
1320
  const detector = wireStallDetector({
557
1321
  session: args.session,
558
- windowMs: Math.ceil(args.fecInterleaveMs * 3),
1322
+ windowMs,
559
1323
  makeDetector: args.makeDetector
560
1324
  });
561
1325
  if (!detector) args.warn?.(`Unable to create starvation detector for ${args.trackName}`);
562
- return detector;
1326
+ if (!detector || typeof args.session.state !== "string") return detector;
1327
+ let active = false;
1328
+ return {
1329
+ update(snapshot) {
1330
+ const state = args.session.state;
1331
+ const shouldObserve = state === "multicast" || state === "amt" || state === "failing";
1332
+ if (!shouldObserve) {
1333
+ if (active) detector.reset();
1334
+ active = false;
1335
+ return;
1336
+ }
1337
+ if (!active) {
1338
+ detector.reset();
1339
+ active = true;
1340
+ }
1341
+ detector.update(snapshot);
1342
+ },
1343
+ reset() {
1344
+ active = false;
1345
+ detector.reset();
1346
+ }
1347
+ };
563
1348
  }
564
1349
 
565
1350
  // src/driad-discovery.ts
@@ -619,10 +1404,7 @@
619
1404
  if (dohRelay) {
620
1405
  return [dohRelay];
621
1406
  }
622
- return [
623
- { host: "amt-relay.m2icast.net", port: 2268, priority: 10, weight: 100, asn: 0 },
624
- { host: "amt.akamaistream.net", port: 2268, priority: 20, weight: 50, asn: 0 }
625
- ];
1407
+ return [];
626
1408
  }
627
1409
  /**
628
1410
  * Query DNS-over-HTTPS for DRIAD relay based on source address (RFC 8777)
@@ -643,7 +1425,7 @@
643
1425
  if (result.Answer && result.Answer.length > 0) {
644
1426
  return {
645
1427
  host: result.Answer[0].data,
646
- port: 2268,
1428
+ port: DEFAULT_AMT_RELAY_PORT,
647
1429
  priority: 10,
648
1430
  weight: 100,
649
1431
  asn: 0
@@ -713,16 +1495,14 @@
713
1495
  capabilities = null;
714
1496
  subscription = null;
715
1497
  driad = new DRIADDiscovery();
716
- abr;
1498
+ /** Built by `connect()` from the caller's ladder; there is no built-in one. */
1499
+ abr = null;
717
1500
  multicastOrchestrator = null;
718
1501
  onOrchestratorCommand;
719
1502
  connectionGeneration = 0;
720
1503
  onPacket;
721
1504
  onError;
722
1505
  onStateChange;
723
- constructor() {
724
- this.abr = new AdaptiveBitrateController();
725
- }
726
1506
  getWindow() {
727
1507
  return typeof window === "undefined" ? void 0 : window;
728
1508
  }
@@ -773,11 +1553,31 @@
773
1553
  * 1. window.multicast gateway bytes
774
1554
  * 2. AMT proxy (specified or discovered via DRIAD)
775
1555
  * 3. MOQ WebTransport (final fallback)
1556
+ *
1557
+ * The ABR ladder (`abrLadder`) and initial rendition (`initialQuality`)
1558
+ * are checked first, before anything is torn down. A config that fails is
1559
+ * refused with the same shape as an invalid endpoint, the error to the
1560
+ * error callback and `false` resolved; unlike one, it is caught before the
1561
+ * live subscription closes, so a refused reconnect leaves that subscription,
1562
+ * its state and any connect already in flight untouched.
776
1563
  */
777
1564
  async connect(config) {
1565
+ let abr;
1566
+ try {
1567
+ abr = new AdaptiveBitrateController({
1568
+ ladder: config.abrLadder,
1569
+ initialQuality: config.initialQuality?.name
1570
+ });
1571
+ } catch (error) {
1572
+ const refused = error instanceof Error ? error : new Error(String(error));
1573
+ if (this.onError) this.onError(refused);
1574
+ else console.error("[Transport] ABR config refused, not connecting:", refused);
1575
+ return false;
1576
+ }
778
1577
  const generation = ++this.connectionGeneration;
779
1578
  this.subscription?.close();
780
1579
  this.subscription = null;
1580
+ this.abr = null;
781
1581
  this.state = {
782
1582
  mode: "disconnected",
783
1583
  connected: false,
@@ -809,10 +1609,8 @@
809
1609
  };
810
1610
  const caps = await this.detectCapabilities();
811
1611
  if (generation !== this.connectionGeneration) return false;
812
- if (config.initialQuality) {
813
- this.abr = new AdaptiveBitrateController(config.initialQuality);
814
- }
815
- this.abr.setCallback((quality) => {
1612
+ this.abr = abr;
1613
+ abr.setCallback((quality) => {
816
1614
  this.state.quality = quality;
817
1615
  this.subscription?.onqualitychange?.(quality);
818
1616
  this.onStateChange?.(this.state);
@@ -877,7 +1675,7 @@
877
1675
  connected: true,
878
1676
  group: config.group,
879
1677
  source: config.source,
880
- quality: this.abr.getCurrentQuality(),
1678
+ quality: this.requireAbr().getCurrentQuality(),
881
1679
  packetsReceived: 0,
882
1680
  bytesReceived: 0,
883
1681
  lastPacketTime: 0
@@ -923,8 +1721,7 @@
923
1721
  async connectAmtProxy(config, amtRelay, generation) {
924
1722
  try {
925
1723
  const relayHost = amtRelay.includes(":") && !amtRelay.startsWith("[") ? `[${amtRelay}]` : amtRelay;
926
- const relayAuthority = config.amtRelayPort ? `${relayHost}:${config.amtRelayPort}` : relayHost;
927
- const wsUrl = new URL(`wss://${relayAuthority}/amt-proxy`);
1724
+ const wsUrl = new URL(`wss://${relayHost}/amt-proxy`);
928
1725
  wsUrl.searchParams.set("group", config.group);
929
1726
  wsUrl.searchParams.set("source", config.source || "");
930
1727
  wsUrl.searchParams.set("port", String(config.port));
@@ -954,7 +1751,7 @@
954
1751
  group: config.group,
955
1752
  source: config.source,
956
1753
  amtRelay,
957
- quality: this.abr.getCurrentQuality(),
1754
+ quality: this.requireAbr().getCurrentQuality(),
958
1755
  packetsReceived: 0,
959
1756
  bytesReceived: 0,
960
1757
  lastPacketTime: 0
@@ -999,7 +1796,7 @@
999
1796
  this.state.packetsReceived++;
1000
1797
  this.state.bytesReceived += data.byteLength;
1001
1798
  this.state.lastPacketTime = Date.now();
1002
- this.abr.recordSample(data.byteLength, 0);
1799
+ this.abr?.recordSample(data.byteLength, 0);
1003
1800
  this.onPacket?.(data);
1004
1801
  }
1005
1802
  /**
@@ -1009,16 +1806,26 @@
1009
1806
  return { ...this.state };
1010
1807
  }
1011
1808
  /**
1012
- * Get current quality level
1809
+ * The rung of the live connection's `abrLadder` ABR is on; `null` when
1810
+ * there is no ladder (before `connect(config)` succeeds, after
1811
+ * `disconnect()`, or after a reconnect that was torn down and bailed).
1013
1812
  */
1014
1813
  getQuality() {
1015
- return this.abr.getCurrentQuality();
1814
+ return this.abr?.getCurrentQuality() ?? null;
1016
1815
  }
1017
1816
  /**
1018
- * Force quality level
1817
+ * Force the rung named `name` of the live connection's `abrLadder`.
1818
+ * Throws `Error` when there is no ladder (see `getQuality()`), and
1819
+ * `RangeError` when the ladder has no rung of that name; it never falls
1820
+ * back to another rung.
1019
1821
  */
1020
1822
  setQuality(name) {
1021
- this.abr.setQuality(name);
1823
+ this.requireAbr().setQuality(name);
1824
+ }
1825
+ /** The controller `connect()` built; before it there is no ladder to adapt over. */
1826
+ requireAbr() {
1827
+ if (!this.abr) throw new Error("[Transport] ABR has no ladder before connect(config)");
1828
+ return this.abr;
1022
1829
  }
1023
1830
  /** Configures multicast-only ABR while leaving the caller's MoQ ABR untouched. */
1024
1831
  configureMulticastTiers(options, onCommand) {
@@ -1030,6 +1837,9 @@
1030
1837
  recordMulticastGoodput(bitrate) {
1031
1838
  return this.dispatchOrchestratorCommand(this.multicastOrchestrator?.onGoodput({ bitrate }));
1032
1839
  }
1840
+ recordMulticastHeadroom(healthy) {
1841
+ return this.dispatchOrchestratorCommand(this.multicastOrchestrator?.onHeadroom({ healthy }));
1842
+ }
1033
1843
  recordMulticastTierPacket(packet) {
1034
1844
  return this.dispatchOrchestratorCommand(this.multicastOrchestrator?.onTierPacket(packet));
1035
1845
  }
@@ -1061,6 +1871,7 @@
1061
1871
  this.connectionGeneration++;
1062
1872
  this.subscription?.close();
1063
1873
  this.subscription = null;
1874
+ this.abr = null;
1064
1875
  this.dispatchOrchestratorCommand(this.multicastOrchestrator?.cancel());
1065
1876
  this.multicastOrchestrator = null;
1066
1877
  this.onOrchestratorCommand = void 0;
@@ -1127,8 +1938,21 @@
1127
1938
  // src/no-modules.ts
1128
1939
  var api = {
1129
1940
  AdaptiveBitrateController,
1941
+ validateQualityLadder,
1942
+ catalogQualityLadder,
1943
+ classifyStall,
1944
+ createMulticastSignalingReconciler,
1945
+ endpointToMulticastConfig,
1946
+ hasMulticastCatalogBaseline,
1947
+ hasUnicastMediaRenditions,
1948
+ isRecoverableMulticastSignalReadError,
1949
+ isMulticastOnlyCatalog,
1130
1950
  MulticastOrchestrator,
1131
1951
  MulticastSubscriptionPool,
1952
+ MOQT_PUBLISHER_PRIORITY,
1953
+ MOQT_SUBSCRIBER_PRIORITY,
1954
+ moqtPublisherPriority,
1955
+ moqtSubscriberPriority,
1132
1956
  TransportManager,
1133
1957
  TransportStallDetector,
1134
1958
  endpointKey,
@@ -1136,11 +1960,22 @@
1136
1960
  isUsableMulticastEndpoint,
1137
1961
  isValidMulticastGroupAddress,
1138
1962
  makeSourceSwitchHandler,
1963
+ mergeUrlMulticastPolicy,
1139
1964
  multicastEndpointKey,
1965
+ normalizeCatalogEndpoints,
1966
+ normalizeMulticastConfig,
1140
1967
  normalizeMulticastEndpoint,
1968
+ readPlayerMulticastConfig,
1141
1969
  readUrlMulticastConfig,
1970
+ toWindowMulticastSubscribeConfig,
1971
+ requiresMulticastCapability,
1142
1972
  runUnicastFallback,
1973
+ sameMulticastConfig,
1974
+ selectEndpointPacketIds,
1975
+ selectMulticastCandidate,
1143
1976
  selectMulticastTier,
1977
+ urlOverrideBlocksMulticastCandidate,
1978
+ withMulticastEndpoints,
1144
1979
  wireStarvationDetector
1145
1980
  };
1146
1981
  Object.defineProperty(globalThis, "__MmtTransport", {