@blockcast/mmt-container 2.1.0 → 2.2.0-main.02321582f1ca

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 (99) hide show
  1. package/README.md +29 -17
  2. package/dist/browser-runtime.js +1302 -249
  3. package/dist/browser-runtime.js.map +4 -4
  4. package/dist/carp-catalog.js.map +1 -1
  5. package/dist/catalog-fec-params.d.ts +17 -1
  6. package/dist/catalog-fec-params.d.ts.map +1 -1
  7. package/dist/catalog-fec-params.js +15 -0
  8. package/dist/catalog-fec-params.js.map +2 -2
  9. package/dist/catalog-network-source.js.map +1 -1
  10. package/dist/cmcd-producer.d.ts +65 -47
  11. package/dist/cmcd-producer.d.ts.map +1 -1
  12. package/dist/cmcd-producer.js +138 -109
  13. package/dist/cmcd-producer.js.map +2 -2
  14. package/dist/delivery-catalog.js +26 -16
  15. package/dist/delivery-catalog.js.map +2 -2
  16. package/dist/delta-fallback-config.js.map +1 -1
  17. package/dist/fec-manager.d.ts +33 -1
  18. package/dist/fec-manager.d.ts.map +1 -1
  19. package/dist/fec-manager.js +213 -6
  20. package/dist/fec-manager.js.map +2 -2
  21. package/dist/fec-state.js.map +1 -1
  22. package/dist/fec-worker-setup.d.ts +1 -1
  23. package/dist/fec-worker-setup.js +1 -1
  24. package/dist/fec-worker-setup.js.map +1 -1
  25. package/dist/held-layer-admission.js.map +1 -1
  26. package/dist/index.d.ts +5 -4
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +9 -1
  29. package/dist/index.js.map +2 -2
  30. package/dist/iso-block-locator.d.ts +9 -0
  31. package/dist/iso-block-locator.d.ts.map +1 -1
  32. package/dist/iso-block-locator.js +17 -0
  33. package/dist/iso-block-locator.js.map +2 -2
  34. package/dist/iso-bmff-timing.d.ts +10 -1
  35. package/dist/iso-bmff-timing.d.ts.map +1 -1
  36. package/dist/iso-bmff-timing.js +36 -2
  37. package/dist/iso-bmff-timing.js.map +2 -2
  38. package/dist/media-router.js.map +1 -1
  39. package/dist/mfu-reassembler.js +12 -19
  40. package/dist/mfu-reassembler.js.map +2 -2
  41. package/dist/mmtp-container-parser.d.ts +37 -2
  42. package/dist/mmtp-container-parser.d.ts.map +1 -1
  43. package/dist/mmtp-container-parser.js +98 -28
  44. package/dist/mmtp-container-parser.js.map +2 -2
  45. package/dist/mmtp-container-parser.no-modules.js +222 -45
  46. package/dist/mmtp-container-parser.no-modules.js.map +3 -3
  47. package/dist/mmtp-decode-pipeline.js +97 -28
  48. package/dist/mmtp-decode-pipeline.js.map +2 -2
  49. package/dist/mmtp-fec-session.d.ts +41 -3
  50. package/dist/mmtp-fec-session.d.ts.map +1 -1
  51. package/dist/mmtp-fec-session.js +118 -13
  52. package/dist/mmtp-fec-session.js.map +3 -3
  53. package/dist/mmtp-router.d.ts +25 -2
  54. package/dist/mmtp-router.d.ts.map +1 -1
  55. package/dist/mmtp-router.js +1749 -56
  56. package/dist/mmtp-router.js.map +4 -4
  57. package/dist/multicast-auth-catalog.js.map +1 -1
  58. package/dist/multicast-input.js.map +1 -1
  59. package/dist/multicast-manager.d.ts +15 -2
  60. package/dist/multicast-manager.d.ts.map +1 -1
  61. package/dist/multicast-manager.js +15683 -14941
  62. package/dist/multicast-manager.js.map +4 -4
  63. package/dist/multicast-transport.d.ts +11 -3
  64. package/dist/multicast-transport.d.ts.map +1 -1
  65. package/dist/multicast-transport.js +281 -35
  66. package/dist/multicast-transport.js.map +4 -4
  67. package/dist/multicast-unwrap.js +12 -19
  68. package/dist/multicast-unwrap.js.map +2 -2
  69. package/dist/no-modules.js +215 -42
  70. package/dist/no-modules.js.map +3 -3
  71. package/dist/repair-fetch.js.map +1 -1
  72. package/dist/repair-layer-subscriptions.d.ts +82 -0
  73. package/dist/repair-layer-subscriptions.d.ts.map +1 -0
  74. package/dist/repair-layer-subscriptions.js +192 -0
  75. package/dist/repair-layer-subscriptions.js.map +7 -0
  76. package/dist/signaling.js.map +2 -2
  77. package/dist/timestamp-codec.js +12 -19
  78. package/dist/timestamp-codec.js.map +2 -2
  79. package/dist/track-session.d.ts +1 -1
  80. package/dist/track-session.js.map +1 -1
  81. package/dist/wasm-mfu-reassembler.js.map +1 -1
  82. package/dist/wasm-mmtp-container-parser.js +97 -28
  83. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  84. package/package.json +8 -5
  85. package/schemas/catalog.schema.json +4 -0
  86. package/src/catalog-fec-params.ts +36 -1
  87. package/src/cmcd-producer.ts +194 -179
  88. package/src/fec-manager.ts +256 -6
  89. package/src/fec-worker-setup.ts +2 -2
  90. package/src/index.ts +18 -2
  91. package/src/iso-block-locator.ts +19 -0
  92. package/src/iso-bmff-timing.ts +40 -1
  93. package/src/mmtp-container-parser.ts +96 -8
  94. package/src/mmtp-fec-session.ts +115 -15
  95. package/src/mmtp-router.ts +414 -29
  96. package/src/multicast-manager.ts +26 -5
  97. package/src/multicast-transport.ts +44 -20
  98. package/src/repair-layer-subscriptions.ts +261 -0
  99. package/src/track-session.ts +1 -1
@@ -149,30 +149,26 @@ function isEmptyOrExactLegacyTrailer(payload, start, end) {
149
149
  const authLength = view2.getUint16(2, false);
150
150
  return start + LEGACY_TRAILER_HEADER_SIZE + authLength === end;
151
151
  }
152
- function resolveDeclaredTrailers(payload, declaredEnd, fecType, options, rapFlag = false) {
152
+ function resolveDeclaredTrailers(payload, declaredEnd, fecType, options) {
153
153
  if (declaredEnd > payload.length)
154
154
  return null;
155
155
  if (options?.skipFecId) {
156
- return isEmptyOrExactLegacyTrailer(payload, declaredEnd, payload.length) ? { hasFecId: false, hasOverlayFecId: false } : null;
156
+ return isEmptyOrExactLegacyTrailer(payload, declaredEnd, payload.length) ? { hasFecId: false } : null;
157
157
  }
158
- const overlay = options?.overlayDeclared === true && rapFlag;
159
158
  if (fecType === 1) {
160
- const fecStart = payload.length - sourceFecTrailerSize(overlay);
159
+ const fecStart = payload.length - SOURCE_FEC_PAYLOAD_ID_SIZE;
161
160
  if (fecStart < declaredEnd || !isEmptyOrExactLegacyTrailer(payload, declaredEnd, fecStart)) {
162
161
  return null;
163
162
  }
164
- return { hasFecId: true, hasOverlayFecId: overlay };
163
+ return { hasFecId: true };
165
164
  }
166
- if (fecType !== 0 || overlay)
165
+ if (fecType !== 0)
167
166
  return null;
168
167
  if (isEmptyOrExactLegacyTrailer(payload, declaredEnd, payload.length)) {
169
- return { hasFecId: false, hasOverlayFecId: false };
168
+ return { hasFecId: false };
170
169
  }
171
170
  return null;
172
171
  }
173
- function sourceFecTrailerSize(overlay) {
174
- return overlay ? 2 * SOURCE_FEC_PAYLOAD_ID_SIZE : SOURCE_FEC_PAYLOAD_ID_SIZE;
175
- }
176
172
  function planMfuExtraction(mmtpPacket, options, parsedHeader) {
177
173
  if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) {
178
174
  return {
@@ -225,11 +221,11 @@ function planMfuExtraction(mmtpPacket, options, parsedHeader) {
225
221
  if ("rejection" in layoutResult)
226
222
  return layoutResult;
227
223
  const { layout } = layoutResult;
228
- const trailerLayout = resolveDeclaredTrailers(payload, layout.dataEnd, header.fecType, options, header.rapFlag);
224
+ const trailerLayout = resolveDeclaredTrailers(payload, layout.dataEnd, header.fecType, options);
229
225
  if (!trailerLayout) {
230
226
  if (!options?.skipFecId && header.fecType === 1) {
231
227
  const observed = payload.length - layout.dataEnd;
232
- const expected = sourceFecTrailerSize(options?.overlayDeclared === true && header.rapFlag);
228
+ const expected = SOURCE_FEC_PAYLOAD_ID_SIZE;
233
229
  if (observed !== expected) {
234
230
  return {
235
231
  rejection: {
@@ -261,13 +257,10 @@ function extractPlannedMfu(mmtpPacket, result) {
261
257
  const mfuDuHeader = mfuDuHeaderPresent && mpu.timedFlag === 1 ? parseTimedMfuDuHeader(payload, MPU_HEADER_SIZE) : null;
262
258
  const itemId = mfuDuHeaderPresent && mpu.timedFlag === 0 ? new DataView(payload.buffer, payload.byteOffset + MPU_HEADER_SIZE, 4).getUint32(0, false) : null;
263
259
  const data = payload.subarray(mfuOffset, dataEnd);
264
- const trailerStart = mmtpPacket.length - sourceFecTrailerSize(trailerLayout.hasOverlayFecId);
265
- const ssIdAt = (offset) => ({
266
- ssId: new DataView(mmtpPacket.buffer, mmtpPacket.byteOffset + offset, 4).getUint32(0, false)
267
- });
268
- const fecPayloadId = trailerLayout.hasFecId ? ssIdAt(trailerStart) : null;
269
- const overlayFecPayloadId = trailerLayout.hasOverlayFecId ? ssIdAt(trailerStart + SOURCE_FEC_PAYLOAD_ID_SIZE) : null;
270
- return { header, mpu, fragmentType, data, mfuDuHeader, itemId, fecPayloadId, overlayFecPayloadId };
260
+ const fecPayloadId = trailerLayout.hasFecId ? {
261
+ ssId: new DataView(mmtpPacket.buffer, mmtpPacket.byteOffset + mmtpPacket.length - SOURCE_FEC_PAYLOAD_ID_SIZE, 4).getUint32(0, false)
262
+ } : null;
263
+ return { header, mpu, fragmentType, data, mfuDuHeader, itemId, fecPayloadId };
271
264
  }
272
265
  function sourceFecPayloadIdOf(mmtpPacket, header) {
273
266
  if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE)
@@ -377,6 +370,1422 @@ var RUNTIME_FEC_RECEIVER = Object.freeze({
377
370
  overlays: true
378
371
  });
379
372
 
373
+ // node_modules/@blockcast/mmt-transport/dist/multicast-tier.js
374
+ function endpointKey(endpoint) {
375
+ return `${endpoint.sourceAddress ?? ""}|${endpoint.groupAddress}|${endpoint.port}`;
376
+ }
377
+ function carriesMedia(endpoint) {
378
+ return endpoint.carriesMedia !== false;
379
+ }
380
+ function selectMulticastTier(endpoints, estimate) {
381
+ const media = endpoints.filter(carriesMedia);
382
+ if (media.length <= 1)
383
+ return media[0];
384
+ const ranked = media.filter((endpoint) => typeof endpoint.bandwidth === "number" && endpoint.bandwidth > 0).sort((a, b) => a.bandwidth - b.bandwidth);
385
+ if (ranked.length < 2)
386
+ return media[0];
387
+ if (estimate === void 0)
388
+ return ranked[0];
389
+ const budget = estimate * 0.8;
390
+ let selected;
391
+ for (const endpoint of ranked) {
392
+ if (endpoint.bandwidth <= budget)
393
+ selected = endpoint;
394
+ }
395
+ return selected ?? ranked[0];
396
+ }
397
+
398
+ // node_modules/@blockcast/mmt-transport/dist/abr-controller.js
399
+ var QUALITY_LEVELS = Object.freeze([
400
+ { name: "240p", bitrate: 3e5, width: 426, height: 240, fps: 15 },
401
+ { name: "360p", bitrate: 5e5, width: 640, height: 360, fps: 24 },
402
+ { name: "480p", bitrate: 1e6, width: 854, height: 480, fps: 30 },
403
+ { name: "720p", bitrate: 25e5, width: 1280, height: 720, fps: 30 },
404
+ { name: "1080p", bitrate: 5e6, width: 1920, height: 1080, fps: 30 },
405
+ { name: "1440p", bitrate: 8e6, width: 2560, height: 1440, fps: 30 },
406
+ { name: "4K", bitrate: 15e6, width: 3840, height: 2160, fps: 30 }
407
+ ].map((level) => Object.freeze(level)));
408
+ function positiveFinite(value) {
409
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
410
+ }
411
+ function validateQualityLadder(ladder) {
412
+ if (!Array.isArray(ladder) || ladder.length === 0) {
413
+ throw new TypeError("[ABR] ladder must list at least one rendition (e.g. the catalog's video tracks)");
414
+ }
415
+ const names = /* @__PURE__ */ new Set();
416
+ for (const level of ladder) {
417
+ if (typeof level?.name !== "string" || level.name.length === 0) {
418
+ throw new TypeError("[ABR] every ladder rendition needs a name");
419
+ }
420
+ if (!positiveFinite(level.bitrate)) {
421
+ throw new RangeError(`[ABR] rendition ${level.name}: bitrate must be a positive finite number, got ${String(level.bitrate)}`);
422
+ }
423
+ for (const field of ["width", "height", "fps"]) {
424
+ if (level[field] !== void 0 && !positiveFinite(level[field])) {
425
+ throw new RangeError(`[ABR] rendition ${level.name}: ${field} must be a positive finite number when stated, got ${String(level[field])}`);
426
+ }
427
+ }
428
+ if (names.has(level.name))
429
+ throw new RangeError(`[ABR] rendition ${level.name} appears twice in the ladder`);
430
+ names.add(level.name);
431
+ }
432
+ return Object.freeze([...ladder].sort((a, b) => a.bitrate - b.bitrate).map((level) => Object.freeze({ ...level })));
433
+ }
434
+ var AdaptiveBitrateController = class {
435
+ /** The caller's renditions, ascending by bitrate (`validateQualityLadder`). */
436
+ ladder;
437
+ currentQualityIndex;
438
+ history = [];
439
+ historyWindow = 1e4;
440
+ // 10 second window
441
+ upgradeThreshold = 0.8;
442
+ // 80% of target bitrate sustained
443
+ downgradeThreshold = 0.5;
444
+ // 50% of target bitrate triggers downgrade
445
+ stabilityPeriod = 5e3;
446
+ // Wait 5s before upgrading
447
+ lastQualityChange = 0;
448
+ onQualityChange;
449
+ // FEC stats for effective loss rate calculation
450
+ fecStats = {
451
+ rawLossRate: 0,
452
+ effectiveLossRate: 0,
453
+ recoveryRate: 1,
454
+ fecEnabled: false
455
+ };
456
+ constructor(options) {
457
+ if (!options || typeof options !== "object") {
458
+ throw new TypeError("[ABR] AdaptiveBitrateController needs { ladder, initialQuality }");
459
+ }
460
+ this.ladder = validateQualityLadder(options.ladder);
461
+ this.currentQualityIndex = options.initialQuality === void 0 ? 0 : this.ladder.findIndex((q) => q.name === options.initialQuality);
462
+ if (this.currentQualityIndex < 0) {
463
+ throw new RangeError(`[ABR] initial rendition ${String(options.initialQuality)} is not in the ladder`);
464
+ }
465
+ }
466
+ setCallback(callback) {
467
+ this.onQualityChange = callback;
468
+ }
469
+ getCurrentQuality() {
470
+ return this.ladder[this.currentQualityIndex];
471
+ }
472
+ /** Selects the multicast (S,G) tier without replacing a player's unicast ABR manager. */
473
+ selectMulticastTier(endpoints, estimate = this.getMetrics().measuredBitrate || void 0) {
474
+ return selectMulticastTier(endpoints, estimate);
475
+ }
476
+ /**
477
+ * Record network sample for bitrate adaptation
478
+ *
479
+ * @param bytesReceived - Bytes received in this sample
480
+ * @param packetsLost - Packets lost (raw, before FEC recovery)
481
+ */
482
+ recordSample(bytesReceived, packetsLost) {
483
+ const now = Date.now();
484
+ this.history.push({ timestamp: now, bytesReceived, packetsLost });
485
+ this.history = this.history.filter((s) => now - s.timestamp < this.historyWindow);
486
+ const { measuredBitrate, lossRate: rawLossRate } = this.calculateMetrics();
487
+ const currentQuality = this.getCurrentQuality();
488
+ const effectiveLossRate = this.fecStats.fecEnabled ? this.fecStats.effectiveLossRate : rawLossRate;
489
+ if (now - this.lastQualityChange < this.stabilityPeriod) {
490
+ return;
491
+ }
492
+ if (effectiveLossRate > 0.05 || measuredBitrate < currentQuality.bitrate * this.downgradeThreshold) {
493
+ this.downgrade();
494
+ return;
495
+ }
496
+ if (measuredBitrate > currentQuality.bitrate * this.upgradeThreshold && effectiveLossRate < 0.01 && this.currentQualityIndex < this.ladder.length - 1) {
497
+ this.upgrade();
498
+ }
499
+ }
500
+ /**
501
+ * Update FEC statistics for ABR decisions
502
+ *
503
+ * ABR uses the effective loss rate (after FEC recovery) rather than the
504
+ * raw transport loss rate.
505
+ *
506
+ * @param stats - FEC statistics from the FEC decoder
507
+ */
508
+ setFecStats(stats) {
509
+ this.fecStats = stats;
510
+ }
511
+ /**
512
+ * Get current FEC statistics
513
+ */
514
+ getFecStats() {
515
+ return { ...this.fecStats };
516
+ }
517
+ calculateMetrics() {
518
+ if (this.history.length < 2) {
519
+ return { measuredBitrate: 0, lossRate: 0 };
520
+ }
521
+ const totalBytes = this.history.reduce((sum, s) => sum + s.bytesReceived, 0);
522
+ const totalLost = this.history.reduce((sum, s) => sum + s.packetsLost, 0);
523
+ const duration = (this.history[this.history.length - 1].timestamp - this.history[0].timestamp) / 1e3;
524
+ const measuredBitrate = duration > 0 ? totalBytes * 8 / duration : 0;
525
+ const lossRate = totalLost / (this.history.length + totalLost);
526
+ return { measuredBitrate, lossRate };
527
+ }
528
+ upgrade() {
529
+ if (this.currentQualityIndex >= this.ladder.length - 1)
530
+ return;
531
+ this.currentQualityIndex++;
532
+ this.lastQualityChange = Date.now();
533
+ const newQuality = this.getCurrentQuality();
534
+ console.log(`[ABR] Upgrading to ${newQuality.name} (${newQuality.bitrate / 1e6}Mbps)`);
535
+ this.onQualityChange?.(newQuality);
536
+ }
537
+ downgrade() {
538
+ if (this.currentQualityIndex <= 0)
539
+ return;
540
+ this.currentQualityIndex--;
541
+ this.lastQualityChange = Date.now();
542
+ const newQuality = this.getCurrentQuality();
543
+ console.log(`[ABR] Downgrading to ${newQuality.name} (${newQuality.bitrate / 1e6}Mbps)`);
544
+ this.onQualityChange?.(newQuality);
545
+ }
546
+ /**
547
+ * Select a ladder rendition: the caller switched to it (a manual override,
548
+ * or a switch the caller made). Throws for a name the ladder does not carry.
549
+ */
550
+ setQuality(name) {
551
+ const index = this.ladder.findIndex((q) => q.name === name);
552
+ if (index < 0)
553
+ throw new RangeError(`[ABR] rendition ${name} is not in the ladder`);
554
+ this.currentQualityIndex = index;
555
+ this.lastQualityChange = Date.now();
556
+ this.onQualityChange?.(this.getCurrentQuality());
557
+ }
558
+ /**
559
+ * Get current metrics including both raw and effective loss rates
560
+ */
561
+ getMetrics() {
562
+ const { measuredBitrate, lossRate: rawLossRate } = this.calculateMetrics();
563
+ return {
564
+ measuredBitrate,
565
+ rawLossRate,
566
+ effectiveLossRate: this.fecStats.fecEnabled ? this.fecStats.effectiveLossRate : rawLossRate,
567
+ fecEnabled: this.fecStats.fecEnabled,
568
+ sampleCount: this.history.length
569
+ };
570
+ }
571
+ /**
572
+ * Reset history and start fresh
573
+ */
574
+ reset() {
575
+ this.history = [];
576
+ this.lastQualityChange = 0;
577
+ }
578
+ };
579
+
580
+ // node_modules/@blockcast/mmt-base/dist/mmtp.js
581
+ var MPU_HEADER_SIZE2 = 8;
582
+ var MPU_LENGTH_FIELD_SIZE2 = 2;
583
+ var MPU_HEADER_REMAINDER_SIZE2 = MPU_HEADER_SIZE2 - MPU_LENGTH_FIELD_SIZE2;
584
+ var MMTP_SIGNALING_PACKET_ID2 = 0;
585
+ var MMTP_PACKET_ID_MAX = 65535;
586
+
587
+ // node_modules/@blockcast/mmt-base/dist/net-constants.js
588
+ var DEFAULT_AMT_RELAY_PORT = 2268;
589
+
590
+ // node_modules/@blockcast/mmt-base/dist/ntp-short.js
591
+ var NTP_SHORT_BITS2 = 32;
592
+ var NTP_SHORT_MODULUS2 = 2 ** NTP_SHORT_BITS2;
593
+ var NTP_SHORT_HALF_RANGE2 = 2 ** (NTP_SHORT_BITS2 - 1);
594
+ function isNtpShort(value) {
595
+ return Number.isInteger(value) && value >= 0 && value < NTP_SHORT_MODULUS2;
596
+ }
597
+ function compareNtpShort(a, b) {
598
+ if (!isNtpShort(a) || !isNtpShort(b)) {
599
+ throw new RangeError(`NTP-short values must be integers in [0, 2^${NTP_SHORT_BITS2}), got ${String(a)} and ${String(b)}`);
600
+ }
601
+ if (a === b)
602
+ return "equal";
603
+ const forward = (a - b + NTP_SHORT_MODULUS2) % NTP_SHORT_MODULUS2;
604
+ if (forward < NTP_SHORT_HALF_RANGE2)
605
+ return "after";
606
+ if (forward > NTP_SHORT_HALF_RANGE2)
607
+ return "before";
608
+ return "unordered";
609
+ }
610
+
611
+ // node_modules/@blockcast/mmt-base/dist/fec-block-profile.js
612
+ var FEC_BLOCK_PROFILES2 = Object.freeze([
613
+ "iso-ssbg1-v1"
614
+ ]);
615
+
616
+ // node_modules/@blockcast/mmt-base/dist/fec-bindings.js
617
+ var RUNTIME_FEC_RECEIVER2 = Object.freeze({
618
+ schemes: Object.freeze(["raptorq", "reed-solomon"]),
619
+ blockProfiles: Object.freeze(["iso-ssbg1-v1"]),
620
+ overlays: true
621
+ });
622
+
623
+ // node_modules/@blockcast/mmt-transport/dist/multicast-orchestrator.js
624
+ var DEFAULT_DWELL_MS = 3e3;
625
+ var DEFAULT_JOIN_DEADLINE_MS = 5e3;
626
+ var MulticastOrchestrator = class {
627
+ options;
628
+ #state = "stable";
629
+ #current;
630
+ #target;
631
+ #estimate;
632
+ #favored;
633
+ #joinStartedAt = 0;
634
+ /**
635
+ * Latest active-tier send time (NTP short), or undefined before the first.
636
+ * "Latest" is serial order, not the numerically largest value: after the
637
+ * field wraps, the largest value is the stale one.
638
+ */
639
+ #highWater;
640
+ #dwellMs;
641
+ #joinDeadlineMs;
642
+ constructor(options) {
643
+ this.options = options;
644
+ this.#dwellMs = options.dwellMs ?? DEFAULT_DWELL_MS;
645
+ this.#joinDeadlineMs = options.joinDeadlineMs ?? DEFAULT_JOIN_DEADLINE_MS;
646
+ if (!Number.isFinite(this.#dwellMs) || !Number.isFinite(this.#joinDeadlineMs) || this.#dwellMs <= 0 || this.#joinDeadlineMs <= 0) {
647
+ throw new Error("Multicast dwell and join deadline must be positive");
648
+ }
649
+ }
650
+ get state() {
651
+ return this.#state;
652
+ }
653
+ get current() {
654
+ return this.#current;
655
+ }
656
+ get target() {
657
+ return this.#target;
658
+ }
659
+ get estimate() {
660
+ return this.#estimate;
661
+ }
662
+ start() {
663
+ this.#current ??= this.options.initialEndpoint ?? selectMulticastTier(this.options.endpoints, void 0);
664
+ return this.#current;
665
+ }
666
+ onGoodput(sample) {
667
+ const bitrate = sample.bitrate;
668
+ if (!this.#current)
669
+ return void 0;
670
+ if (this.#state === "joining")
671
+ return this.poll();
672
+ if (this.options.adaptive === false)
673
+ return void 0;
674
+ if (bitrate === void 0 || !Number.isFinite(bitrate))
675
+ return void 0;
676
+ this.#estimate = bitrate;
677
+ const desired = selectMulticastTier(this.options.endpoints, bitrate);
678
+ if (!desired)
679
+ return void 0;
680
+ const desiredKey = endpointKey(desired);
681
+ if (desiredKey === endpointKey(this.#current)) {
682
+ if (this.#favored?.source === "goodput")
683
+ this.#favored = void 0;
684
+ return void 0;
685
+ }
686
+ if (typeof desired.bandwidth !== "number" || typeof this.#current.bandwidth !== "number" || desired.bandwidth >= this.#current.bandwidth) {
687
+ if (this.#favored?.source === "goodput")
688
+ this.#favored = void 0;
689
+ return void 0;
690
+ }
691
+ const now = this.#now();
692
+ if (!this.#favored || this.#favored.key !== desiredKey || this.#favored.source !== "goodput") {
693
+ this.#favored = { key: desiredKey, since: now, source: "goodput" };
694
+ return void 0;
695
+ }
696
+ if (now - this.#favored.since < this.#dwellMs)
697
+ return void 0;
698
+ this.#state = "joining";
699
+ this.#target = desired;
700
+ this.#joinStartedAt = now;
701
+ this.#favored = void 0;
702
+ return { kind: "join-target", current: this.#current, target: desired };
703
+ }
704
+ onHeadroom(sample) {
705
+ if (!this.#current || this.options.adaptive === false || this.#state === "joining")
706
+ return void 0;
707
+ if (!sample.healthy) {
708
+ if (this.#favored?.source === "headroom")
709
+ this.#favored = void 0;
710
+ return void 0;
711
+ }
712
+ if (this.#favored?.source === "goodput")
713
+ return void 0;
714
+ const currentBandwidth = this.#current.bandwidth;
715
+ if (typeof currentBandwidth !== "number")
716
+ return void 0;
717
+ const desired = this.options.endpoints.filter((endpoint) => carriesMedia(endpoint) && typeof endpoint.bandwidth === "number" && endpoint.bandwidth > currentBandwidth).sort((a, b) => a.bandwidth - b.bandwidth)[0];
718
+ if (!desired) {
719
+ this.#favored = void 0;
720
+ return void 0;
721
+ }
722
+ const desiredKey = endpointKey(desired);
723
+ if (desiredKey === endpointKey(this.#current)) {
724
+ this.#favored = void 0;
725
+ return void 0;
726
+ }
727
+ const now = this.#now();
728
+ if (!this.#favored || this.#favored.key !== desiredKey) {
729
+ this.#favored = { key: desiredKey, since: now, source: "headroom" };
730
+ return void 0;
731
+ }
732
+ if (now - this.#favored.since < this.#dwellMs)
733
+ return void 0;
734
+ this.#state = "joining";
735
+ this.#target = desired;
736
+ this.#joinStartedAt = now;
737
+ this.#favored = void 0;
738
+ return { kind: "join-target", current: this.#current, target: desired };
739
+ }
740
+ /**
741
+ * Join `target` because the viewer selected it, not because goodput or
742
+ * headroom ranked it. It runs whether or not the orchestrator adapts, and
743
+ * takes the path an adaptive join takes: commit on the target's RAP at or
744
+ * beyond the high-water mark, abandon at the join deadline. While it is
745
+ * pending nothing re-ranks the ladder; goodput only advances the deadline.
746
+ *
747
+ * Returns undefined, changing nothing, while another join is pending (cancel
748
+ * it first), when `target` is the current tier, or when it carries no media.
749
+ */
750
+ select(target) {
751
+ if (!this.#current || this.#state === "joining" || !carriesMedia(target))
752
+ return void 0;
753
+ if (endpointKey(target) === endpointKey(this.#current))
754
+ return void 0;
755
+ this.#state = "joining";
756
+ this.#target = target;
757
+ this.#joinStartedAt = this.#now();
758
+ this.#favored = void 0;
759
+ return { kind: "join-target", current: this.#current, target };
760
+ }
761
+ /**
762
+ * Feed a packet from the active tier (to advance the high-water mark) or
763
+ * from the in-flight target (to find a committable RAP).
764
+ *
765
+ * Serial order is only the true order while the compared instants are less
766
+ * than `NTP_SHORT_HALF_RANGE` (2^15 s, ~9.1 h) apart. Feeding every
767
+ * active-tier packet keeps the mark that close to the live stream. A mark
768
+ * refreshed only by commits can fall further behind than that, and a
769
+ * target RAP may then compare as older, so the join is abandoned.
770
+ */
771
+ onTierPacket(packet) {
772
+ if (!this.#current)
773
+ return void 0;
774
+ const sentAt = tierPacketSendTime(packet);
775
+ if (packet.endpointKey === endpointKey(this.#current) && sentAt !== void 0 && (this.#highWater === void 0 || compareNtpShort(sentAt, this.#highWater) === "after")) {
776
+ this.#highWater = sentAt;
777
+ }
778
+ if (this.#state !== "joining" || !this.#target)
779
+ return void 0;
780
+ const expired = this.poll();
781
+ if (expired)
782
+ return expired;
783
+ if (packet.endpointKey !== endpointKey(this.#target) || !packet.keyframe || !packet.hasValidCodecConfig || sentAt === void 0 || !this.#reachesHighWater(sentAt))
784
+ return void 0;
785
+ const command = {
786
+ kind: "commit-swap",
787
+ target: this.#target,
788
+ old: this.#current
789
+ };
790
+ this.#highWater = sentAt;
791
+ this.#current = this.#target;
792
+ this.#target = void 0;
793
+ this.#state = "stable";
794
+ return command;
795
+ }
796
+ poll() {
797
+ if (this.#state !== "joining" || !this.#target || !this.#current)
798
+ return void 0;
799
+ if (this.#now() - this.#joinStartedAt < this.#joinDeadlineMs)
800
+ return void 0;
801
+ return this.cancel();
802
+ }
803
+ cancel() {
804
+ if (this.#state !== "joining" || !this.#target || !this.#current)
805
+ return void 0;
806
+ const command = {
807
+ kind: "abandon-join",
808
+ current: this.#current,
809
+ target: this.#target
810
+ };
811
+ this.#target = void 0;
812
+ this.#state = "stable";
813
+ return command;
814
+ }
815
+ /**
816
+ * Whether a target RAP sent at `sentAt` is at or beyond the active tier's
817
+ * high-water mark. A pair exactly half the NTP-short space apart has no
818
+ * serial order, so it cannot show that and does not commit.
819
+ */
820
+ #reachesHighWater(sentAt) {
821
+ if (this.#highWater === void 0)
822
+ return true;
823
+ const order = compareNtpShort(sentAt, this.#highWater);
824
+ return order === "after" || order === "equal";
825
+ }
826
+ #now() {
827
+ const now = this.options.now();
828
+ if (!Number.isFinite(now))
829
+ throw new Error("Multicast orchestrator clock must be finite");
830
+ return now;
831
+ }
832
+ };
833
+ function tierPacketSendTime(packet) {
834
+ if (!Number.isFinite(packet.timestamp))
835
+ return void 0;
836
+ if (!isNtpShort(packet.timestamp)) {
837
+ throw new RangeError(`Multicast tier packet timestamp must be an MMTP NTP-short send time, got ${packet.timestamp}`);
838
+ }
839
+ return packet.timestamp;
840
+ }
841
+
842
+ // node_modules/@blockcast/mmt-transport/dist/endpoint-config.js
843
+ function toWindowMulticastSubscribeConfig(input) {
844
+ if (input.mode !== void 0 && !["auto", "native", "tunnel"].includes(input.mode)) {
845
+ throw new TypeError(`Unsupported multicast mode: ${String(input.mode)}`);
846
+ }
847
+ if (input.packetIds !== void 0 && !Array.isArray(input.packetIds)) {
848
+ throw new TypeError(`multicast packetIds must be an array, got ${input.packetIds === null ? "null" : typeof input.packetIds}`);
849
+ }
850
+ if (input.packetIds?.some((id) => !isMulticastTrackPacketId(id)) || input.tracks?.some((track) => !isMulticastTrackPacketId(track.packetId))) {
851
+ throw new RangeError(`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`);
852
+ }
853
+ const source = input.source?.trim() || void 0;
854
+ const senderAddress = input.senderAddress?.trim() || void 0;
855
+ if (source && senderAddress) {
856
+ throw new TypeError("multicast senderAddress is an ASM filter and never appears beside source");
857
+ }
858
+ const base = {
859
+ group: input.group,
860
+ port: input.port,
861
+ ...source ? { source } : {},
862
+ consume: "bytes",
863
+ ...input.packetIds?.length ? { packetIds: [...input.packetIds] } : {}
864
+ };
865
+ const filter = senderAddress ? { senderAddress } : {};
866
+ if (input.atsc3) {
867
+ if (senderAddress)
868
+ throw new TypeError("an ATSC3 tuner request carries no senderAddress");
869
+ const { frequency, plpId, serviceId } = input.atsc3;
870
+ if (!Number.isInteger(frequency) || frequency < 1) {
871
+ throw new TypeError(`ATSC3 tuning requires a positive integer frequency in kHz, got ${String(frequency)}`);
872
+ }
873
+ return {
874
+ ...base,
875
+ origin: "tuner",
876
+ standard: "atsc3",
877
+ frequency,
878
+ ...plpId !== void 0 ? { plpId } : {},
879
+ ...serviceId !== void 0 ? { serviceId } : {}
880
+ };
881
+ }
882
+ const relay = input.amtRelay?.trim();
883
+ const driad = !relay && input.amtDiscovery === "driad";
884
+ const wantsAmt = input.mode === "tunnel" || input.mode !== "native" && (relay !== void 0 && relay !== "" || driad);
885
+ if (!wantsAmt) {
886
+ const native = {
887
+ ...base,
888
+ ...filter,
889
+ origin: "ip-multicast",
890
+ ...input.tracks?.length ? { tracks: input.tracks.map(({ name, packetId }) => ({ name, packetId })) } : {}
891
+ };
892
+ return native;
893
+ }
894
+ const relayPort = input.amtRelayPort;
895
+ if (driad) {
896
+ if (!base.source)
897
+ throw new TypeError("AMT DRIAD discovery requires a source address");
898
+ if (relayPort !== void 0 && !isPositivePort(relayPort)) {
899
+ throw new TypeError("AMT DRIAD discovery requires a valid relay port when one is given");
900
+ }
901
+ return {
902
+ ...base,
903
+ source: base.source,
904
+ origin: "amt",
905
+ discovery: "driad",
906
+ ...relayPort !== void 0 ? { relayPort } : {}
907
+ };
908
+ }
909
+ if (!relay || relayPort === void 0 || !isPositivePort(relayPort)) {
910
+ throw new TypeError("AMT requires a resolved relay and a valid relay port");
911
+ }
912
+ return {
913
+ ...base,
914
+ ...filter,
915
+ origin: "amt",
916
+ relay,
917
+ relayPort
918
+ };
919
+ }
920
+ function isValidMulticastGroupAddress(address) {
921
+ const value = address?.trim().toLowerCase();
922
+ if (!value)
923
+ return false;
924
+ if (value.startsWith("ff"))
925
+ return isValidIpv6MulticastAddress(value);
926
+ const parts = value.split(".");
927
+ if (parts.length !== 4)
928
+ return false;
929
+ const octets = parts.map((part) => {
930
+ if (!/^\d+$/.test(part))
931
+ return Number.NaN;
932
+ const octet = Number(part);
933
+ return Number.isInteger(octet) && octet >= 0 && octet <= 255 ? octet : Number.NaN;
934
+ });
935
+ return octets.every(Number.isInteger) && octets[0] >= 224 && octets[0] <= 239;
936
+ }
937
+ function normalizeMulticastEndpoint(endpoint) {
938
+ const groupAddress = endpoint.groupAddress.trim().toLowerCase();
939
+ const sourceAddress = endpoint.sourceAddress?.trim().toLowerCase() || void 0;
940
+ const senderAddress = endpoint.senderAddress?.trim().toLowerCase() || void 0;
941
+ if (sourceAddress && senderAddress)
942
+ return void 0;
943
+ const amtRelay = endpoint.amtRelay?.trim().toLowerCase() || void 0;
944
+ if (!isValidMulticastGroupAddress(groupAddress) || !isPositivePort(endpoint.port))
945
+ return void 0;
946
+ if (endpoint.mode && !["auto", "native", "tunnel"].includes(endpoint.mode))
947
+ return void 0;
948
+ if (endpoint.amtRelayPort !== void 0 && !isPositivePort(endpoint.amtRelayPort))
949
+ return void 0;
950
+ return {
951
+ groupAddress,
952
+ ...sourceAddress ? { sourceAddress } : {},
953
+ ...senderAddress ? { senderAddress } : {},
954
+ port: endpoint.port,
955
+ ...endpoint.mode ? { mode: endpoint.mode } : {},
956
+ ...amtRelay ? { amtRelay } : {},
957
+ ...endpoint.amtRelayPort ? { amtRelayPort: endpoint.amtRelayPort } : {},
958
+ ...endpoint.amtDiscovery === "driad" ? { amtDiscovery: "driad" } : {}
959
+ };
960
+ }
961
+ function multicastEndpointKey(endpoint) {
962
+ const normalized = normalizeMulticastEndpoint(endpoint);
963
+ if (!normalized)
964
+ throw new Error("Invalid multicast endpoint");
965
+ return JSON.stringify([
966
+ normalized.sourceAddress ?? "",
967
+ normalized.groupAddress,
968
+ normalized.port,
969
+ normalized.mode ?? "auto",
970
+ normalized.amtRelay ?? "",
971
+ normalized.amtRelayPort ?? "",
972
+ // A DRIAD join is a different gateway subscription from a native one.
973
+ ...normalized.amtDiscovery ? [normalized.amtDiscovery] : [],
974
+ // An ASM sender filter is a different subscription from the unfiltered join.
975
+ ...normalized.senderAddress ? [{ senderAddress: normalized.senderAddress }] : []
976
+ ]);
977
+ }
978
+ function isMulticastTrackPacketId(value) {
979
+ return Number.isInteger(value) && value > MMTP_SIGNALING_PACKET_ID2 && value <= MMTP_PACKET_ID_MAX;
980
+ }
981
+ function isValidIpv6MulticastAddress(value) {
982
+ if (!/^[0-9a-f:]+$/.test(value))
983
+ return false;
984
+ const compressed = value.includes("::");
985
+ if (value.indexOf("::") !== value.lastIndexOf("::"))
986
+ return false;
987
+ const groups = value.split(":").filter(Boolean);
988
+ if (groups.length === 0 || groups.length > 8)
989
+ return false;
990
+ if (!compressed && groups.length !== 8)
991
+ return false;
992
+ return groups.every((group) => /^[0-9a-f]{1,4}$/.test(group));
993
+ }
994
+ function isPositivePort(port) {
995
+ return Number.isInteger(port) && port > 0 && port <= 65535;
996
+ }
997
+
998
+ // node_modules/@blockcast/mmt-transport/dist/packet-batch.js
999
+ function subscriptionDeliversPacketBatches(subscription) {
1000
+ return subscription.eventTypes?.includes("packets") === true;
1001
+ }
1002
+
1003
+ // node_modules/@blockcast/mmt-transport/dist/multicast-subscription-pool.js
1004
+ var MulticastSubscriptionPool = class {
1005
+ #entries = /* @__PURE__ */ new Map();
1006
+ #gatewayIds = /* @__PURE__ */ new WeakMap();
1007
+ #nextGatewayId = 1;
1008
+ async acquire(gateway, config, consumer) {
1009
+ const endpoint = normalizeMulticastEndpoint({
1010
+ groupAddress: config.group,
1011
+ sourceAddress: config.source,
1012
+ port: config.port,
1013
+ mode: config.mode,
1014
+ amtRelay: config.amtRelay,
1015
+ amtRelayPort: config.amtRelayPort,
1016
+ amtDiscovery: config.amtDiscovery
1017
+ });
1018
+ if (!endpoint)
1019
+ throw new Error("Invalid multicast endpoint");
1020
+ if (typeof gateway.subscribe !== "function")
1021
+ throw new Error("window.multicast.subscribe unavailable");
1022
+ const key = multicastEndpointKey(endpoint);
1023
+ const internalKey = `${this.#gatewayId(gateway)}|${key}`;
1024
+ let record = this.#entries.get(internalKey);
1025
+ if (!record) {
1026
+ record = { claims: 0, entry: Promise.resolve(null) };
1027
+ const createdRecord = record;
1028
+ record.entry = this.#createEntry(gateway, endpoint, (entry2) => {
1029
+ if (this.#entries.get(internalKey) === createdRecord)
1030
+ this.#entries.delete(internalKey);
1031
+ this.#detach(entry2);
1032
+ }).catch((error) => {
1033
+ if (this.#entries.get(internalKey) === createdRecord)
1034
+ this.#entries.delete(internalKey);
1035
+ throw error;
1036
+ });
1037
+ this.#entries.set(internalKey, record);
1038
+ }
1039
+ record.claims++;
1040
+ let entry;
1041
+ try {
1042
+ entry = await record.entry;
1043
+ } catch (error) {
1044
+ record.claims--;
1045
+ throw error;
1046
+ }
1047
+ if (entry.closed) {
1048
+ record.claims--;
1049
+ return this.acquire(gateway, config, consumer);
1050
+ }
1051
+ const token = Symbol(key);
1052
+ entry.consumers.set(token, consumer);
1053
+ let released = false;
1054
+ return {
1055
+ key,
1056
+ release: async () => {
1057
+ if (released)
1058
+ return;
1059
+ released = true;
1060
+ entry.consumers.delete(token);
1061
+ record.claims--;
1062
+ if (record.claims > 0)
1063
+ return;
1064
+ if (this.#entries.get(internalKey) === record)
1065
+ this.#entries.delete(internalKey);
1066
+ this.#detach(entry);
1067
+ if (!entry.closed)
1068
+ await entry.subscription.unsubscribe();
1069
+ }
1070
+ };
1071
+ }
1072
+ get size() {
1073
+ return this.#entries.size;
1074
+ }
1075
+ async #createEntry(gateway, endpoint, onClosed) {
1076
+ const subscription = await gateway.subscribe(toWindowMulticastSubscribeConfig({
1077
+ group: endpoint.groupAddress,
1078
+ ...endpoint.sourceAddress ? { source: endpoint.sourceAddress } : {},
1079
+ port: endpoint.port,
1080
+ ...endpoint.mode ? { mode: endpoint.mode } : {},
1081
+ ...endpoint.amtRelay ? { amtRelay: endpoint.amtRelay } : {},
1082
+ ...endpoint.amtRelayPort ? { amtRelayPort: endpoint.amtRelayPort } : {},
1083
+ ...endpoint.amtDiscovery ? { amtDiscovery: endpoint.amtDiscovery } : {}
1084
+ }));
1085
+ const consumers = /* @__PURE__ */ new Map();
1086
+ let entry;
1087
+ const packetListener = subscriptionDeliversPacketBatches(subscription) ? {
1088
+ type: "packets",
1089
+ listener: (event) => {
1090
+ for (const data of event.detail) {
1091
+ this.#dispatch(consumers, (consumer) => consumer.onBytes(data));
1092
+ }
1093
+ }
1094
+ } : {
1095
+ type: "bytes",
1096
+ listener: (event) => {
1097
+ this.#dispatch(consumers, (consumer) => consumer.onBytes(event.detail));
1098
+ }
1099
+ };
1100
+ const stateListener = (event) => {
1101
+ this.#dispatch(consumers, (consumer) => consumer.onState?.(event.detail));
1102
+ if (event.detail === "closed" && !entry.closed) {
1103
+ entry.closed = true;
1104
+ onClosed(entry);
1105
+ }
1106
+ };
1107
+ const errorListener = (event) => {
1108
+ const error = event.detail instanceof Error ? event.detail : new Error(String(event.detail));
1109
+ this.#dispatch(consumers, (consumer) => consumer.onError?.(error));
1110
+ };
1111
+ entry = { subscription, consumers, packetListener, stateListener, errorListener, closed: false };
1112
+ if (packetListener.type === "packets") {
1113
+ subscription.addEventListener("packets", packetListener.listener);
1114
+ } else {
1115
+ subscription.addEventListener("bytes", packetListener.listener);
1116
+ }
1117
+ subscription.addEventListener("state", stateListener);
1118
+ subscription.addEventListener("error", errorListener);
1119
+ return entry;
1120
+ }
1121
+ #gatewayId(gateway) {
1122
+ const object = gateway;
1123
+ let id = this.#gatewayIds.get(object);
1124
+ if (!id) {
1125
+ id = this.#nextGatewayId++;
1126
+ this.#gatewayIds.set(object, id);
1127
+ }
1128
+ return id;
1129
+ }
1130
+ #detach(entry) {
1131
+ const { packetListener } = entry;
1132
+ if (packetListener.type === "packets") {
1133
+ entry.subscription.removeEventListener("packets", packetListener.listener);
1134
+ } else {
1135
+ entry.subscription.removeEventListener("bytes", packetListener.listener);
1136
+ }
1137
+ entry.subscription.removeEventListener("state", entry.stateListener);
1138
+ entry.subscription.removeEventListener("error", entry.errorListener);
1139
+ }
1140
+ #dispatch(consumers, callback) {
1141
+ for (const consumer of consumers.values()) {
1142
+ try {
1143
+ callback(consumer);
1144
+ } catch (error) {
1145
+ console.error("[Transport] multicast consumer callback failed", error);
1146
+ }
1147
+ }
1148
+ }
1149
+ };
1150
+ var multicastSubscriptionPool = new MulticastSubscriptionPool();
1151
+
1152
+ // node_modules/@blockcast/mmt-transport/dist/transport-manager.js
1153
+ var TransportManager = class {
1154
+ state = {
1155
+ mode: "disconnected",
1156
+ connected: false,
1157
+ packetsReceived: 0,
1158
+ bytesReceived: 0,
1159
+ lastPacketTime: 0
1160
+ };
1161
+ capabilities = null;
1162
+ subscription = null;
1163
+ /** Built by `connect()` from the caller's ladder; there is no built-in one. */
1164
+ abr = null;
1165
+ multicastOrchestrator = null;
1166
+ onOrchestratorCommand;
1167
+ connectionGeneration = 0;
1168
+ onPacket;
1169
+ onError;
1170
+ onStateChange;
1171
+ getWindow() {
1172
+ return typeof window === "undefined" ? void 0 : window;
1173
+ }
1174
+ getWindowMulticastGateway() {
1175
+ return this.getWindow()?.multicast;
1176
+ }
1177
+ async getGatewayCapabilities(gateway) {
1178
+ if (!gateway?.capabilities)
1179
+ return void 0;
1180
+ try {
1181
+ return typeof gateway.capabilities === "function" ? await gateway.capabilities() : gateway.capabilities;
1182
+ } catch (error) {
1183
+ console.warn("[Transport] window.multicast capabilities failed:", error);
1184
+ return void 0;
1185
+ }
1186
+ }
1187
+ async gatewaySupportsBytes(gateway) {
1188
+ if (typeof gateway?.subscribe !== "function")
1189
+ return false;
1190
+ if (!gateway.capabilities)
1191
+ return true;
1192
+ const capabilities = await this.getGatewayCapabilities(gateway);
1193
+ if (!capabilities)
1194
+ return false;
1195
+ const consumeModes = capabilities.consumeModes;
1196
+ return !consumeModes || consumeModes.includes("bytes");
1197
+ }
1198
+ /**
1199
+ * Detect available transport capabilities
1200
+ */
1201
+ async detectCapabilities() {
1202
+ if (this.capabilities)
1203
+ return this.capabilities;
1204
+ const gateway = this.getWindowMulticastGateway();
1205
+ const hasGatewayBytes = await this.gatewaySupportsBytes(gateway);
1206
+ const win = this.getWindow();
1207
+ this.capabilities = {
1208
+ nativeSSM: hasGatewayBytes,
1209
+ nativeIGMP: hasGatewayBytes,
1210
+ directSockets: typeof win?.UDPSocket !== "undefined",
1211
+ amt: false,
1212
+ driad: hasGatewayBytes,
1213
+ // the gateway runs RFC 8777 discovery
1214
+ fecDecode: false
1215
+ };
1216
+ console.log("[Transport] Capabilities:", this.capabilities);
1217
+ return this.capabilities;
1218
+ }
1219
+ /**
1220
+ * Connect using best available transport
1221
+ *
1222
+ * Promotion hierarchy:
1223
+ * 1. window.multicast gateway bytes
1224
+ * 2. AMT: a configured relay via the AMT proxy, else gateway DRIAD discovery
1225
+ * 3. MOQ WebTransport (final fallback)
1226
+ *
1227
+ * The ABR ladder (`abrLadder`) and initial rendition (`initialQuality`)
1228
+ * are checked first, before anything is torn down. A config that fails is
1229
+ * refused with the same shape as an invalid endpoint, the error to the
1230
+ * error callback and `false` resolved; unlike one, it is caught before the
1231
+ * live subscription closes, so a refused reconnect leaves that subscription,
1232
+ * its state and any connect already in flight untouched.
1233
+ */
1234
+ async connect(config) {
1235
+ let abr;
1236
+ try {
1237
+ abr = new AdaptiveBitrateController({
1238
+ ladder: config.abrLadder,
1239
+ initialQuality: config.initialQuality?.name
1240
+ });
1241
+ } catch (error) {
1242
+ const refused = error instanceof Error ? error : new Error(String(error));
1243
+ if (this.onError)
1244
+ this.onError(refused);
1245
+ else
1246
+ console.error("[Transport] ABR config refused, not connecting:", refused);
1247
+ return false;
1248
+ }
1249
+ const generation = ++this.connectionGeneration;
1250
+ this.subscription?.close();
1251
+ this.subscription = null;
1252
+ this.abr = null;
1253
+ this.state = {
1254
+ mode: "disconnected",
1255
+ connected: false,
1256
+ packetsReceived: 0,
1257
+ bytesReceived: 0,
1258
+ lastPacketTime: 0
1259
+ };
1260
+ this.onStateChange?.(this.state);
1261
+ const endpoint = normalizeMulticastEndpoint({
1262
+ groupAddress: config.group,
1263
+ sourceAddress: config.source,
1264
+ port: config.port,
1265
+ mode: config.mode,
1266
+ amtRelay: config.amtRelay,
1267
+ amtRelayPort: config.amtRelayPort
1268
+ });
1269
+ if (!endpoint) {
1270
+ const error = new Error("Invalid multicast endpoint configuration");
1271
+ this.onError?.(error);
1272
+ return false;
1273
+ }
1274
+ config = {
1275
+ ...config,
1276
+ group: endpoint.groupAddress,
1277
+ source: endpoint.sourceAddress ?? "",
1278
+ mode: endpoint.mode,
1279
+ amtRelay: endpoint.amtRelay,
1280
+ amtRelayPort: endpoint.amtRelayPort
1281
+ };
1282
+ const caps = await this.detectCapabilities();
1283
+ if (generation !== this.connectionGeneration)
1284
+ return false;
1285
+ this.abr = abr;
1286
+ abr.setCallback((quality) => {
1287
+ this.state.quality = quality;
1288
+ this.subscription?.onqualitychange?.(quality);
1289
+ this.onStateChange?.(this.state);
1290
+ });
1291
+ let connected = false;
1292
+ if (caps.nativeSSM || caps.nativeIGMP) {
1293
+ console.log("[Transport] Attempting window.multicast...");
1294
+ connected = await this.connectWindowMulticast(config, generation);
1295
+ if (connected) {
1296
+ if (generation !== this.connectionGeneration)
1297
+ return false;
1298
+ this.state.mode = "multicast";
1299
+ return true;
1300
+ }
1301
+ }
1302
+ if (!connected) {
1303
+ console.log("[Transport] Attempting AMT tunnel...");
1304
+ connected = await this.connectAmt(config, generation);
1305
+ if (connected) {
1306
+ if (generation !== this.connectionGeneration)
1307
+ return false;
1308
+ this.state.mode = "amt";
1309
+ return true;
1310
+ }
1311
+ }
1312
+ console.log("[Transport] All multicast transports failed, falling back to MOQ");
1313
+ return false;
1314
+ }
1315
+ /**
1316
+ * Connect via the lowercase window.multicast bytes gateway.
1317
+ */
1318
+ async connectWindowMulticast(config, generation, mode = "multicast") {
1319
+ try {
1320
+ const gateway = this.getWindowMulticastGateway();
1321
+ if (typeof gateway?.subscribe !== "function")
1322
+ return false;
1323
+ const lease = await multicastSubscriptionPool.acquire(gateway, config, {
1324
+ onBytes: (data) => {
1325
+ if (generation === this.connectionGeneration)
1326
+ this.handlePacket(data);
1327
+ },
1328
+ onState: (state) => {
1329
+ if (generation !== this.connectionGeneration)
1330
+ return;
1331
+ this.state.connected = state !== "closed";
1332
+ this.onStateChange?.(this.state);
1333
+ },
1334
+ onError: (error) => {
1335
+ if (generation !== this.connectionGeneration)
1336
+ return;
1337
+ console.error("[Transport] window.multicast error:", error);
1338
+ this.onError?.(error);
1339
+ }
1340
+ });
1341
+ if (generation !== this.connectionGeneration) {
1342
+ await lease.release();
1343
+ return false;
1344
+ }
1345
+ this.subscription = {
1346
+ id: lease.key,
1347
+ config,
1348
+ close: () => {
1349
+ void lease.release();
1350
+ }
1351
+ };
1352
+ this.state = {
1353
+ mode,
1354
+ connected: true,
1355
+ group: config.group,
1356
+ source: config.source,
1357
+ quality: this.requireAbr().getCurrentQuality(),
1358
+ packetsReceived: 0,
1359
+ bytesReceived: 0,
1360
+ lastPacketTime: 0
1361
+ };
1362
+ console.log(`[Transport] Connected via window.multicast: (${config.source}, ${config.group})`);
1363
+ this.onStateChange?.(this.state);
1364
+ return true;
1365
+ } catch (error) {
1366
+ console.warn("[Transport] window.multicast failed:", error);
1367
+ return false;
1368
+ }
1369
+ }
1370
+ /**
1371
+ * Connect via AMT: a configured relay goes through the AMT proxy; with no
1372
+ * relay, the window.multicast gateway discovers one per RFC 8777 (DRIAD).
1373
+ */
1374
+ async connectAmt(config, generation) {
1375
+ try {
1376
+ if (config.amtRelay)
1377
+ return await this.connectAmtProxy(config, config.amtRelay, generation);
1378
+ if (config.driadEnabled === false || !config.source || config.mode === "native") {
1379
+ console.warn("[Transport] No AMT relay available (DRIAD requires source address for SSM)");
1380
+ return false;
1381
+ }
1382
+ console.log(`[Transport] Asking the gateway for a DRIAD relay for source ${config.source}...`);
1383
+ return await this.connectWindowMulticast({ ...config, amtDiscovery: "driad" }, generation, "amt");
1384
+ } catch (error) {
1385
+ console.warn("[Transport] AMT connection failed:", error);
1386
+ return false;
1387
+ }
1388
+ }
1389
+ /**
1390
+ * Connect via WebSocket-based AMT proxy (browser fallback)
1391
+ */
1392
+ async connectAmtProxy(config, amtRelay, generation) {
1393
+ try {
1394
+ const relayHost = amtRelay.includes(":") && !amtRelay.startsWith("[") ? `[${amtRelay}]` : amtRelay;
1395
+ const wsUrl = new URL(`wss://${relayHost}/amt-proxy`);
1396
+ wsUrl.searchParams.set("group", config.group);
1397
+ wsUrl.searchParams.set("source", config.source || "");
1398
+ wsUrl.searchParams.set("port", String(config.port));
1399
+ const ws = new WebSocket(wsUrl);
1400
+ return new Promise((resolve, reject) => {
1401
+ ws.binaryType = "arraybuffer";
1402
+ const timeout = setTimeout(() => {
1403
+ ws.close();
1404
+ reject(new Error("AMT proxy connection timeout"));
1405
+ }, 1e4);
1406
+ ws.onopen = () => {
1407
+ clearTimeout(timeout);
1408
+ if (generation !== this.connectionGeneration) {
1409
+ ws.close();
1410
+ resolve(false);
1411
+ return;
1412
+ }
1413
+ console.log(`[Transport] Connected to AMT proxy: ${amtRelay}`);
1414
+ this.subscription = {
1415
+ id: `amt-proxy-${Date.now()}`,
1416
+ config,
1417
+ close: () => ws.close()
1418
+ };
1419
+ this.state = {
1420
+ mode: "amt",
1421
+ connected: true,
1422
+ group: config.group,
1423
+ source: config.source,
1424
+ amtRelay,
1425
+ quality: this.requireAbr().getCurrentQuality(),
1426
+ packetsReceived: 0,
1427
+ bytesReceived: 0,
1428
+ lastPacketTime: 0
1429
+ };
1430
+ this.onStateChange?.(this.state);
1431
+ resolve(true);
1432
+ };
1433
+ ws.onmessage = (event) => {
1434
+ if (generation === this.connectionGeneration && event.data instanceof ArrayBuffer) {
1435
+ this.handlePacket(event.data);
1436
+ }
1437
+ };
1438
+ ws.onerror = (error) => {
1439
+ clearTimeout(timeout);
1440
+ if (generation !== this.connectionGeneration) {
1441
+ resolve(false);
1442
+ return;
1443
+ }
1444
+ console.error("[Transport] AMT proxy error:", error);
1445
+ reject(error);
1446
+ };
1447
+ ws.onclose = () => {
1448
+ clearTimeout(timeout);
1449
+ if (generation !== this.connectionGeneration) {
1450
+ resolve(false);
1451
+ return;
1452
+ }
1453
+ this.state.connected = false;
1454
+ this.onStateChange?.(this.state);
1455
+ resolve(false);
1456
+ };
1457
+ });
1458
+ } catch (error) {
1459
+ console.warn("[Transport] AMT proxy failed:", error);
1460
+ return false;
1461
+ }
1462
+ }
1463
+ /**
1464
+ * Handle received packet
1465
+ */
1466
+ handlePacket(data) {
1467
+ this.state.packetsReceived++;
1468
+ this.state.bytesReceived += data.byteLength;
1469
+ this.state.lastPacketTime = Date.now();
1470
+ this.abr?.recordSample(data.byteLength, 0);
1471
+ this.onPacket?.(data);
1472
+ }
1473
+ /**
1474
+ * Get current transport state
1475
+ */
1476
+ getState() {
1477
+ return { ...this.state };
1478
+ }
1479
+ /**
1480
+ * The rung of the live connection's `abrLadder` ABR is on; `null` when
1481
+ * there is no ladder (before `connect(config)` succeeds, after
1482
+ * `disconnect()`, or after a reconnect that was torn down and bailed).
1483
+ */
1484
+ getQuality() {
1485
+ return this.abr?.getCurrentQuality() ?? null;
1486
+ }
1487
+ /**
1488
+ * Force the rung named `name` of the live connection's `abrLadder`.
1489
+ * Throws `Error` when there is no ladder (see `getQuality()`), and
1490
+ * `RangeError` when the ladder has no rung of that name; it never falls
1491
+ * back to another rung.
1492
+ */
1493
+ setQuality(name) {
1494
+ this.requireAbr().setQuality(name);
1495
+ }
1496
+ /** The controller `connect()` built; before it there is no ladder to adapt over. */
1497
+ requireAbr() {
1498
+ if (!this.abr)
1499
+ throw new Error("[Transport] ABR has no ladder before connect(config)");
1500
+ return this.abr;
1501
+ }
1502
+ /** Configures multicast-only ABR while leaving the caller's MoQ ABR untouched. */
1503
+ configureMulticastTiers(options, onCommand) {
1504
+ this.dispatchOrchestratorCommand(this.multicastOrchestrator?.cancel());
1505
+ this.multicastOrchestrator = new MulticastOrchestrator(options);
1506
+ this.onOrchestratorCommand = onCommand;
1507
+ return this.multicastOrchestrator.start();
1508
+ }
1509
+ recordMulticastGoodput(bitrate) {
1510
+ return this.dispatchOrchestratorCommand(this.multicastOrchestrator?.onGoodput({ bitrate }));
1511
+ }
1512
+ recordMulticastHeadroom(healthy) {
1513
+ return this.dispatchOrchestratorCommand(this.multicastOrchestrator?.onHeadroom({ healthy }));
1514
+ }
1515
+ recordMulticastTierPacket(packet) {
1516
+ return this.dispatchOrchestratorCommand(this.multicastOrchestrator?.onTierPacket(packet));
1517
+ }
1518
+ pollMulticastTierSwitch() {
1519
+ return this.dispatchOrchestratorCommand(this.multicastOrchestrator?.poll());
1520
+ }
1521
+ /** Abandon the in-flight tier join, e.g. because the host could not join the target. */
1522
+ cancelMulticastTierJoin() {
1523
+ return this.dispatchOrchestratorCommand(this.multicastOrchestrator?.cancel());
1524
+ }
1525
+ /**
1526
+ * Set packet callback
1527
+ */
1528
+ setPacketCallback(callback) {
1529
+ this.onPacket = callback;
1530
+ }
1531
+ /**
1532
+ * Set error callback
1533
+ */
1534
+ setErrorCallback(callback) {
1535
+ this.onError = callback;
1536
+ }
1537
+ /**
1538
+ * Set state change callback
1539
+ */
1540
+ setStateCallback(callback) {
1541
+ this.onStateChange = callback;
1542
+ }
1543
+ /**
1544
+ * Disconnect
1545
+ */
1546
+ disconnect() {
1547
+ this.connectionGeneration++;
1548
+ this.subscription?.close();
1549
+ this.subscription = null;
1550
+ this.abr = null;
1551
+ this.dispatchOrchestratorCommand(this.multicastOrchestrator?.cancel());
1552
+ this.multicastOrchestrator = null;
1553
+ this.onOrchestratorCommand = void 0;
1554
+ this.state = {
1555
+ mode: "disconnected",
1556
+ connected: false,
1557
+ packetsReceived: 0,
1558
+ bytesReceived: 0,
1559
+ lastPacketTime: 0
1560
+ };
1561
+ this.onStateChange?.(this.state);
1562
+ }
1563
+ dispatchOrchestratorCommand(command) {
1564
+ if (command)
1565
+ this.onOrchestratorCommand?.(command);
1566
+ return command;
1567
+ }
1568
+ };
1569
+ var transportManager = new TransportManager();
1570
+
1571
+ // node_modules/@blockcast/mmt-transport/dist/multicast-receiver-gateway.js
1572
+ var USER_AGENT_VERIFIED = /* @__PURE__ */ Symbol.for("@blockcast/mmt-transport/user-agent-verified/v1");
1573
+
1574
+ // node_modules/@blockcast/mmt-transport/dist/moqt-priority.js
1575
+ var MAX_MOQT_PRIORITY = 255;
1576
+ function moqtPriority(value, field) {
1577
+ if (!Number.isInteger(value) || value < 0 || value > MAX_MOQT_PRIORITY) {
1578
+ throw new RangeError(`${field} must be an integer in [0, ${MAX_MOQT_PRIORITY}], got ${value}`);
1579
+ }
1580
+ return value;
1581
+ }
1582
+ function moqtSubscriberPriority(value) {
1583
+ return moqtPriority(value, "MOQT subscriber priority");
1584
+ }
1585
+ function moqtPublisherPriority(value) {
1586
+ return moqtPriority(value, "MOQT publisher priority");
1587
+ }
1588
+ var MOQT_SUBSCRIBER_PRIORITY = Object.freeze({
1589
+ catalog: moqtSubscriberPriority(0),
1590
+ /**
1591
+ * @deprecated A repair FETCH's SUBSCRIBER_PRIORITY is derived per request
1592
+ * from the catalog source and repair priorities (draft-ramadan-moq-fec
1593
+ * §11.4.5), not a player constant. Use the `subscriberPriority` that
1594
+ * `@blockcast/mmt-container`'s `wireRepairFetch` puts on each
1595
+ * `RepairFetchRequest` (`repairFetchSubscriberPriority`).
1596
+ */
1597
+ deadlineRepair: moqtSubscriberPriority(0),
1598
+ audio: moqtSubscriberPriority(1),
1599
+ video: moqtSubscriberPriority(2),
1600
+ /**
1601
+ * @deprecated See `deadlineRepair`: use the request's catalog-derived
1602
+ * `RepairFetchRequest.subscriberPriority` (draft-ramadan-moq-fec §11.4.5).
1603
+ */
1604
+ backgroundRepair: moqtSubscriberPriority(3)
1605
+ });
1606
+ var MOQT_PUBLISHER_PRIORITY = Object.freeze({
1607
+ catalog: moqtPublisherPriority(32),
1608
+ source: moqtPublisherPriority(128),
1609
+ repair: moqtPublisherPriority(240)
1610
+ });
1611
+
1612
+ // node_modules/@blockcast/mmt-transport/dist/amt-gateway.js
1613
+ var MAX_PENDING_PACKETS = 256;
1614
+ var AMTGatewayManager = class {
1615
+ subscriptions = /* @__PURE__ */ new Map();
1616
+ messageHandler;
1617
+ messageListenerRegistered = false;
1618
+ pendingPackets = /* @__PURE__ */ new Map();
1619
+ constructor() {
1620
+ this.messageHandler = (event) => {
1621
+ const data = event.data;
1622
+ if (data.type === "UDP_PACKET") {
1623
+ this.handlePacket(data);
1624
+ } else if (data.type === "SOCKET_ERROR") {
1625
+ this.handleSocketError(data);
1626
+ } else if (data.type === "DEBUG_LOG") {
1627
+ console.log(`[AMT SW Debug] ${data.message}`);
1628
+ }
1629
+ };
1630
+ }
1631
+ ensureMessageListener() {
1632
+ if (this.messageListenerRegistered || typeof navigator === "undefined" || !navigator.serviceWorker || !this.messageHandler) {
1633
+ return;
1634
+ }
1635
+ navigator.serviceWorker.addEventListener("message", this.messageHandler);
1636
+ this.messageListenerRegistered = true;
1637
+ console.log("[AMT] Registered service worker message handler");
1638
+ }
1639
+ removeMessageListener() {
1640
+ if (!this.messageListenerRegistered)
1641
+ return;
1642
+ if (this.messageHandler && typeof navigator !== "undefined" && navigator.serviceWorker) {
1643
+ navigator.serviceWorker.removeEventListener("message", this.messageHandler);
1644
+ }
1645
+ this.messageListenerRegistered = false;
1646
+ }
1647
+ /**
1648
+ * Subscribe via AMT tunnel
1649
+ */
1650
+ async subscribe(config) {
1651
+ const { key } = config;
1652
+ if (config.protected) {
1653
+ throw new Error("AMTGatewayManager refuses a protected join: it reports no per-datagram sender (multicast-02 7.2.7)");
1654
+ }
1655
+ this.ensureMessageListener();
1656
+ const keyStr = this.getKeyString(key);
1657
+ if (this.subscriptions.has(keyStr)) {
1658
+ console.log(`[AMT] Reusing existing subscription to ${keyStr}`);
1659
+ return keyStr;
1660
+ }
1661
+ try {
1662
+ const relayAddress = config.relayAddress;
1663
+ const relayPort = config.relayPort || DEFAULT_AMT_RELAY_PORT;
1664
+ if (!relayAddress) {
1665
+ throw new Error(`AMT relay address required: this manager does no relay discovery. For RFC 8777 DRIAD discovery, join through window.multicast with amtDiscovery: "driad".`);
1666
+ }
1667
+ console.log(`[AMT] Using configured relay ${relayAddress}:${relayPort}`);
1668
+ const subscriptionId = keyStr;
1669
+ this.pendingPackets.set(subscriptionId, []);
1670
+ const subscription = {
1671
+ key,
1672
+ subscriptionId,
1673
+ config,
1674
+ handshakeComplete: false,
1675
+ relay: {
1676
+ address: relayAddress,
1677
+ port: relayPort,
1678
+ discoveryMethod: "manual",
1679
+ discoveredAt: Date.now()
1680
+ }
1681
+ };
1682
+ this.subscriptions.set(keyStr, subscription);
1683
+ console.log(`[AMT] Subscription created for ${keyStr} via ${relayAddress}:${relayPort}`);
1684
+ return keyStr;
1685
+ } catch (error) {
1686
+ const subscription = this.subscriptions.get(keyStr);
1687
+ if (subscription) {
1688
+ this.subscriptions.delete(keyStr);
1689
+ this.pendingPackets.delete(subscription.subscriptionId);
1690
+ }
1691
+ if (this.subscriptions.size === 0)
1692
+ this.removeMessageListener();
1693
+ throw new Error(`Failed to subscribe via AMT: ${error instanceof Error ? error.message : String(error)}`);
1694
+ }
1695
+ }
1696
+ /**
1697
+ * Unsubscribe from AMT tunnel
1698
+ */
1699
+ async unsubscribe(handle) {
1700
+ const subscription = this.subscriptions.get(handle);
1701
+ if (!subscription) {
1702
+ throw new Error(`Subscription ${handle} not found`);
1703
+ }
1704
+ try {
1705
+ if (subscription.keepAliveTimer) {
1706
+ clearInterval(subscription.keepAliveTimer);
1707
+ }
1708
+ console.log(`[AMT] Unsubscribed from ${handle}`);
1709
+ } catch (error) {
1710
+ console.error("[AMT] Error during unsubscribe:", error);
1711
+ } finally {
1712
+ this.subscriptions.delete(handle);
1713
+ this.pendingPackets.delete(subscription.subscriptionId);
1714
+ if (this.subscriptions.size === 0)
1715
+ this.removeMessageListener();
1716
+ }
1717
+ }
1718
+ /**
1719
+ * Get list of active subscriptions
1720
+ */
1721
+ getSubscriptions() {
1722
+ return Array.from(this.subscriptions.keys());
1723
+ }
1724
+ /**
1725
+ * Handle packet from service worker
1726
+ */
1727
+ handlePacket(message) {
1728
+ const { subscriptionId, data } = message;
1729
+ if (!subscriptionId)
1730
+ return;
1731
+ const subscription = this.subscriptions.get(subscriptionId);
1732
+ if (!subscription)
1733
+ return;
1734
+ if (!subscription.handshakeComplete) {
1735
+ const queue = this.pendingPackets.get(subscriptionId);
1736
+ const packetData2 = data;
1737
+ if (queue && queue.length < MAX_PENDING_PACKETS) {
1738
+ queue.push({
1739
+ data: packetData2.data,
1740
+ remoteAddress: packetData2.remoteAddress,
1741
+ remotePort: packetData2.remotePort
1742
+ });
1743
+ }
1744
+ return;
1745
+ }
1746
+ const packetData = data;
1747
+ const metadata = {
1748
+ sourceAddress: subscription.relay?.address || "unknown",
1749
+ sourcePort: subscription.relay?.port || 0,
1750
+ transport: "tunnel",
1751
+ timestamp: performance.now()
1752
+ };
1753
+ subscription.config.onPacket(packetData.data, metadata);
1754
+ }
1755
+ /**
1756
+ * Handle socket error from service worker
1757
+ */
1758
+ handleSocketError(message) {
1759
+ const { subscriptionId, error } = message;
1760
+ if (!subscriptionId)
1761
+ return;
1762
+ const subscription = this.subscriptions.get(subscriptionId);
1763
+ if (!subscription)
1764
+ return;
1765
+ subscription.config.onError(new Error(error || "Unknown error"));
1766
+ }
1767
+ /**
1768
+ * Cleanup on destroy
1769
+ */
1770
+ destroy() {
1771
+ this.removeMessageListener();
1772
+ const handles = Array.from(this.subscriptions.keys());
1773
+ for (const handle of handles) {
1774
+ this.unsubscribe(handle).catch((err) => {
1775
+ console.error(`[AMT] Error unsubscribing ${handle}:`, err);
1776
+ });
1777
+ }
1778
+ }
1779
+ /**
1780
+ * Get key string for HashMap
1781
+ */
1782
+ getKeyString(key) {
1783
+ const source = key.source ? `@${key.source}` : "";
1784
+ return `${key.group}:${key.port}${source}`;
1785
+ }
1786
+ };
1787
+ var amtGatewayManager = new AMTGatewayManager();
1788
+
380
1789
  // src/mfu-reassembler.ts
381
1790
  function fragmentProvenance(fragment) {
382
1791
  if (fragment.deliveryProvenance !== void 0) return fragment.deliveryProvenance;
@@ -6268,9 +7677,21 @@ var IsoBmffTimingResolver = class {
6268
7677
  }
6269
7678
  #track = null;
6270
7679
  #fragments = /* @__PURE__ */ new Map();
7680
+ /** First sample of each pending fragment, kept in `#delivered` with its sample count. */
7681
+ #firstSamples = /* @__PURE__ */ new Map();
7682
+ /**
7683
+ * First sample and sample count of the newest fragments whose every slot was
7684
+ * cleared. Abandonment counts: `releaseSample` clears a slot too, which
7685
+ * matches the combiner, since it retires an abandoned sample as well.
7686
+ */
7687
+ #delivered = /* @__PURE__ */ new Map();
6271
7688
  #pendingSamples = 0;
6272
7689
  #evictedSamples = 0;
6273
7690
  #lastMovieFragmentRejection = null;
7691
+ /** True once an FT=0 has bound the track that FT=1 timing is read against. */
7692
+ get initialized() {
7693
+ return this.#track !== null;
7694
+ }
6274
7695
  get pendingSampleCount() {
6275
7696
  return this.#pendingSamples;
6276
7697
  }
@@ -6294,7 +7715,14 @@ var IsoBmffTimingResolver = class {
6294
7715
  this.#track = timing;
6295
7716
  return true;
6296
7717
  }
6297
- addMovieFragment(mpuSequenceNumber, data, additionalSampleLimit = this.maxPendingSamples) {
7718
+ /**
7719
+ * `recovered` marks an FEC-recovered copy. A recovered block hands back
7720
+ * every FT=1 it holds, the received ones included, so a recovered copy of a
7721
+ * fragment whose samples are all done is a duplicate: admitting it again
7722
+ * would charge stale timing and could displace the live MPU's. A source
7723
+ * copy is still admitted again (a rendition switch re-arms timing that way).
7724
+ */
7725
+ addMovieFragment(mpuSequenceNumber, data, additionalSampleLimit = this.maxPendingSamples, recovered = false) {
6298
7726
  this.#lastMovieFragmentRejection = null;
6299
7727
  if (!Number.isInteger(additionalSampleLimit) || additionalSampleLimit < 0) {
6300
7728
  this.#lastMovieFragmentRejection = "invalid";
@@ -6320,6 +7748,8 @@ var IsoBmffTimingResolver = class {
6320
7748
  (sample, index) => sample !== null && sameSample(sample, parsed.samples[index])
6321
7749
  );
6322
7750
  }
7751
+ const delivered = recovered ? this.#delivered.get(key) : void 0;
7752
+ if (delivered && delivered.sampleCount === parsed.samples.length && sameSample(delivered.first, parsed.samples[0])) return true;
6323
7753
  let freed = 0;
6324
7754
  while (this.#fragments.size > 0 && (this.#pendingSamples + parsed.samples.length > this.maxPendingSamples || parsed.samples.length - freed > additionalSampleLimit)) {
6325
7755
  freed += this.#evictOldestFragment();
@@ -6333,6 +7763,7 @@ var IsoBmffTimingResolver = class {
6333
7763
  return false;
6334
7764
  }
6335
7765
  this.#fragments.set(key, [...parsed.samples]);
7766
+ this.#firstSamples.set(key, parsed.samples[0]);
6336
7767
  this.#pendingSamples += parsed.samples.length;
6337
7768
  return true;
6338
7769
  }
@@ -6390,6 +7821,7 @@ var IsoBmffTimingResolver = class {
6390
7821
  let live = 0;
6391
7822
  for (const sample of samples) if (sample !== null) live++;
6392
7823
  this.#fragments.delete(oldest);
7824
+ this.#firstSamples.delete(oldest);
6393
7825
  this.#pendingSamples -= live;
6394
7826
  this.#evictedSamples += live;
6395
7827
  return live;
@@ -6405,12 +7837,23 @@ var IsoBmffTimingResolver = class {
6405
7837
  if (!samples || !samples[index]) return false;
6406
7838
  samples[index] = null;
6407
7839
  this.#pendingSamples--;
6408
- if (samples.every((value) => value === null)) this.#fragments.delete(key);
7840
+ if (samples.every((value) => value === null)) {
7841
+ this.#fragments.delete(key);
7842
+ const first = this.#firstSamples.get(key);
7843
+ this.#firstSamples.delete(key);
7844
+ this.#delivered.delete(key);
7845
+ this.#delivered.set(key, { first, sampleCount: samples.length });
7846
+ if (this.#delivered.size > this.maxPendingSamples) {
7847
+ this.#delivered.delete(this.#delivered.keys().next().value);
7848
+ }
7849
+ }
6409
7850
  return true;
6410
7851
  }
6411
7852
  reset() {
6412
7853
  this.#track = null;
6413
7854
  this.#fragments.clear();
7855
+ this.#firstSamples.clear();
7856
+ this.#delivered.clear();
6414
7857
  this.#pendingSamples = 0;
6415
7858
  }
6416
7859
  };
@@ -6892,6 +8335,12 @@ var MmtpContainerParser = class _MmtpContainerParser {
6892
8335
  this.#reclaimedTimingSamples = 0;
6893
8336
  this.#evictedTimingSamples = 0;
6894
8337
  this.#rejectedMovieFragments = 0;
8338
+ this.#recoveredHeadsAwaitingTiming = {
8339
+ held: 0,
8340
+ expired: 0,
8341
+ discarded: 0,
8342
+ superseded: 0
8343
+ };
6895
8344
  this.#sequencers = /* @__PURE__ */ new Map();
6896
8345
  this.#fecGate = null;
6897
8346
  this.#fecSequencer = null;
@@ -7000,6 +8449,7 @@ var MmtpContainerParser = class _MmtpContainerParser {
7000
8449
  #reclaimedTimingSamples;
7001
8450
  #evictedTimingSamples;
7002
8451
  #rejectedMovieFragments;
8452
+ #recoveredHeadsAwaitingTiming;
7003
8453
  #sequencers;
7004
8454
  #fecGate;
7005
8455
  #fecSequencer;
@@ -7120,6 +8570,14 @@ var MmtpContainerParser = class _MmtpContainerParser {
7120
8570
  rejectedMovieFragments: this.#rejectedMovieFragments
7121
8571
  };
7122
8572
  }
8573
+ /** Recovered heads parked for FT=1 timing (BLO-53369). Survives dispose(). */
8574
+ get recoveredHeadsAwaitingTiming() {
8575
+ let pending = 0;
8576
+ for (const { awaitingTiming } of this.#timingStreams.values()) {
8577
+ for (const head of awaitingTiming) if (head.options?.recovered) pending++;
8578
+ }
8579
+ return { ...this.#recoveredHeadsAwaitingTiming, pending };
8580
+ }
7123
8581
  get reorderStats() {
7124
8582
  let bufferedFrames = this.#fecSequencer?.buffered ?? 0;
7125
8583
  for (const { sequencer } of this.#sequencers.values()) bufferedFrames += sequencer.buffered;
@@ -7293,7 +8751,7 @@ var MmtpContainerParser = class _MmtpContainerParser {
7293
8751
  const now = Date.now();
7294
8752
  for (const { awaitingTiming } of this.#timingStreams.values()) {
7295
8753
  while (awaitingTiming.length > 0 && now - awaitingTiming[0].arrivedAt > timeoutMs) {
7296
- awaitingTiming.shift();
8754
+ this.#leaveAwaitingTiming(awaitingTiming.shift(), "expired");
7297
8755
  }
7298
8756
  }
7299
8757
  }
@@ -7411,11 +8869,16 @@ var MmtpContainerParser = class _MmtpContainerParser {
7411
8869
  if (mpu.fragmentationIndicator !== 0 || mpu.fragmentCounter !== 0)
7412
8870
  return this.#drop("fragment-unexpected-fragmentation");
7413
8871
  const resolver2 = this.#timingResolver(streamKey, info.packetId, false);
7414
- if (!resolver2) return this.#drop("fragment-no-resolver");
8872
+ if (!resolver2?.initialized) return this.#drop("fragment-no-resolver");
7415
8873
  const pendingBefore = resolver2.pendingSampleCount;
7416
8874
  const evictedBefore = resolver2.evictedSampleCount;
7417
8875
  const remaining = this.#maxPendingTimingSamples - this.#pendingTimingSamples;
7418
- const admitted = resolver2.addMovieFragment(mpu.mpuSequenceNumber, data, remaining);
8876
+ const admitted = resolver2.addMovieFragment(
8877
+ mpu.mpuSequenceNumber,
8878
+ data,
8879
+ remaining,
8880
+ options?.recovered === true
8881
+ );
7419
8882
  this.#pendingTimingSamples += resolver2.pendingSampleCount - pendingBefore;
7420
8883
  this.#evictedTimingSamples += resolver2.evictedSampleCount - evictedBefore;
7421
8884
  if (!admitted) {
@@ -7435,7 +8898,7 @@ var MmtpContainerParser = class _MmtpContainerParser {
7435
8898
  };
7436
8899
  }
7437
8900
  if (fragmentType !== 2) return this.#drop("unknown-fragment-type");
7438
- const resolver = this.#timingResolver(streamKey, info.packetId, false);
8901
+ const resolver = this.#timingResolver(streamKey, info.packetId, true);
7439
8902
  if (!resolver) return this.#drop("mfu-no-resolver");
7440
8903
  if (!Number.isSafeInteger(data.byteLength) || data.byteLength <= 0)
7441
8904
  return this.#drop("mfu-empty-payload");
@@ -7456,6 +8919,7 @@ var MmtpContainerParser = class _MmtpContainerParser {
7456
8919
  mpuSequenceNumber: mpu.mpuSequenceNumber,
7457
8920
  movieFragmentSequenceNumber: mfuDuHeader.movieFragmentSequenceNumber,
7458
8921
  sampleNumber: mfuDuHeader.sampleNumber,
8922
+ sequenceNumber: info.sequenceNumber,
7459
8923
  arrivedAt: Date.now()
7460
8924
  });
7461
8925
  return this.#drop("mfu-sample-timing-missing");
@@ -7779,6 +9243,10 @@ var MmtpContainerParser = class _MmtpContainerParser {
7779
9243
  }
7780
9244
  this.#emitFrame(data, meta);
7781
9245
  }
9246
+ /** Whether FT=1 metadata already names this sample, without claiming it. */
9247
+ hasAuthoredTimingFor(packetId, mpuSequenceNumber, movieFragmentSequenceNumber, sampleNumber, catalogTrackName) {
9248
+ return this.#timingResolver(this.#streamKey(packetId, catalogTrackName), packetId, false)?.sampleTiming(mpuSequenceNumber, movieFragmentSequenceNumber, sampleNumber) != null;
9249
+ }
7782
9250
  /**
7783
9251
  * Claim and return the authored timing for a recovered sample. Recovery does
7784
9252
  * not pass through the ordinary `feed()` completion path, so it must retire
@@ -7840,6 +9308,9 @@ var MmtpContainerParser = class _MmtpContainerParser {
7840
9308
  for (const { sequencer } of this.#sequencers.values()) sequencer.discard();
7841
9309
  this.#sequencers.clear();
7842
9310
  this.#fecSequencer?.discard();
9311
+ for (const { awaitingTiming } of this.#timingStreams.values()) {
9312
+ for (const head of awaitingTiming) this.#leaveAwaitingTiming(head, "discarded");
9313
+ }
7843
9314
  this.#timingStreams.clear();
7844
9315
  this.#pendingTimingSamples = 0;
7845
9316
  this.#reliableMoqStartupGate.clear();
@@ -7858,10 +9329,23 @@ var MmtpContainerParser = class _MmtpContainerParser {
7858
9329
  return resolver;
7859
9330
  }
7860
9331
  #awaitTiming(streamKey, head) {
7861
- const awaitingTiming = this.#timingStreams.get(streamKey)?.awaitingTiming;
7862
- if (!awaitingTiming) return;
9332
+ const state = this.#timingStreams.get(streamKey);
9333
+ if (!state) return;
9334
+ if (!head.options?.recovered) {
9335
+ const kept = state.awaitingTiming.filter((queued) => !queued.options?.recovered || queued.sequenceNumber !== head.sequenceNumber);
9336
+ this.#recoveredHeadsAwaitingTiming.superseded += state.awaitingTiming.length - kept.length;
9337
+ state.awaitingTiming = kept;
9338
+ }
9339
+ const awaitingTiming = state.awaitingTiming;
7863
9340
  awaitingTiming.push(head);
7864
- if (awaitingTiming.length > MAX_AWAITING_TIMING_HEADS) awaitingTiming.shift();
9341
+ if (head.options?.recovered) this.#recoveredHeadsAwaitingTiming.held++;
9342
+ if (awaitingTiming.length > MAX_AWAITING_TIMING_HEADS) {
9343
+ this.#leaveAwaitingTiming(awaitingTiming.shift(), "expired");
9344
+ }
9345
+ }
9346
+ /** Count a recovered head leaving the queue other than by replay or its source copy. */
9347
+ #leaveAwaitingTiming(head, outcome) {
9348
+ if (head.options?.recovered) this.#recoveredHeadsAwaitingTiming[outcome]++;
7865
9349
  }
7866
9350
  /**
7867
9351
  * Feed again the heads whose timing the FT=1 just admitted declares. That
@@ -7889,6 +9373,7 @@ var MmtpContainerParser = class _MmtpContainerParser {
7889
9373
  const state = this.#timingStreams.get(streamKey);
7890
9374
  if (!state) return;
7891
9375
  this.#timingStreams.delete(streamKey);
9376
+ for (const head of state.awaitingTiming) this.#leaveAwaitingTiming(head, "discarded");
7892
9377
  this.#pendingTimingSamples -= state.resolver.pendingSampleCount;
7893
9378
  if (this.#pendingTimingSamples < 0) {
7894
9379
  throw new Error("[MmtpContainerParser] pending timing sample accounting underflow");
@@ -8183,7 +9668,7 @@ function decideGap(input) {
8183
9668
 
8184
9669
  // src/duplicate-ledger.ts
8185
9670
  var COUNT_BOUNDED_GENERATION = 2e3;
8186
- function serial32Precedes2(a, b) {
9671
+ function serial32Precedes3(a, b) {
8187
9672
  const distance = b - a >>> 0;
8188
9673
  return distance !== 0 && distance < 2147483648;
8189
9674
  }
@@ -8359,12 +9844,12 @@ var StreamDuplicateLedgers = class {
8359
9844
  predatesHorizon(packetSequenceNumber, ssId) {
8360
9845
  if (this.#retentionMs === null) return false;
8361
9846
  const seqFloor = this.sourceSeqs.horizonFloor;
8362
- if (seqFloor !== void 0 && serial32Precedes2(packetSequenceNumber, seqFloor) && !this.sourceSeqs.has(packetSequenceNumber)) {
9847
+ if (seqFloor !== void 0 && serial32Precedes3(packetSequenceNumber, seqFloor) && !this.sourceSeqs.has(packetSequenceNumber)) {
8363
9848
  return true;
8364
9849
  }
8365
9850
  if (ssId === void 0) return false;
8366
9851
  const ssIdFloor = this.parsedSymbols.horizonFloor;
8367
- return ssIdFloor !== void 0 && serial32Precedes2(ssId, ssIdFloor) && !this.parsedSymbols.has(ssId);
9852
+ return ssIdFloor !== void 0 && serial32Precedes3(ssId, ssIdFloor) && !this.parsedSymbols.has(ssId);
8368
9853
  }
8369
9854
  /** Whether the stream keeps ledgers, for its FEC horizon or by count. */
8370
9855
  get retaining() {
@@ -8429,6 +9914,16 @@ function keyframeOverlaySsId(header) {
8429
9914
  if (ext?.type !== KEYFRAME_OVERLAY_EXTENSION_TYPE || ext.data.length !== 4) return void 0;
8430
9915
  return new DataView(ext.data.buffer, ext.data.byteOffset, 4).getUint32(0);
8431
9916
  }
9917
+ function overlayBaseSsId(anchor, overlaySsId, restoredPsn, headerPsn) {
9918
+ if (anchor.baseSsId === void 0 || headerPsn !== restoredPsn) return void 0;
9919
+ return serial32Add(anchor.baseSsId, overlaySsId - anchor.overlaySsId | 0);
9920
+ }
9921
+ function unstampedBaseSsId(offset, unstampedOffset, headerPsn) {
9922
+ if (offset === void 0 || offset !== unstampedOffset) return void 0;
9923
+ return headerPsn + offset >>> 0;
9924
+ }
9925
+ var OVERLAY_ROUTED_LIMIT = 1024;
9926
+ var OVERLAY_PENDING_LIMIT = 256;
8432
9927
  var OVERLAY_OFFSET_MPUS = 16;
8433
9928
  var OVERLAY_MOQ_HORIZON_BLOCKS = 64;
8434
9929
  function assertStreamIdentity(role, stream) {
@@ -8596,6 +10091,14 @@ var MmtpRouter = class {
8596
10091
  * sequence is its SS_ID minus this; see #routeProcessedFecSymbols.
8597
10092
  */
8598
10093
  #ssIdSeqOffsetByStream = /* @__PURE__ */ new Map();
10094
+ /**
10095
+ * Per stream, the SS_ID offset at which a base recovery last showed the
10096
+ * sender's own packet_sequence_number: a recovered packet's header psn
10097
+ * equal to its SS_ID minus that offset. While it equals the stream's
10098
+ * current offset, no hop re-stamps the stream and a packet's protected psn
10099
+ * names its base SS_ID (joint decoding of an MPU no member of which arrived).
10100
+ */
10101
+ #unstampedSsIdSeqOffsetByStream = /* @__PURE__ */ new Map();
8599
10102
  /**
8600
10103
  * Streams whose multicast leg delivered a RAP-flagged source packet that was
8601
10104
  * not parsed, typically because its overlap copy was parsed first
@@ -8665,7 +10168,9 @@ var MmtpRouter = class {
8665
10168
  recovered: 0,
8666
10169
  restarts: 0,
8667
10170
  unsequencedDropped: 0,
8668
- protectedDropped: 0
10171
+ protectedDropped: 0,
10172
+ baseFed: 0,
10173
+ baseUndetermined: 0
8669
10174
  };
8670
10175
  /** Callback fired when a delta-track FEC block completes or fails. */
8671
10176
  #onDeltaBlockCompleteCb = null;
@@ -8838,7 +10343,7 @@ var MmtpRouter = class {
8838
10343
  const cb = this.#getFrameCallback(packetId);
8839
10344
  if (isVideo && !assembler) {
8840
10345
  if (frameMeta.recovered) {
8841
- this.#routeDirectRecoveredVideo(packetId, data, frameMeta, cb);
10346
+ this.#routeDirectRecoveredVideo(packetId, data, frameMeta, cb, meta.fecSbn);
8842
10347
  } else {
8843
10348
  this.#routeDirectSourceVideo(packetId, data, frameMeta, cb);
8844
10349
  }
@@ -9228,6 +10733,10 @@ var MmtpRouter = class {
9228
10733
  get keyframeOverlayStats() {
9229
10734
  return { ...this.#overlayStats };
9230
10735
  }
10736
+ /** Recovered FT=2 heads held for their FT=1, and how each left the wait (BLO-53369). */
10737
+ get recoveredHeadsAwaitingTiming() {
10738
+ return this.#parser.recoveredHeadsAwaitingTiming;
10739
+ }
9231
10740
  #isOverlayRepair(repairPacketId, repairTrackName) {
9232
10741
  return this.#overlayLegs.has(repairPacketId) || repairTrackName !== void 0 && this.#overlayRepairNames.has(repairTrackName);
9233
10742
  }
@@ -9278,6 +10787,11 @@ var MmtpRouter = class {
9278
10787
  */
9279
10788
  resetKeyframeOverlays(sourcePacketId, catalogTrackName) {
9280
10789
  for (const inst of this.#overlaysForSource(sourcePacketId, catalogTrackName)) this.#resetOverlay(inst);
10790
+ for (const [name, stream] of this.#streams.get(sourcePacketId) ?? []) {
10791
+ if (catalogTrackName !== void 0 && name !== catalogTrackName) continue;
10792
+ stream.overlayToBase.routed.clear();
10793
+ stream.overlayToBase.pending.clear();
10794
+ }
9281
10795
  }
9282
10796
  #resetOverlay(inst) {
9283
10797
  inst.fec.resetTrack(inst.repairPacketId, inst.streamName);
@@ -9334,9 +10848,10 @@ var MmtpRouter = class {
9334
10848
  * A source packet that carries a 0x8B01 overlay SS_ID is also a symbol of
9335
10849
  * every overlay installed for its source, at that SS_ID, with the bytes the
9336
10850
  * base protects: the packet minus its 4-byte SS_ID trailer, extension
9337
- * included (7.1, 7.4.3).
10851
+ * included (7.1, 7.4.3). `baseSsId` is that trailer's SS_ID, when the base
10852
+ * FEC reads it.
9338
10853
  */
9339
- #feedOverlaySource(packetId, catalogTrackName, packet, header, sequenceNumber, rapFlag, source) {
10854
+ #feedOverlaySource(packetId, catalogTrackName, packet, header, sequenceNumber, rapFlag, source, baseSsId) {
9340
10855
  if (this.#overlays.size === 0) return;
9341
10856
  const ssId = keyframeOverlaySsId(header);
9342
10857
  if (ssId === void 0) return;
@@ -9345,7 +10860,11 @@ var MmtpRouter = class {
9345
10860
  const identity = MmtpContainerParser.sourcePacketIdentity(packet, header);
9346
10861
  if (identity !== null) {
9347
10862
  inst.offsets.delete(identity.mpuSequenceNumber);
9348
- inst.offsets.set(identity.mpuSequenceNumber, ssId - sequenceNumber >>> 0);
10863
+ inst.offsets.set(identity.mpuSequenceNumber, {
10864
+ psnOffset: ssId - sequenceNumber >>> 0,
10865
+ overlaySsId: ssId,
10866
+ ...baseSsId === void 0 ? {} : { baseSsId }
10867
+ });
9349
10868
  if (inst.offsets.size > OVERLAY_OFFSET_MPUS) inst.offsets.delete(inst.offsets.keys().next().value);
9350
10869
  }
9351
10870
  const { fec, repairPacketId: pid, streamName: name } = inst;
@@ -9424,14 +10943,27 @@ var MmtpRouter = class {
9424
10943
  const streamKey = this.#mediaStreamKey(sourcePacketId, sourceName);
9425
10944
  const restamped = this.#restampedStreams.has(streamKey);
9426
10945
  const restored = /* @__PURE__ */ new Set();
10946
+ const baseSsIds = /* @__PURE__ */ new Map();
9427
10947
  const symbols = result.symbols.flatMap((sym) => {
9428
- const offset = inst.offsets.get(sym.fragment.mpuSequenceNumber);
9429
- if (offset !== void 0) {
9430
- const packetSequenceNumber = serial32Add(blockStart, sym.index) - offset >>> 0;
10948
+ const anchor = inst.offsets.get(sym.fragment.mpuSequenceNumber);
10949
+ if (anchor !== void 0) {
10950
+ const overlaySsId = serial32Add(blockStart, sym.index);
10951
+ const packetSequenceNumber = overlaySsId - anchor.psnOffset >>> 0;
9431
10952
  restored.add(packetSequenceNumber);
9432
- return [{ ...sym, packetSequenceNumber }];
10953
+ const out = { ...sym, packetSequenceNumber };
10954
+ const baseSsId = overlayBaseSsId(anchor, overlaySsId, packetSequenceNumber, sym.packetSequenceNumber);
10955
+ if (baseSsId !== void 0) baseSsIds.set(out, baseSsId);
10956
+ return [out];
10957
+ }
10958
+ if (!restamped) {
10959
+ const baseSsId = unstampedBaseSsId(
10960
+ this.#ssIdSeqOffsetByStream.get(streamKey),
10961
+ this.#unstampedSsIdSeqOffsetByStream.get(streamKey),
10962
+ sym.packetSequenceNumber
10963
+ );
10964
+ if (baseSsId !== void 0) baseSsIds.set(sym, baseSsId);
10965
+ return [sym];
9433
10966
  }
9434
- if (!restamped) return [sym];
9435
10967
  this.#overlayStats.unsequencedDropped++;
9436
10968
  return [];
9437
10969
  });
@@ -9440,6 +10972,85 @@ var MmtpRouter = class {
9440
10972
  deliveryProvenance: inst.moqBlocks.has(blockStart) ? "mixed" : "multicast"
9441
10973
  });
9442
10974
  this.#noteOverlayRecoveredRaps(sourcePacketId, sourceName, streamKey, symbols.filter((sym) => restored.has(sym.packetSequenceNumber)));
10975
+ this.#feedOverlayRecoveredToBase(inst, blockStart, symbols, baseSsIds);
10976
+ }
10977
+ /**
10978
+ * Joint decoding (BLO-52085). A packet the overlay rebuilt is a source of
10979
+ * its base block too, once its base SS_ID is determined (overlayBaseSsId,
10980
+ * from a received member of its MPU, else unstampedBaseSsId),
10981
+ * so it is fed to the base decoder as a rebuilt source
10982
+ * (FECManager.feedRebuiltSourceSymbol): the base block has one erasure
10983
+ * fewer to solve, and its repair can recover the block's other packets.
10984
+ * A base block no repair has stated yet takes it when one does
10985
+ * (#feedPendingRebuilt).
10986
+ *
10987
+ * The overlay route already handled each packet fed (delivered it, or
10988
+ * held an untimed head for its FT=1), so a base recovery routes none of
10989
+ * them again (#routeVerifiedFecSymbols): a second recovered copy of a
10990
+ * held head would wait in the parser for timing its first copy consumed.
10991
+ * Only an overlay block decoded from multicast symbols alone is fed: with
10992
+ * a MoQ symbol in it, the base block's recovery would depend on bytes
10993
+ * that crossed no multicast socket, which the provenance counted from the
10994
+ * block's own arrivals cannot show.
10995
+ */
10996
+ #feedOverlayRecoveredToBase(inst, blockStart, symbols, baseSsIds) {
10997
+ const fec = this.#fecManager;
10998
+ const owner = inst.leg.sourcePacketId;
10999
+ const name = inst.leg.sourceCatalogTrackName;
11000
+ if (!fec?.feedRebuiltSourceSymbol || inst.moqBlocks.has(blockStart)) return;
11001
+ if (this.#classifyPacketId(owner) === null || fec.isTrackConfigured?.(owner) === false) return;
11002
+ const { duplicates, overlayToBase: { routed } } = this.#stream(owner, name);
11003
+ const feeds = [];
11004
+ for (const sym of symbols) {
11005
+ if (duplicates.sourceSeqs.has(sym.packetSequenceNumber)) continue;
11006
+ const ssId = baseSsIds.get(sym);
11007
+ if (ssId === void 0) {
11008
+ this.#overlayStats.baseUndetermined++;
11009
+ continue;
11010
+ }
11011
+ routed.delete(ssId);
11012
+ routed.add(ssId);
11013
+ if (routed.size > OVERLAY_ROUTED_LIMIT) routed.delete(routed.values().next().value);
11014
+ feeds.push([ssId, sym.rawSymbol]);
11015
+ }
11016
+ for (const [ssId, symbolData] of feeds) {
11017
+ if (this.#classifyPacketId(owner) === null) return;
11018
+ this.#feedRebuiltToBase(owner, name, ssId, symbolData);
11019
+ }
11020
+ }
11021
+ /** Feed one rebuilt source to its base block, or keep it until a repair states that block. */
11022
+ #feedRebuiltToBase(owner, name, ssId, symbolData) {
11023
+ const fec = this.#fecManager;
11024
+ if (!fec?.feedRebuiltSourceSymbol) return;
11025
+ const coords = fec.locateIsoSource?.(owner, name, ssId);
11026
+ if (coords == null) {
11027
+ const pending = this.#stream(owner, name).overlayToBase.pending;
11028
+ pending.delete(ssId);
11029
+ pending.set(ssId, symbolData.slice());
11030
+ if (pending.size > OVERLAY_PENDING_LIMIT) pending.delete(pending.keys().next().value);
11031
+ return;
11032
+ }
11033
+ const fed = fec.feedRebuiltSourceSymbol(owner, ssId, symbolData, name);
11034
+ if (!fed.taken) return;
11035
+ this.#overlayStats.baseFed++;
11036
+ if (fed.recovered === null) return;
11037
+ const sbn = coords.sbn;
11038
+ this.#routeRecoveredBlock(sbn, fed.recovered, owner, name, () => {
11039
+ fec.cancelBlockTimeout(sbn, owner, name);
11040
+ fec.countRecovery(owner, sbn, name);
11041
+ fec.markBlockCompleted(sbn, owner, name);
11042
+ });
11043
+ }
11044
+ /** A repair stated `block`: the rebuilt sources that waited for it join it now. */
11045
+ #feedPendingRebuilt(owner, name, block) {
11046
+ const pending = this.#streams.get(owner)?.get(name)?.overlayToBase.pending;
11047
+ if (pending === void 0 || pending.size === 0) return;
11048
+ for (const [ssId, symbolData] of [...pending]) {
11049
+ if (this.#classifyPacketId(owner) === null) return;
11050
+ if (serial32Distance(block.start, ssId) >= block.length) continue;
11051
+ pending.delete(ssId);
11052
+ this.#feedRebuiltToBase(owner, name, ssId, symbolData);
11053
+ }
9443
11054
  }
9444
11055
  /** fec-multicast-00 §6.6: report each base block whose RAP MFU fragments
9445
11056
  * the overlay restored, when the keyframe's FT=0 and FT=1 are held too. A
@@ -9571,10 +11182,11 @@ var MmtpRouter = class {
9571
11182
  if (!entry) return;
9572
11183
  entry.receiver.release();
9573
11184
  entry.protected = false;
11185
+ entry.userAgent = false;
9574
11186
  this.#assistBlocks.clear();
9575
11187
  this.#assistFailures.clear();
9576
11188
  }
9577
- /** MoQ packets of a track a protected flow lists, dropped on its unicast path: duplicate, overflow, or listed by more than one flow. */
11189
+ /** Packets of a track a protected flow lists, dropped unread: a MoQ copy (duplicate, overflow, or listed by more than one flow) or a multicast source datagram that names no flow, of a track a user-agent-admitted flow lists. */
9578
11190
  get unadmittedListedDropCount() {
9579
11191
  return this.#unadmittedListedDrops;
9580
11192
  }
@@ -9615,6 +11227,13 @@ var MmtpRouter = class {
9615
11227
  }
9616
11228
  return false;
9617
11229
  }
11230
+ /** Whether a protected flow the user agent admitted lists `packetId`. */
11231
+ #userAgentGoverns(packetId) {
11232
+ for (const entry of this.#provenance.values()) {
11233
+ if (entry.protected && entry.userAgent && ingressHasPacketId(entry.ingress, packetId)) return true;
11234
+ }
11235
+ return false;
11236
+ }
9618
11237
  /**
9619
11238
  * A MoQ repair symbol no protected flow lists, owned by a source track one
9620
11239
  * does: its FEC instance is governed (7.2.7 step 2) and takes only admitted
@@ -9705,7 +11324,7 @@ var MmtpRouter = class {
9705
11324
  fec.cancelBlockTimeout(blockStart, ownerPacketId, catalogTrackName);
9706
11325
  fec.countRecovery(ownerPacketId, blockStart, catalogTrackName);
9707
11326
  fec.markBlockCompleted(blockStart, ownerPacketId, catalogTrackName);
9708
- })) {
11327
+ }, true)) {
9709
11328
  this.#unlistedAssistedRecoveries++;
9710
11329
  this.#assistBlocks.delete(key);
9711
11330
  return;
@@ -10057,6 +11676,8 @@ var MmtpRouter = class {
10057
11676
  )) return;
10058
11677
  fec.noteBlockActivity?.(blockStart, ownerPacketId, catalogTrackName);
10059
11678
  fec.noteRepairGroupLength?.(blockStart, ownerPacketId, repair.rsbLength, catalogTrackName);
11679
+ this.#feedPendingRebuilt(ownerPacketId, catalogTrackName, placement.block);
11680
+ if (this.#classifyPacketId(ownerPacketId) === null) return;
10060
11681
  const alreadyCompleted = fec.isBlockCompleted(blockStart, ownerPacketId, catalogTrackName);
10061
11682
  const recovered = fec.feedRepairSymbol(
10062
11683
  ownerPacketId,
@@ -10194,6 +11815,7 @@ var MmtpRouter = class {
10194
11815
  this.#droppedPreSyncVideo.delete(streamKey);
10195
11816
  this.#latestVideoSeqByStream.delete(streamKey);
10196
11817
  this.#ssIdSeqOffsetByStream.delete(streamKey);
11818
+ this.#unstampedSsIdSeqOffsetByStream.delete(streamKey);
10197
11819
  this.#multicastRapTwinByStream.delete(streamKey);
10198
11820
  this.#lastDirectSourceVideoTsByStream.delete(streamKey);
10199
11821
  this.#lastDirectEmittedVideoTsByStream.delete(streamKey);
@@ -10861,6 +12483,12 @@ var MmtpRouter = class {
10861
12483
  debugRouter(`[MmtpRouter] Dropping multicast packet pid=${info.packetId}: flow ${ingress.flow} has no receiver`);
10862
12484
  return;
10863
12485
  }
12486
+ if (ingress.admittedBy === USER_AGENT_VERIFIED) {
12487
+ this.#noteFlowAdmitted(ingress.flow);
12488
+ protectedFlow.userAgent = true;
12489
+ this.#processMMTP(packet, source, true, ingress, overlapCopy);
12490
+ return;
12491
+ }
10864
12492
  const { verdict } = protectedFlow.receiver.onDatagram(packet, "multicast");
10865
12493
  if (verdict === "admitted") {
10866
12494
  this.#noteFlowAdmitted(ingress.flow);
@@ -10878,6 +12506,11 @@ var MmtpRouter = class {
10878
12506
  return;
10879
12507
  }
10880
12508
  }
12509
+ const flowless = source === "multicast" && !manifestAdmitted && ingress?.flow === void 0;
12510
+ if (flowless && !info.isRepair && this.#userAgentGoverns(info.packetId)) {
12511
+ this.#unadmittedListedDrops++;
12512
+ return;
12513
+ }
10881
12514
  if (info.isRepair) {
10882
12515
  const overlay = this.#overlayRepairTarget(info.packetId, catalogTrackNameForPacket(ingress, info.packetId));
10883
12516
  if (overlay !== void 0) {
@@ -10895,7 +12528,7 @@ var MmtpRouter = class {
10895
12528
  );
10896
12529
  if (!repair) return;
10897
12530
  if (ownerPacketId != null && this.#fecManager) {
10898
- if (source === "moq" && !manifestAdmitted && this.#setAsideUnlistedRepair(ownerPacketId, catalogTrackNameForPacket(ingress, ownerPacketId), repair)) return;
12531
+ if ((flowless && this.#userAgentGoverns(ownerPacketId) || source === "moq" && !manifestAdmitted) && this.#setAsideUnlistedRepair(ownerPacketId, catalogTrackNameForPacket(ingress, ownerPacketId), repair)) return;
10899
12532
  this.#repairPidCounts[info.packetId] = (this.#repairPidCounts[info.packetId] || 0) + 1;
10900
12533
  const count = this.#repairPidCounts[info.packetId];
10901
12534
  if (count <= 2 || count % 500 === 0) {
@@ -11098,7 +12731,8 @@ var MmtpRouter = class {
11098
12731
  parsed.header,
11099
12732
  info.sequenceNumber,
11100
12733
  info.rapFlag,
11101
- source
12734
+ source,
12735
+ fecPayloadId?.ssId
11102
12736
  );
11103
12737
  }
11104
12738
  }
@@ -11248,7 +12882,8 @@ var MmtpRouter = class {
11248
12882
  if (stream === void 0) {
11249
12883
  stream = {
11250
12884
  key: JSON.stringify([packetId, catalogTrackName ?? null]),
11251
- duplicates: new StreamDuplicateLedgers(() => this.duplicateHorizonFor(packetId))
12885
+ duplicates: new StreamDuplicateLedgers(() => this.duplicateHorizonFor(packetId)),
12886
+ overlayToBase: { routed: /* @__PURE__ */ new Set(), pending: /* @__PURE__ */ new Map() }
11252
12887
  };
11253
12888
  byName.set(catalogTrackName, stream);
11254
12889
  }
@@ -11363,8 +12998,16 @@ var MmtpRouter = class {
11363
12998
  this.#drainDirectRecoveredVideo(streamKey, packetId, (frame) => frame.timestamp <= meta.timestamp, cb);
11364
12999
  this.#emitDirectVideo(packetId, data, meta, cb);
11365
13000
  }
11366
- #routeDirectRecoveredVideo(packetId, data, meta, cb) {
13001
+ #routeDirectRecoveredVideo(packetId, data, meta, cb, fecSbn) {
11367
13002
  const streamKey = this.#mediaStreamKey(packetId, meta.catalogTrackName);
13003
+ if (this.#isFecTrack(packetId)) {
13004
+ const high = this.#deliveredVideoHighWaterByStream.get(streamKey);
13005
+ if (fecSbn !== void 0 || high !== void 0 && authoredAdjacent(high, meta)) {
13006
+ this.#drainDirectRecoveredVideo(streamKey, packetId, (frame) => frame.timestamp <= meta.timestamp, cb);
13007
+ this.#emitDirectVideo(packetId, data, meta, cb);
13008
+ return;
13009
+ }
13010
+ }
11368
13011
  this.#enqueueDirectRecoveredVideo(packetId, data, meta);
11369
13012
  const lastSourceTs = this.#lastDirectSourceVideoTsByStream.get(streamKey);
11370
13013
  this.#drainDirectRecoveredVideo(
@@ -11474,7 +13117,7 @@ var MmtpRouter = class {
11474
13117
  }
11475
13118
  // ── Private: FEC recovery routing ───────────────────────────────
11476
13119
  /** Route WASM-recovered block through appropriate path based on container kind. */
11477
- #routeRecoveredBlock(blockId, recovered, ownerPacketId, catalogTrackName, complete) {
13120
+ #routeRecoveredBlock(blockId, recovered, ownerPacketId, catalogTrackName, complete, assisted = false) {
11478
13121
  const packetSymbolSize = this.#symbolSizeForPacket(ownerPacketId);
11479
13122
  if (ownerPacketId === void 0) {
11480
13123
  throw new Error("[MmtpRouter] a recovered FEC block needs its owner packetId to read its fec.blockProfile");
@@ -11491,7 +13134,7 @@ var MmtpRouter = class {
11491
13134
  ownerPacketId,
11492
13135
  blockProfile
11493
13136
  });
11494
- return this.#routeVerifiedFecSymbols(blockId, result, ownerPacketId, catalogTrackName, complete);
13137
+ return this.#routeVerifiedFecSymbols(blockId, result, ownerPacketId, catalogTrackName, complete, assisted);
11495
13138
  }
11496
13139
  /**
11497
13140
  * A recovered source packet of a protected flow is admitted on its own
@@ -11503,9 +13146,31 @@ var MmtpRouter = class {
11503
13146
  * recovered packets is refused is unrecovered (7.2.7): its caller leaves it
11504
13147
  * open, so it reaches its FEC deadline and fails by class. Always true
11505
13148
  * outside a protected flow.
13149
+ *
13150
+ * On a flow the user agent admitted, the live decoder's recoveries are kept
13151
+ * without a record: while the flow is protected that decoder takes only
13152
+ * symbols the user agent or a record verified (a flow-less multicast
13153
+ * datagram of a listed track is dropped or set aside in #processMMTP), so
13154
+ * what it recovers is as verified as they are. An `assisted` (scratch-decoder) recovery mixes in
13155
+ * unlisted MoQ repair and still needs a record.
13156
+ * ponytail: per-flow flag; per-block provenance only if a protected flow
13157
+ * ever mixes verified and unverified live inputs.
13158
+ * ponytail: a worker frame computed before the protection reset can still
13159
+ * arrive after it (frames carry no epoch); its inputs are MoQ copies read
13160
+ * and played before protection, so it admits nothing new. Drop pre-reset
13161
+ * frames by epoch only if pre-protection input ever matters.
11506
13162
  */
11507
- #routeVerifiedFecSymbols(blockId, result, ownerPacketId, catalogTrackName, complete) {
11508
- const receiver = ownerPacketId == null ? void 0 : this.#provenanceFor(ownerPacketId, catalogTrackName);
13163
+ #routeVerifiedFecSymbols(blockId, result, ownerPacketId, catalogTrackName, complete, assisted = false) {
13164
+ const overlayRouted = ownerPacketId == null ? void 0 : this.#streams.get(ownerPacketId)?.get(catalogTrackName)?.overlayToBase.routed;
13165
+ if (overlayRouted !== void 0 && overlayRouted.size > 0) {
13166
+ result = {
13167
+ ...result,
13168
+ symbols: result.symbols.filter((sym) => !overlayRouted.has(this.#fecAnchorForRecovered(blockId, sym.index)))
13169
+ };
13170
+ }
13171
+ const entry = ownerPacketId == null ? void 0 : this.#provenanceFor(ownerPacketId, catalogTrackName);
13172
+ const receiver = entry === void 0 || entry === "ambiguous" ? entry : entry.receiver;
13173
+ const userAgent = !assisted && entry !== void 0 && entry !== "ambiguous" && entry.protected === true && entry.userAgent === true;
11509
13174
  let recovered = true;
11510
13175
  if (receiver) {
11511
13176
  const parsed = this.#stream(ownerPacketId, catalogTrackName).duplicates.parsedSymbols;
@@ -11517,6 +13182,8 @@ var MmtpRouter = class {
11517
13182
  let admitted = false;
11518
13183
  if (receiver === "ambiguous") {
11519
13184
  this.#ambiguousRecoveredDrops++;
13185
+ } else if (userAgent) {
13186
+ admitted = true;
11520
13187
  } else {
11521
13188
  const wire = new Uint8Array(sym.rawSymbol.byteLength + 4);
11522
13189
  wire.set(sym.rawSymbol);
@@ -11544,12 +13211,12 @@ var MmtpRouter = class {
11544
13211
  catalogTrackName === void 0 || !byName ? byName?.values() : byName.has(catalogTrackName) ? [byName.get(catalogTrackName)] : []
11545
13212
  );
11546
13213
  if (flows.size > 1) return "ambiguous";
11547
- for (const bound of flows) return this.#provenance.get(bound)?.receiver ?? "ambiguous";
13214
+ for (const bound of flows) return this.#provenance.get(bound) ?? "ambiguous";
11548
13215
  let match;
11549
- for (const { receiver, ingress } of this.#provenance.values()) {
11550
- if (ingress.tracks.some((t) => t.packetId === packetId && (catalogTrackName === void 0 || t.name === catalogTrackName))) {
13216
+ for (const entry of this.#provenance.values()) {
13217
+ if (entry.ingress.tracks.some((t) => t.packetId === packetId && (catalogTrackName === void 0 || t.name === catalogTrackName))) {
11551
13218
  if (match) return "ambiguous";
11552
- match = receiver;
13219
+ match = entry;
11553
13220
  }
11554
13221
  }
11555
13222
  return match;
@@ -11565,7 +13232,10 @@ var MmtpRouter = class {
11565
13232
  ...result,
11566
13233
  symbols: result.symbols.map((sym) => {
11567
13234
  const wire = this.#fecAnchorForRecovered(blockId, sym.index) - ssIdSeqOffset >>> 0;
11568
- if (wire === sym.packetSequenceNumber) return sym;
13235
+ if (wire === sym.packetSequenceNumber) {
13236
+ this.#unstampedSsIdSeqOffsetByStream.set(streamKey, ssIdSeqOffset);
13237
+ return sym;
13238
+ }
11569
13239
  this.#restampedRecoveredSeqs++;
11570
13240
  this.#restampedStreams.add(streamKey);
11571
13241
  return { ...sym, packetSequenceNumber: wire };
@@ -11601,6 +13271,29 @@ var MmtpRouter = class {
11601
13271
  }
11602
13272
  );
11603
13273
  }
13274
+ const untimedHead = (sym) => sym.fragmentType === 2 && sym.fragment.fragmentationIndicator === 1 && sym.fragment.movieFragmentSequenceNumber !== void 0 && sym.fragment.sampleNumber !== void 0 && !this.#parser.hasAuthoredTimingFor(
13275
+ ownerPacketId,
13276
+ sym.fragment.mpuSequenceNumber,
13277
+ sym.fragment.movieFragmentSequenceNumber,
13278
+ sym.fragment.sampleNumber,
13279
+ catalogTrackName
13280
+ );
13281
+ for (const sym of result.symbols) {
13282
+ if (!untimedHead(sym) || this.#sourceSeqReceived(ownerPacketId, sym.packetSequenceNumber, catalogTrackName)) continue;
13283
+ this.#feedViaParser(
13284
+ sym.rawSymbol,
13285
+ { packetId: ownerPacketId, sequenceNumber: sym.packetSequenceNumber },
13286
+ void 0,
13287
+ {
13288
+ skipFecPayloadId: true,
13289
+ recovered: true,
13290
+ deliveryProvenance,
13291
+ ...fecSbnOf(sym.index),
13292
+ ...catalogTrackName === void 0 ? {} : { catalogTrackName }
13293
+ }
13294
+ );
13295
+ }
13296
+ result = { ...result, symbols: result.symbols.filter((sym) => !untimedHead(sym)) };
11604
13297
  const gated = this.#isFecTrack(ownerPacketId);
11605
13298
  const assembler = this.#mseAssemblerFor(ownerPacketId, catalogTrackName);
11606
13299
  if (assembler) {