@blockcast/mmt-container 2.2.0-main.efb4fa4521af → 2.2.0-main.f480a7ede8b1

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 (72) hide show
  1. package/README.md +29 -17
  2. package/dist/browser-runtime.js +446 -162
  3. package/dist/browser-runtime.js.map +3 -3
  4. package/dist/catalog-fec-params.d.ts +17 -1
  5. package/dist/catalog-fec-params.d.ts.map +1 -1
  6. package/dist/catalog-fec-params.js +15 -0
  7. package/dist/catalog-fec-params.js.map +2 -2
  8. package/dist/cmcd-producer.d.ts +60 -47
  9. package/dist/cmcd-producer.d.ts.map +1 -1
  10. package/dist/cmcd-producer.js +138 -109
  11. package/dist/cmcd-producer.js.map +2 -2
  12. package/dist/delivery-catalog.js +26 -16
  13. package/dist/delivery-catalog.js.map +2 -2
  14. package/dist/delta-fallback-config.js.map +1 -1
  15. package/dist/fec-manager.d.ts +3 -1
  16. package/dist/fec-manager.d.ts.map +1 -1
  17. package/dist/fec-manager.js +121 -5
  18. package/dist/fec-manager.js.map +2 -2
  19. package/dist/fec-worker-setup.d.ts +1 -1
  20. package/dist/fec-worker-setup.js +1 -1
  21. package/dist/fec-worker-setup.js.map +1 -1
  22. package/dist/index.d.ts +3 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +5 -1
  25. package/dist/index.js.map +2 -2
  26. package/dist/iso-block-locator.d.ts +9 -0
  27. package/dist/iso-block-locator.d.ts.map +1 -1
  28. package/dist/iso-block-locator.js +17 -0
  29. package/dist/iso-block-locator.js.map +2 -2
  30. package/dist/iso-bmff-timing.d.ts +10 -1
  31. package/dist/iso-bmff-timing.d.ts.map +1 -1
  32. package/dist/iso-bmff-timing.js +36 -2
  33. package/dist/iso-bmff-timing.js.map +2 -2
  34. package/dist/media-router.js.map +1 -1
  35. package/dist/mmtp-container-parser.d.ts +37 -2
  36. package/dist/mmtp-container-parser.d.ts.map +1 -1
  37. package/dist/mmtp-container-parser.js +86 -9
  38. package/dist/mmtp-container-parser.js.map +2 -2
  39. package/dist/mmtp-container-parser.no-modules.js +89 -11
  40. package/dist/mmtp-container-parser.no-modules.js.map +2 -2
  41. package/dist/mmtp-decode-pipeline.js +85 -9
  42. package/dist/mmtp-decode-pipeline.js.map +2 -2
  43. package/dist/mmtp-router.d.ts +6 -0
  44. package/dist/mmtp-router.d.ts.map +1 -1
  45. package/dist/mmtp-router.js +148 -27
  46. package/dist/mmtp-router.js.map +2 -2
  47. package/dist/multicast-manager.js +269 -32
  48. package/dist/multicast-manager.js.map +2 -2
  49. package/dist/multicast-transport.js +26 -16
  50. package/dist/multicast-transport.js.map +2 -2
  51. package/dist/no-modules.js +85 -9
  52. package/dist/no-modules.js.map +2 -2
  53. package/dist/repair-layer-subscriptions.d.ts +6 -2
  54. package/dist/repair-layer-subscriptions.d.ts.map +1 -1
  55. package/dist/repair-layer-subscriptions.js +6 -3
  56. package/dist/repair-layer-subscriptions.js.map +2 -2
  57. package/dist/track-session.d.ts +1 -1
  58. package/dist/track-session.js.map +1 -1
  59. package/dist/wasm-mmtp-container-parser.js +85 -9
  60. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  61. package/package.json +8 -5
  62. package/src/catalog-fec-params.ts +36 -1
  63. package/src/cmcd-producer.ts +189 -179
  64. package/src/fec-manager.ts +130 -5
  65. package/src/fec-worker-setup.ts +2 -2
  66. package/src/index.ts +7 -2
  67. package/src/iso-block-locator.ts +19 -0
  68. package/src/iso-bmff-timing.ts +40 -1
  69. package/src/mmtp-container-parser.ts +96 -8
  70. package/src/mmtp-router.ts +68 -1
  71. package/src/repair-layer-subscriptions.ts +13 -5
  72. package/src/track-session.ts +1 -1
@@ -7988,8 +7988,8 @@ var MulticastSubscriptionPool = class {
7988
7988
  record.claims--;
7989
7989
  return this.acquire(gateway, config, consumer);
7990
7990
  }
7991
- const token = Symbol(key);
7992
- entry.consumers.set(token, consumer);
7991
+ const token2 = Symbol(key);
7992
+ entry.consumers.set(token2, consumer);
7993
7993
  let released = false;
7994
7994
  return {
7995
7995
  key,
@@ -7997,7 +7997,7 @@ var MulticastSubscriptionPool = class {
7997
7997
  if (released)
7998
7998
  return;
7999
7999
  released = true;
8000
- entry.consumers.delete(token);
8000
+ entry.consumers.delete(token2);
8001
8001
  record.claims--;
8002
8002
  if (record.claims > 0)
8003
8003
  return;
@@ -8746,14 +8746,15 @@ var MOQT_PUBLISHER_PRIORITY = Object.freeze({
8746
8746
  });
8747
8747
 
8748
8748
  // node_modules/@blockcast/mmt-transport/dist/amt-gateway.js
8749
+ var MAX_PENDING_PACKETS = 256;
8749
8750
  var AMTGatewayManager = class {
8750
8751
  subscriptions = /* @__PURE__ */ new Map();
8751
8752
  messageHandler;
8753
+ messageListenerRegistered = false;
8752
8754
  pendingPackets = /* @__PURE__ */ new Map();
8753
8755
  constructor() {
8754
8756
  this.messageHandler = (event) => {
8755
8757
  const data = event.data;
8756
- console.log(`[AMT] SW message received:`, data.type);
8757
8758
  if (data.type === "UDP_PACKET") {
8758
8759
  this.handlePacket(data);
8759
8760
  } else if (data.type === "SOCKET_ERROR") {
@@ -8762,12 +8763,22 @@ var AMTGatewayManager = class {
8762
8763
  console.log(`[AMT SW Debug] ${data.message}`);
8763
8764
  }
8764
8765
  };
8765
- if (typeof navigator !== "undefined" && navigator.serviceWorker) {
8766
- navigator.serviceWorker.addEventListener("message", this.messageHandler);
8767
- console.log("[AMT] Registered service worker message handler");
8768
- } else {
8769
- console.warn("[AMT] Service worker not available");
8766
+ }
8767
+ ensureMessageListener() {
8768
+ if (this.messageListenerRegistered || typeof navigator === "undefined" || !navigator.serviceWorker || !this.messageHandler) {
8769
+ return;
8770
8770
  }
8771
+ navigator.serviceWorker.addEventListener("message", this.messageHandler);
8772
+ this.messageListenerRegistered = true;
8773
+ console.log("[AMT] Registered service worker message handler");
8774
+ }
8775
+ removeMessageListener() {
8776
+ if (!this.messageListenerRegistered)
8777
+ return;
8778
+ if (this.messageHandler && typeof navigator !== "undefined" && navigator.serviceWorker) {
8779
+ navigator.serviceWorker.removeEventListener("message", this.messageHandler);
8780
+ }
8781
+ this.messageListenerRegistered = false;
8771
8782
  }
8772
8783
  /**
8773
8784
  * Subscribe via AMT tunnel
@@ -8777,6 +8788,7 @@ var AMTGatewayManager = class {
8777
8788
  if (config.protected) {
8778
8789
  throw new Error("AMTGatewayManager refuses a protected join: it reports no per-datagram sender (multicast-02 7.2.7)");
8779
8790
  }
8791
+ this.ensureMessageListener();
8780
8792
  const keyStr = this.getKeyString(key);
8781
8793
  if (this.subscriptions.has(keyStr)) {
8782
8794
  console.log(`[AMT] Reusing existing subscription to ${keyStr}`);
@@ -8812,6 +8824,8 @@ var AMTGatewayManager = class {
8812
8824
  this.subscriptions.delete(keyStr);
8813
8825
  this.pendingPackets.delete(subscription.subscriptionId);
8814
8826
  }
8827
+ if (this.subscriptions.size === 0)
8828
+ this.removeMessageListener();
8815
8829
  throw new Error(`Failed to subscribe via AMT: ${error instanceof Error ? error.message : String(error)}`);
8816
8830
  }
8817
8831
  }
@@ -8833,6 +8847,8 @@ var AMTGatewayManager = class {
8833
8847
  } finally {
8834
8848
  this.subscriptions.delete(handle);
8835
8849
  this.pendingPackets.delete(subscription.subscriptionId);
8850
+ if (this.subscriptions.size === 0)
8851
+ this.removeMessageListener();
8836
8852
  }
8837
8853
  }
8838
8854
  /**
@@ -8849,14 +8865,12 @@ var AMTGatewayManager = class {
8849
8865
  if (!subscriptionId)
8850
8866
  return;
8851
8867
  const subscription = this.subscriptions.get(subscriptionId);
8852
- if (!subscription) {
8853
- console.warn(`[AMT] Received packet for unknown subscription: ${subscriptionId}`);
8868
+ if (!subscription)
8854
8869
  return;
8855
- }
8856
8870
  if (!subscription.handshakeComplete) {
8857
8871
  const queue = this.pendingPackets.get(subscriptionId);
8858
8872
  const packetData2 = data;
8859
- if (queue) {
8873
+ if (queue && queue.length < MAX_PENDING_PACKETS) {
8860
8874
  queue.push({
8861
8875
  data: packetData2.data,
8862
8876
  remoteAddress: packetData2.remoteAddress,
@@ -8882,19 +8896,15 @@ var AMTGatewayManager = class {
8882
8896
  if (!subscriptionId)
8883
8897
  return;
8884
8898
  const subscription = this.subscriptions.get(subscriptionId);
8885
- if (!subscription) {
8886
- console.warn(`[AMT] Received error for unknown subscription: ${subscriptionId}`);
8899
+ if (!subscription)
8887
8900
  return;
8888
- }
8889
8901
  subscription.config.onError(new Error(error || "Unknown error"));
8890
8902
  }
8891
8903
  /**
8892
8904
  * Cleanup on destroy
8893
8905
  */
8894
8906
  destroy() {
8895
- if (this.messageHandler && typeof navigator !== "undefined" && navigator.serviceWorker) {
8896
- navigator.serviceWorker.removeEventListener("message", this.messageHandler);
8897
- }
8907
+ this.removeMessageListener();
8898
8908
  const handles = Array.from(this.subscriptions.keys());
8899
8909
  for (const handle of handles) {
8900
8910
  this.unsubscribe(handle).catch((err) => {
@@ -12939,6 +12949,7 @@ var IsoBlockLocator = class _IsoBlockLocator {
12939
12949
  #retentionBlocks;
12940
12950
  #heldEvicted;
12941
12951
  #newest;
12952
+ #newestSource;
12942
12953
  #holes;
12943
12954
  static #checkedRetention(retentionBlocks) {
12944
12955
  if (!Number.isSafeInteger(retentionBlocks) || retentionBlocks <= 0) {
@@ -12976,6 +12987,21 @@ var IsoBlockLocator = class _IsoBlockLocator {
12976
12987
  observe(position) {
12977
12988
  if (this.#newest === void 0 || serial32Precedes(this.#newest, position)) this.#newest = position;
12978
12989
  }
12990
+ /**
12991
+ * The newest source SS_ID noted with `noteSource` (RFC 1982 order). Unlike
12992
+ * `newest`, a repair never moves it: a repair can run ahead of its sources,
12993
+ * a source cannot run ahead of the sources sent before it except by
12994
+ * reordering.
12995
+ */
12996
+ get newestSource() {
12997
+ return this.#newestSource;
12998
+ }
12999
+ /** Note a received source's SS_ID; true when it is the newest one yet. */
13000
+ noteSource(ssId) {
13001
+ if (this.#newestSource !== void 0 && !serial32Precedes(this.#newestSource, ssId)) return false;
13002
+ this.#newestSource = ssId;
13003
+ return true;
13004
+ }
12979
13005
  /** Held sources evicted by `heldCapacity` (never by the horizon itself). */
12980
13006
  get heldEvicted() {
12981
13007
  return this.#heldEvicted;
@@ -13208,6 +13234,7 @@ var IsoBlockLocator = class _IsoBlockLocator {
13208
13234
  this.#held.clear();
13209
13235
  this.#coverageEnd = void 0;
13210
13236
  this.#newest = void 0;
13237
+ this.#newestSource = void 0;
13211
13238
  }
13212
13239
  #claim(block) {
13213
13240
  const placed = [];
@@ -14052,6 +14079,10 @@ var FECManager = class _FECManager {
14052
14079
  // ── Keyframe-protected repair policy (block-policy.ts) ──
14053
14080
  // One classifier per physical stream, built from that stream's geometry.
14054
14081
  this.#classifiers = /* @__PURE__ */ new Map();
14082
+ // A stream without catalog timing has no classifier, so the last RAP it
14083
+ // released stands in for one: "an MPU boundary bounds its RAPs" holds until
14084
+ // a second RAP is released inside one MPU (BLO-52085).
14085
+ this.#noSpanRaps = /* @__PURE__ */ new Map();
14055
14086
  // Geometry a stream-scoped configuration gave one stream (streamKey). ABR
14056
14087
  // rungs can share a packetId with a different framerate or GOP, so the
14057
14088
  // packet-level geometry is only the geometry of a packet whose streams
@@ -14251,6 +14282,7 @@ var FECManager = class _FECManager {
14251
14282
  #orderedFrontiers;
14252
14283
  #orderedPendingBlocks;
14253
14284
  #classifiers;
14285
+ #noSpanRaps;
14254
14286
  #streamGeometries;
14255
14287
  #failedBlocks;
14256
14288
  #resolvedBlockClasses;
@@ -14912,6 +14944,7 @@ var FECManager = class _FECManager {
14912
14944
  };
14913
14945
  const discontinuity = () => {
14914
14946
  this.#classifiers.get(streamKey)?.noteSourceDiscontinuity();
14947
+ this.#noSpanRaps.delete(streamKey);
14915
14948
  this.#highestSbn.set(streamKey, { sbn, atMs: now });
14916
14949
  this.#sourceFrontiers.set(streamKey, { sbn, atMs: now });
14917
14950
  if (psn === void 0) this.#sourceAnchors.delete(streamKey);
@@ -15324,6 +15357,9 @@ var FECManager = class _FECManager {
15324
15357
  }
15325
15358
  if (policyChanged) {
15326
15359
  this.#bumpEpoch(packetId);
15360
+ for (const key of [...this.#noSpanRaps.keys()]) {
15361
+ if (this.#belongsToPacket(key, packetId)) this.#noSpanRaps.delete(key);
15362
+ }
15327
15363
  for (const [key, classifier] of [...this.#classifiers]) {
15328
15364
  if (!this.#belongsToPacket(key, packetId)) continue;
15329
15365
  const stream = this.#parseStreamKey(key);
@@ -15559,6 +15595,7 @@ var FECManager = class _FECManager {
15559
15595
  resetMap(this.#isoLocators);
15560
15596
  resetMap(this.#sourceAnchors);
15561
15597
  this.#classifiers.delete(this.#streamKey(packetId, catalogTrackName));
15598
+ this.#noSpanRaps.delete(this.#streamKey(packetId, catalogTrackName));
15562
15599
  this.#bumpEpoch(packetId);
15563
15600
  for (const [sbn, keys] of this.#blockKeysBySbn) {
15564
15601
  for (const key of keys.keys()) {
@@ -15665,6 +15702,7 @@ var FECManager = class _FECManager {
15665
15702
  dropMap(this.#isoLocators);
15666
15703
  dropMap(this.#sourceAnchors);
15667
15704
  dropMap(this.#classifiers);
15705
+ dropMap(this.#noSpanRaps);
15668
15706
  dropMap(this.#streamGeometries);
15669
15707
  dropSet(this.#pendingWorkerCleanups);
15670
15708
  dropSet(this.#completedBlocks);
@@ -15810,6 +15848,9 @@ var FECManager = class _FECManager {
15810
15848
  this.#unindexBlockKey(key);
15811
15849
  }
15812
15850
  }
15851
+ if (isSourceSymbol && source === "multicast") {
15852
+ this.#noteMulticastSource(this.#isoLocatorFor(packetId, catalogTrackName), sbn + esi >>> 0, packetId, catalogTrackName);
15853
+ }
15813
15854
  return true;
15814
15855
  }
15815
15856
  #observeBlockCatalogTrackName(blockKey, sbn, packetId, catalogTrackName) {
@@ -16127,6 +16168,7 @@ var FECManager = class _FECManager {
16127
16168
  const locator = this.#growingIsoLocatorFor(packetId, catalogTrackName);
16128
16169
  const evictedBefore = locator.heldEvicted;
16129
16170
  const held = locator.hold(ssId, source);
16171
+ if (held && source.source === "multicast") this.#noteMulticastSource(locator, ssId, packetId, catalogTrackName);
16130
16172
  const evicted = locator.heldEvicted - evictedBefore;
16131
16173
  if (evicted > 0) {
16132
16174
  const before = this.#isoHeldEvictedCount;
@@ -16295,11 +16337,14 @@ var FECManager = class _FECManager {
16295
16337
  /**
16296
16338
  * Whether an MPU boundary still bounds this stream's RAPs (see
16297
16339
  * `BlockClassifier.mpuBoundsRaps`). Gap decisions drop MPU evidence once
16298
- * it does not. A stream with no classifier yet has shown nothing against it.
16340
+ * it does not. A stream with no classifier yet has shown nothing against it,
16341
+ * and neither has a stream without catalog timing until it releases two
16342
+ * RAPs inside one MPU.
16299
16343
  */
16300
16344
  mpuBoundsRapsFor(packetId, catalogTrackName) {
16301
- if (!this.#hasCatalogTimingForPacket(packetId)) return false;
16302
- return this.#classifiers.get(this.#streamKey(packetId, catalogTrackName))?.mpuBoundsRaps ?? true;
16345
+ const streamKey = this.#streamKey(packetId, catalogTrackName);
16346
+ if (!this.#hasCatalogTimingForPacket(packetId)) return this.#noSpanRaps.get(streamKey)?.inside !== true;
16347
+ return this.#classifiers.get(streamKey)?.mpuBoundsRaps ?? true;
16303
16348
  }
16304
16349
  /** A block's class now, or frozen at its resolution. */
16305
16350
  blockClassFor(sbn, packetId, catalogTrackName) {
@@ -16325,7 +16370,14 @@ var FECManager = class _FECManager {
16325
16370
  * invariant (consecutive MPUs exactly one keyframe interval apart).
16326
16371
  */
16327
16372
  noteReleasedRap(packetId, catalogTrackName, mpu, presentationTime, mediaTimescale) {
16328
- if (!this.#trackConfigs.has(packetId) || !this.#hasCatalogTimingForPacket(packetId)) return;
16373
+ if (!this.#trackConfigs.has(packetId)) return;
16374
+ if (!this.#hasCatalogTimingForPacket(packetId)) {
16375
+ const streamKey = this.#streamKey(packetId, catalogTrackName);
16376
+ const last = this.#noSpanRaps.get(streamKey);
16377
+ const inside = last?.inside === true || last?.mpu === mpu && last.presentationTime !== presentationTime;
16378
+ this.#noSpanRaps.set(streamKey, { mpu, presentationTime, inside });
16379
+ return;
16380
+ }
16329
16381
  this.#applyClassifierChanges(
16330
16382
  packetId,
16331
16383
  catalogTrackName,
@@ -16943,8 +16995,72 @@ var FECManager = class _FECManager {
16943
16995
  this.#completeCleanBlock(blockKey, sbn, packetId, catalogTrackName);
16944
16996
  return null;
16945
16997
  }
16998
+ if (recovered === null) this.#shortenUnrecoverableWait(blockKey, sbn, packetId, catalogTrackName);
16946
16999
  return recovered;
16947
17000
  }
17001
+ /**
17002
+ * A first-arrival multicast source, held or placed. A datagram flow keeps
17003
+ * its sources in send order, so one that moves the stream's newest source
17004
+ * position past a block leaves the sources that block still misses
17005
+ * reordered behind it at best: it shortens the wait of every block it
17006
+ * passed that only a late source can complete. MoQ is left out: its streams
17007
+ * can overtake one another, so a MoQ source shows nothing about the sources
17008
+ * sent before it.
17009
+ */
17010
+ #noteMulticastSource(locator, ssId, packetId, catalogTrackName) {
17011
+ const previous = locator.newestSource;
17012
+ if (!locator.noteSource(ssId) || previous === void 0) return;
17013
+ for (const block of locator.blocksBetween(previous - 1 >>> 0, ssId)) {
17014
+ if (block.span || !serial32Precedes(serial32Add(block.start, block.length - 1), ssId)) continue;
17015
+ this.#shortenUnrecoverableWait(
17016
+ this.#blockKey(block.start, packetId, catalogTrackName),
17017
+ block.start,
17018
+ packetId,
17019
+ catalogTrackName
17020
+ );
17021
+ }
17022
+ }
17023
+ /**
17024
+ * The wait of a block that only a late source can complete (BLO-52085): a
17025
+ * repair stated it, its received sources plus every symbol its repair group
17026
+ * carries (RSB_length) are short of K' (the repair scheduler's
17027
+ * group-cannot-complete), and a later source of the stream has arrived, so
17028
+ * a source it misses is lost or reordered behind that one. The span, which
17029
+ * covers repair symbols paced after its sources, buys it nothing: it waits
17030
+ * the late-symbol allowance alone, the multicast wait less the span (the
17031
+ * catalog tolerance or the measured delivery gap). Undefined for any other
17032
+ * block.
17033
+ */
17034
+ #lateSourceWaitMs(key, sbn, packetId, catalogTrackName, config) {
17035
+ const authoredLength = this.#blockAuthoredLengths.get(key);
17036
+ const rsbLength = this.#blockRsbLengths.get(key);
17037
+ if (authoredLength === void 0 || rsbLength === void 0) return void 0;
17038
+ if ((this.#blockSourceCounts.get(key) ?? 0) + rsbLength >= authoredLength) return void 0;
17039
+ const newestSource = this.#isoLocators.get(this.#streamKey(packetId, catalogTrackName))?.newestSource;
17040
+ if (newestSource === void 0 || !serial32Precedes(serial32Add(sbn, authoredLength - 1), newestSource)) {
17041
+ return void 0;
17042
+ }
17043
+ return this.#multicastWaitMs(config) - config.interleaveDepthMs;
17044
+ }
17045
+ /**
17046
+ * Bring an open block's wait in to its late-source wait, when it has one.
17047
+ * Never later than it is armed: only a source can complete such a block,
17048
+ * so a repair of its group (paced across the span) must not keep it open.
17049
+ */
17050
+ #shortenUnrecoverableWait(key, sbn, packetId, catalogTrackName) {
17051
+ if (!this.#activeBlocks.has(key)) return;
17052
+ const config = this.#trackConfigs.get(packetId);
17053
+ if (config === void 0) return;
17054
+ const waitMs = this.#lateSourceWaitMs(key, sbn, packetId, catalogTrackName, config);
17055
+ if (waitMs === void 0) return;
17056
+ const armedAtMs = this.#blockWaitDeadlinesMs.get(key);
17057
+ this.#armBlockTimeout(
17058
+ sbn,
17059
+ packetId,
17060
+ catalogTrackName,
17061
+ armedAtMs === void 0 ? waitMs : Math.min(waitMs, armedAtMs - this.#receiptNow())
17062
+ );
17063
+ }
16948
17064
  /**
16949
17065
  * Record a block's authored SSB_length from a repair symbol. Returns false,
16950
17066
  * leaving the block unchanged, when the symbol contradicts the block: a
@@ -17173,7 +17289,16 @@ var FECManager = class _FECManager {
17173
17289
  this.#repairDeadlineMs(trackConfig)
17174
17290
  );
17175
17291
  }
17176
- this.#armBlockTimeout(sbn, packetId, catalogTrackName, timeoutMs);
17292
+ if (!sourceProgress && this.#lateSourceWaitMs(key, sbn, packetId, catalogTrackName, trackConfig) !== void 0) {
17293
+ this.#shortenUnrecoverableWait(key, sbn, packetId, catalogTrackName);
17294
+ return;
17295
+ }
17296
+ this.#armBlockTimeout(
17297
+ sbn,
17298
+ packetId,
17299
+ catalogTrackName,
17300
+ this.#lateSourceWaitMs(key, sbn, packetId, catalogTrackName, trackConfig) ?? timeoutMs
17301
+ );
17177
17302
  }
17178
17303
  #failBlock(sbn, packetId, catalogTrackName) {
17179
17304
  const key = this.#blockKey(sbn, packetId, catalogTrackName);
@@ -17340,6 +17465,7 @@ var FECManager = class _FECManager {
17340
17465
  this.#blockAnchorsMs.clear();
17341
17466
  this.#blockCatalogTrackNames.clear();
17342
17467
  this.#classifiers.clear();
17468
+ this.#noSpanRaps.clear();
17343
17469
  this.#streamGeometries.clear();
17344
17470
  this.#failedBlocks.clear();
17345
17471
  this.#resolvedBlockClasses.clear();
@@ -17870,9 +17996,21 @@ var IsoBmffTimingResolver = class {
17870
17996
  }
17871
17997
  #track = null;
17872
17998
  #fragments = /* @__PURE__ */ new Map();
17999
+ /** First sample of each pending fragment, kept in `#delivered` with its sample count. */
18000
+ #firstSamples = /* @__PURE__ */ new Map();
18001
+ /**
18002
+ * First sample and sample count of the newest fragments whose every slot was
18003
+ * cleared. Abandonment counts: `releaseSample` clears a slot too, which
18004
+ * matches the combiner, since it retires an abandoned sample as well.
18005
+ */
18006
+ #delivered = /* @__PURE__ */ new Map();
17873
18007
  #pendingSamples = 0;
17874
18008
  #evictedSamples = 0;
17875
18009
  #lastMovieFragmentRejection = null;
18010
+ /** True once an FT=0 has bound the track that FT=1 timing is read against. */
18011
+ get initialized() {
18012
+ return this.#track !== null;
18013
+ }
17876
18014
  get pendingSampleCount() {
17877
18015
  return this.#pendingSamples;
17878
18016
  }
@@ -17896,7 +18034,14 @@ var IsoBmffTimingResolver = class {
17896
18034
  this.#track = timing;
17897
18035
  return true;
17898
18036
  }
17899
- addMovieFragment(mpuSequenceNumber2, data, additionalSampleLimit = this.maxPendingSamples) {
18037
+ /**
18038
+ * `recovered` marks an FEC-recovered copy. A recovered block hands back
18039
+ * every FT=1 it holds, the received ones included, so a recovered copy of a
18040
+ * fragment whose samples are all done is a duplicate: admitting it again
18041
+ * would charge stale timing and could displace the live MPU's. A source
18042
+ * copy is still admitted again (a rendition switch re-arms timing that way).
18043
+ */
18044
+ addMovieFragment(mpuSequenceNumber2, data, additionalSampleLimit = this.maxPendingSamples, recovered = false) {
17900
18045
  this.#lastMovieFragmentRejection = null;
17901
18046
  if (!Number.isInteger(additionalSampleLimit) || additionalSampleLimit < 0) {
17902
18047
  this.#lastMovieFragmentRejection = "invalid";
@@ -17922,6 +18067,8 @@ var IsoBmffTimingResolver = class {
17922
18067
  (sample, index) => sample !== null && sameSample(sample, parsed.samples[index])
17923
18068
  );
17924
18069
  }
18070
+ const delivered = recovered ? this.#delivered.get(key) : void 0;
18071
+ if (delivered && delivered.sampleCount === parsed.samples.length && sameSample(delivered.first, parsed.samples[0])) return true;
17925
18072
  let freed = 0;
17926
18073
  while (this.#fragments.size > 0 && (this.#pendingSamples + parsed.samples.length > this.maxPendingSamples || parsed.samples.length - freed > additionalSampleLimit)) {
17927
18074
  freed += this.#evictOldestFragment();
@@ -17935,6 +18082,7 @@ var IsoBmffTimingResolver = class {
17935
18082
  return false;
17936
18083
  }
17937
18084
  this.#fragments.set(key, [...parsed.samples]);
18085
+ this.#firstSamples.set(key, parsed.samples[0]);
17938
18086
  this.#pendingSamples += parsed.samples.length;
17939
18087
  return true;
17940
18088
  }
@@ -17992,6 +18140,7 @@ var IsoBmffTimingResolver = class {
17992
18140
  let live = 0;
17993
18141
  for (const sample of samples) if (sample !== null) live++;
17994
18142
  this.#fragments.delete(oldest);
18143
+ this.#firstSamples.delete(oldest);
17995
18144
  this.#pendingSamples -= live;
17996
18145
  this.#evictedSamples += live;
17997
18146
  return live;
@@ -18007,12 +18156,23 @@ var IsoBmffTimingResolver = class {
18007
18156
  if (!samples || !samples[index]) return false;
18008
18157
  samples[index] = null;
18009
18158
  this.#pendingSamples--;
18010
- if (samples.every((value) => value === null)) this.#fragments.delete(key);
18159
+ if (samples.every((value) => value === null)) {
18160
+ this.#fragments.delete(key);
18161
+ const first = this.#firstSamples.get(key);
18162
+ this.#firstSamples.delete(key);
18163
+ this.#delivered.delete(key);
18164
+ this.#delivered.set(key, { first, sampleCount: samples.length });
18165
+ if (this.#delivered.size > this.maxPendingSamples) {
18166
+ this.#delivered.delete(this.#delivered.keys().next().value);
18167
+ }
18168
+ }
18011
18169
  return true;
18012
18170
  }
18013
18171
  reset() {
18014
18172
  this.#track = null;
18015
18173
  this.#fragments.clear();
18174
+ this.#firstSamples.clear();
18175
+ this.#delivered.clear();
18016
18176
  this.#pendingSamples = 0;
18017
18177
  }
18018
18178
  };
@@ -18494,6 +18654,12 @@ var MmtpContainerParser = class _MmtpContainerParser {
18494
18654
  this.#reclaimedTimingSamples = 0;
18495
18655
  this.#evictedTimingSamples = 0;
18496
18656
  this.#rejectedMovieFragments = 0;
18657
+ this.#recoveredHeadsAwaitingTiming = {
18658
+ held: 0,
18659
+ expired: 0,
18660
+ discarded: 0,
18661
+ superseded: 0
18662
+ };
18497
18663
  this.#sequencers = /* @__PURE__ */ new Map();
18498
18664
  this.#fecGate = null;
18499
18665
  this.#fecSequencer = null;
@@ -18602,6 +18768,7 @@ var MmtpContainerParser = class _MmtpContainerParser {
18602
18768
  #reclaimedTimingSamples;
18603
18769
  #evictedTimingSamples;
18604
18770
  #rejectedMovieFragments;
18771
+ #recoveredHeadsAwaitingTiming;
18605
18772
  #sequencers;
18606
18773
  #fecGate;
18607
18774
  #fecSequencer;
@@ -18722,6 +18889,14 @@ var MmtpContainerParser = class _MmtpContainerParser {
18722
18889
  rejectedMovieFragments: this.#rejectedMovieFragments
18723
18890
  };
18724
18891
  }
18892
+ /** Recovered heads parked for FT=1 timing (BLO-53369). Survives dispose(). */
18893
+ get recoveredHeadsAwaitingTiming() {
18894
+ let pending = 0;
18895
+ for (const { awaitingTiming } of this.#timingStreams.values()) {
18896
+ for (const head of awaitingTiming) if (head.options?.recovered) pending++;
18897
+ }
18898
+ return { ...this.#recoveredHeadsAwaitingTiming, pending };
18899
+ }
18725
18900
  get reorderStats() {
18726
18901
  let bufferedFrames = this.#fecSequencer?.buffered ?? 0;
18727
18902
  for (const { sequencer } of this.#sequencers.values()) bufferedFrames += sequencer.buffered;
@@ -18850,10 +19025,10 @@ var MmtpContainerParser = class _MmtpContainerParser {
18850
19025
  get reassemblerDataUnitsReturned() {
18851
19026
  return this.#reassemblerDataUnitsReturned;
18852
19027
  }
18853
- #countOutcome(token) {
18854
- const count = (this.#feedOutcomes.get(token) ?? 0) + 1;
18855
- this.#feedOutcomes.set(token, count);
18856
- this.#lastOutcome = token;
19028
+ #countOutcome(token2) {
19029
+ const count = (this.#feedOutcomes.get(token2) ?? 0) + 1;
19030
+ this.#feedOutcomes.set(token2, count);
19031
+ this.#lastOutcome = token2;
18857
19032
  this.#lastOutcomeCount = count;
18858
19033
  }
18859
19034
  #countDeferredOutcome(reason) {
@@ -18895,7 +19070,7 @@ var MmtpContainerParser = class _MmtpContainerParser {
18895
19070
  const now = Date.now();
18896
19071
  for (const { awaitingTiming } of this.#timingStreams.values()) {
18897
19072
  while (awaitingTiming.length > 0 && now - awaitingTiming[0].arrivedAt > timeoutMs) {
18898
- awaitingTiming.shift();
19073
+ this.#leaveAwaitingTiming(awaitingTiming.shift(), "expired");
18899
19074
  }
18900
19075
  }
18901
19076
  }
@@ -19013,11 +19188,16 @@ var MmtpContainerParser = class _MmtpContainerParser {
19013
19188
  if (mpu.fragmentationIndicator !== 0 || mpu.fragmentCounter !== 0)
19014
19189
  return this.#drop("fragment-unexpected-fragmentation");
19015
19190
  const resolver2 = this.#timingResolver(streamKey, info.packetId, false);
19016
- if (!resolver2) return this.#drop("fragment-no-resolver");
19191
+ if (!resolver2?.initialized) return this.#drop("fragment-no-resolver");
19017
19192
  const pendingBefore = resolver2.pendingSampleCount;
19018
19193
  const evictedBefore = resolver2.evictedSampleCount;
19019
19194
  const remaining = this.#maxPendingTimingSamples - this.#pendingTimingSamples;
19020
- const admitted = resolver2.addMovieFragment(mpu.mpuSequenceNumber, data, remaining);
19195
+ const admitted = resolver2.addMovieFragment(
19196
+ mpu.mpuSequenceNumber,
19197
+ data,
19198
+ remaining,
19199
+ options?.recovered === true
19200
+ );
19021
19201
  this.#pendingTimingSamples += resolver2.pendingSampleCount - pendingBefore;
19022
19202
  this.#evictedTimingSamples += resolver2.evictedSampleCount - evictedBefore;
19023
19203
  if (!admitted) {
@@ -19037,7 +19217,7 @@ var MmtpContainerParser = class _MmtpContainerParser {
19037
19217
  };
19038
19218
  }
19039
19219
  if (fragmentType !== 2) return this.#drop("unknown-fragment-type");
19040
- const resolver = this.#timingResolver(streamKey, info.packetId, false);
19220
+ const resolver = this.#timingResolver(streamKey, info.packetId, true);
19041
19221
  if (!resolver) return this.#drop("mfu-no-resolver");
19042
19222
  if (!Number.isSafeInteger(data.byteLength) || data.byteLength <= 0)
19043
19223
  return this.#drop("mfu-empty-payload");
@@ -19058,6 +19238,7 @@ var MmtpContainerParser = class _MmtpContainerParser {
19058
19238
  mpuSequenceNumber: mpu.mpuSequenceNumber,
19059
19239
  movieFragmentSequenceNumber: mfuDuHeader.movieFragmentSequenceNumber,
19060
19240
  sampleNumber: mfuDuHeader.sampleNumber,
19241
+ sequenceNumber: info.sequenceNumber,
19061
19242
  arrivedAt: Date.now()
19062
19243
  });
19063
19244
  return this.#drop("mfu-sample-timing-missing");
@@ -19381,6 +19562,10 @@ var MmtpContainerParser = class _MmtpContainerParser {
19381
19562
  }
19382
19563
  this.#emitFrame(data, meta);
19383
19564
  }
19565
+ /** Whether FT=1 metadata already names this sample, without claiming it. */
19566
+ hasAuthoredTimingFor(packetId, mpuSequenceNumber2, movieFragmentSequenceNumber, sampleNumber, catalogTrackName) {
19567
+ return this.#timingResolver(this.#streamKey(packetId, catalogTrackName), packetId, false)?.sampleTiming(mpuSequenceNumber2, movieFragmentSequenceNumber, sampleNumber) != null;
19568
+ }
19384
19569
  /**
19385
19570
  * Claim and return the authored timing for a recovered sample. Recovery does
19386
19571
  * not pass through the ordinary `feed()` completion path, so it must retire
@@ -19442,6 +19627,9 @@ var MmtpContainerParser = class _MmtpContainerParser {
19442
19627
  for (const { sequencer } of this.#sequencers.values()) sequencer.discard();
19443
19628
  this.#sequencers.clear();
19444
19629
  this.#fecSequencer?.discard();
19630
+ for (const { awaitingTiming } of this.#timingStreams.values()) {
19631
+ for (const head of awaitingTiming) this.#leaveAwaitingTiming(head, "discarded");
19632
+ }
19445
19633
  this.#timingStreams.clear();
19446
19634
  this.#pendingTimingSamples = 0;
19447
19635
  this.#reliableMoqStartupGate.clear();
@@ -19460,10 +19648,23 @@ var MmtpContainerParser = class _MmtpContainerParser {
19460
19648
  return resolver;
19461
19649
  }
19462
19650
  #awaitTiming(streamKey, head) {
19463
- const awaitingTiming = this.#timingStreams.get(streamKey)?.awaitingTiming;
19464
- if (!awaitingTiming) return;
19651
+ const state = this.#timingStreams.get(streamKey);
19652
+ if (!state) return;
19653
+ if (!head.options?.recovered) {
19654
+ const kept = state.awaitingTiming.filter((queued) => !queued.options?.recovered || queued.sequenceNumber !== head.sequenceNumber);
19655
+ this.#recoveredHeadsAwaitingTiming.superseded += state.awaitingTiming.length - kept.length;
19656
+ state.awaitingTiming = kept;
19657
+ }
19658
+ const awaitingTiming = state.awaitingTiming;
19465
19659
  awaitingTiming.push(head);
19466
- if (awaitingTiming.length > MAX_AWAITING_TIMING_HEADS) awaitingTiming.shift();
19660
+ if (head.options?.recovered) this.#recoveredHeadsAwaitingTiming.held++;
19661
+ if (awaitingTiming.length > MAX_AWAITING_TIMING_HEADS) {
19662
+ this.#leaveAwaitingTiming(awaitingTiming.shift(), "expired");
19663
+ }
19664
+ }
19665
+ /** Count a recovered head leaving the queue other than by replay or its source copy. */
19666
+ #leaveAwaitingTiming(head, outcome) {
19667
+ if (head.options?.recovered) this.#recoveredHeadsAwaitingTiming[outcome]++;
19467
19668
  }
19468
19669
  /**
19469
19670
  * Feed again the heads whose timing the FT=1 just admitted declares. That
@@ -19491,6 +19692,7 @@ var MmtpContainerParser = class _MmtpContainerParser {
19491
19692
  const state = this.#timingStreams.get(streamKey);
19492
19693
  if (!state) return;
19493
19694
  this.#timingStreams.delete(streamKey);
19695
+ for (const head of state.awaitingTiming) this.#leaveAwaitingTiming(head, "discarded");
19494
19696
  this.#pendingTimingSamples -= state.resolver.pendingSampleCount;
19495
19697
  if (this.#pendingTimingSamples < 0) {
19496
19698
  throw new Error("[MmtpContainerParser] pending timing sample accounting underflow");
@@ -23106,7 +23308,7 @@ var MmtpRouter = class {
23106
23308
  const cb = this.#getFrameCallback(packetId);
23107
23309
  if (isVideo && !assembler) {
23108
23310
  if (frameMeta.recovered) {
23109
- this.#routeDirectRecoveredVideo(packetId, data, frameMeta, cb);
23311
+ this.#routeDirectRecoveredVideo(packetId, data, frameMeta, cb, meta.fecSbn);
23110
23312
  } else {
23111
23313
  this.#routeDirectSourceVideo(packetId, data, frameMeta, cb);
23112
23314
  }
@@ -23496,6 +23698,10 @@ var MmtpRouter = class {
23496
23698
  get keyframeOverlayStats() {
23497
23699
  return { ...this.#overlayStats };
23498
23700
  }
23701
+ /** Recovered FT=2 heads held for their FT=1, and how each left the wait (BLO-53369). */
23702
+ get recoveredHeadsAwaitingTiming() {
23703
+ return this.#parser.recoveredHeadsAwaitingTiming;
23704
+ }
23499
23705
  #isOverlayRepair(repairPacketId, repairTrackName) {
23500
23706
  return this.#overlayLegs.has(repairPacketId) || repairTrackName !== void 0 && this.#overlayRepairNames.has(repairTrackName);
23501
23707
  }
@@ -25650,8 +25856,16 @@ var MmtpRouter = class {
25650
25856
  this.#drainDirectRecoveredVideo(streamKey, packetId, (frame) => frame.timestamp <= meta.timestamp, cb);
25651
25857
  this.#emitDirectVideo(packetId, data, meta, cb);
25652
25858
  }
25653
- #routeDirectRecoveredVideo(packetId, data, meta, cb) {
25859
+ #routeDirectRecoveredVideo(packetId, data, meta, cb, fecSbn) {
25654
25860
  const streamKey = this.#mediaStreamKey(packetId, meta.catalogTrackName);
25861
+ if (this.#isFecTrack(packetId)) {
25862
+ const high = this.#deliveredVideoHighWaterByStream.get(streamKey);
25863
+ if (fecSbn !== void 0 || high !== void 0 && authoredAdjacent(high, meta)) {
25864
+ this.#drainDirectRecoveredVideo(streamKey, packetId, (frame) => frame.timestamp <= meta.timestamp, cb);
25865
+ this.#emitDirectVideo(packetId, data, meta, cb);
25866
+ return;
25867
+ }
25868
+ }
25655
25869
  this.#enqueueDirectRecoveredVideo(packetId, data, meta);
25656
25870
  const lastSourceTs = this.#lastDirectSourceVideoTsByStream.get(streamKey);
25657
25871
  this.#drainDirectRecoveredVideo(
@@ -25905,6 +26119,29 @@ var MmtpRouter = class {
25905
26119
  }
25906
26120
  );
25907
26121
  }
26122
+ const untimedHead = (sym) => sym.fragmentType === 2 && sym.fragment.fragmentationIndicator === 1 && sym.fragment.movieFragmentSequenceNumber !== void 0 && sym.fragment.sampleNumber !== void 0 && !this.#parser.hasAuthoredTimingFor(
26123
+ ownerPacketId,
26124
+ sym.fragment.mpuSequenceNumber,
26125
+ sym.fragment.movieFragmentSequenceNumber,
26126
+ sym.fragment.sampleNumber,
26127
+ catalogTrackName
26128
+ );
26129
+ for (const sym of result.symbols) {
26130
+ if (!untimedHead(sym) || this.#sourceSeqReceived(ownerPacketId, sym.packetSequenceNumber, catalogTrackName)) continue;
26131
+ this.#feedViaParser(
26132
+ sym.rawSymbol,
26133
+ { packetId: ownerPacketId, sequenceNumber: sym.packetSequenceNumber },
26134
+ void 0,
26135
+ {
26136
+ skipFecPayloadId: true,
26137
+ recovered: true,
26138
+ deliveryProvenance,
26139
+ ...fecSbnOf(sym.index),
26140
+ ...catalogTrackName === void 0 ? {} : { catalogTrackName }
26141
+ }
26142
+ );
26143
+ }
26144
+ result = { ...result, symbols: result.symbols.filter((sym) => !untimedHead(sym)) };
25908
26145
  const gated = this.#isFecTrack(ownerPacketId);
25909
26146
  const assembler = this.#mseAssemblerFor(ownerPacketId, catalogTrackName);
25910
26147
  if (assembler) {
@@ -29666,7 +29903,7 @@ async function setupFecWorkerForTrack(opts) {
29666
29903
  blockProfile,
29667
29904
  k: opts.fecK,
29668
29905
  // P as this receiver joined it, not the catalog total: the worker snapshot
29669
- // and CMCD `x-bc-fec-p` are read from this. Identity on a single layer.
29906
+ // and CMCD `com.blockcast-fp` are read from this. Identity on a single layer.
29670
29907
  repairCount: opts.joinedRepairCount ?? opts.fecRepairCount,
29671
29908
  symbolSize: opts.fecSymbolSize,
29672
29909
  interleaveDepth: interleaveDepthBlocks,
@@ -30208,24 +30445,24 @@ var MediaRouter = class {
30208
30445
  if (!this.#multicastVideoKeyed) return;
30209
30446
  if (this.#requireAudio && !this.#multicastAudioReceived) return;
30210
30447
  if (this.#requireAudio && !this.#moqAudioWarmed) return;
30211
- const token = ++this.#sourceSwitchToken;
30448
+ const token2 = ++this.#sourceSwitchToken;
30212
30449
  const maybeReady = this.#onSourceSwitch?.("multicast");
30213
30450
  if (!maybeReady || typeof maybeReady.then !== "function") {
30214
- this.#commitMulticastActivation(token);
30451
+ this.#commitMulticastActivation(token2);
30215
30452
  return;
30216
30453
  }
30217
30454
  const ready = Promise.resolve(maybeReady);
30218
- this.#pendingMulticastToken = token;
30455
+ this.#pendingMulticastToken = token2;
30219
30456
  this.#sourceSwitchReady = ready;
30220
30457
  void ready.then(
30221
- () => this.#commitMulticastActivation(token),
30222
- (err) => this.#rejectMulticastActivation(token, err)
30458
+ () => this.#commitMulticastActivation(token2),
30459
+ (err) => this.#rejectMulticastActivation(token2, err)
30223
30460
  ).finally(() => {
30224
30461
  if (this.#sourceSwitchReady === ready) this.#sourceSwitchReady = null;
30225
30462
  });
30226
30463
  }
30227
- #commitMulticastActivation(token) {
30228
- if (token !== this.#sourceSwitchToken) return;
30464
+ #commitMulticastActivation(token2) {
30465
+ if (token2 !== this.#sourceSwitchToken) return;
30229
30466
  this.#active = "multicast";
30230
30467
  this.#suppressed = true;
30231
30468
  this.#pendingMulticastToken = null;
@@ -30236,9 +30473,9 @@ var MediaRouter = class {
30236
30473
  if (video) this.#onVideoFrame?.(video.data, video.meta);
30237
30474
  if (audio) this.#onAudioFrame?.(audio.data, audio.meta);
30238
30475
  }
30239
- #rejectMulticastActivation(token, err) {
30240
- if (token !== this.#sourceSwitchToken) return;
30241
- if (this.#pendingMulticastToken === token) this.#pendingMulticastToken = null;
30476
+ #rejectMulticastActivation(token2, err) {
30477
+ if (token2 !== this.#sourceSwitchToken) return;
30478
+ if (this.#pendingMulticastToken === token2) this.#pendingMulticastToken = null;
30242
30479
  this.#pendingMulticastVideo = null;
30243
30480
  this.#pendingMulticastAudio = null;
30244
30481
  console.warn("[MediaRouter] onSourceSwitch(multicast) failed", err);
@@ -31207,63 +31444,65 @@ var FecStarvationDetector = class {
31207
31444
 
31208
31445
  // src/cmcd-producer.ts
31209
31446
  var PLAY_MODES = ["full", "base-only", "single-track"];
31210
- function sanitize(value) {
31211
- return value.replace(/[,\r\n]/g, "");
31212
- }
31213
- function buildFecParams(stats) {
31214
- const params = {};
31215
- const cfg = stats.config;
31216
- params["x-bc-fec-algo"] = cfg.algorithm;
31217
- params["x-bc-fec-k"] = cfg.k;
31218
- params["x-bc-fec-p"] = cfg.repairCount;
31219
- params["x-bc-fec-recovery"] = parseFloat((stats.recoveryRate / 100).toFixed(4));
31220
- return params;
31221
- }
31222
- function buildTransportParams(snap) {
31223
- if (!snap) return {};
31224
- const params = {};
31225
- params["x-bc-transport"] = snap.transport;
31226
- if (snap.multicastGroup != null) {
31227
- params["x-bc-multicast-group"] = snap.multicastGroup;
31228
- }
31229
- if (snap.multicastSource != null) {
31230
- params["x-bc-multicast-source"] = snap.multicastSource;
31231
- }
31232
- if (snap.amtRelay != null) {
31233
- params["x-bc-amt-relay"] = snap.amtRelay;
31234
- }
31235
- return params;
31236
- }
31237
- function formatQueryString(params) {
31238
- return Object.keys(params).sort().map((key) => {
31239
- const v = params[key];
31240
- if (typeof v === "string") return `${key}=${sanitize(v)}`;
31241
- return `${key}=${v}`;
31447
+ var DEFAULT_INTERVAL_MS = 3e4;
31448
+ var CUSTOM_PREFIX = "com.blockcast-";
31449
+ var MAX_CUSTOM_VALUE = 64;
31450
+ var TRANSPORT_TOKEN = {
31451
+ native: "ssm",
31452
+ amt: "amt",
31453
+ moq: "moq"
31454
+ };
31455
+ function sfString(value) {
31456
+ return `"${value.replace(/[^\x20-\x7e]/g, "").replace(/[\\"]/g, "\\$&")}"`;
31457
+ }
31458
+ function encodeCmcdRecord(record) {
31459
+ return Object.keys(record).sort().map((key) => {
31460
+ const value = record[key];
31461
+ if (typeof value === "number") return `${key}=${value}`;
31462
+ if (typeof value === "string") return `${key}=${sfString(value)}`;
31463
+ if (Array.isArray(value)) return `${key}=(${value.map(sfString).join(" ")})`;
31464
+ return `${key}=${value.token}`;
31242
31465
  }).join(",");
31243
31466
  }
31467
+ var token = (value) => ({ token: value });
31468
+ function putCustom(record, name, value) {
31469
+ if (value === void 0) return;
31470
+ if (typeof value === "number") value = String(value);
31471
+ if (typeof value === "string" && value.length > MAX_CUSTOM_VALUE) return;
31472
+ record[CUSTOM_PREFIX + name] = value;
31473
+ }
31244
31474
  var CmcdProducer = class {
31245
- #onCmcdParams;
31246
31475
  #onCmcdPost;
31476
+ #intervalMs;
31477
+ #sessionId;
31478
+ #now;
31479
+ #sn = 0;
31480
+ #lastHeartbeat = null;
31247
31481
  #transport;
31248
31482
  #lastStats;
31249
- // Phase 14 state — Plan 03 Task 2 (D-12, D-15).
31250
31483
  #playMode = null;
31251
31484
  #deltaFailRate = null;
31252
31485
  #switchMiscount = 0;
31253
31486
  #manifestStats = null;
31254
31487
  #fecFailClosed = null;
31488
+ /** Video-block resolutions from the repair lifecycle; null until the player feeds one. */
31489
+ #lifecycle = null;
31255
31490
  constructor(options) {
31256
- this.#onCmcdParams = options.onCmcdParams;
31257
31491
  this.#onCmcdPost = options.onCmcdPost;
31492
+ this.#intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
31493
+ if (!Number.isFinite(this.#intervalMs) || this.#intervalMs <= 0) {
31494
+ throw new Error(`[CmcdProducer] invalid intervalMs "${String(options.intervalMs)}"; expected a positive number`);
31495
+ }
31496
+ this.#sessionId = options.sessionId ?? globalThis.crypto.randomUUID();
31497
+ this.#now = options.now ?? Date.now;
31258
31498
  }
31259
31499
  /** Update the stored transport state. Called by player wiring layer. */
31260
31500
  updateTransport(snapshot) {
31261
31501
  this.#transport = snapshot;
31262
31502
  }
31263
31503
  /**
31264
- * Set the current play-mode. Emitted as `x-bc-play-mode` on subsequent
31265
- * snapshots. Throws on any value outside {'full','base-only','single-track'}
31266
- * (CLAUDE.md: error on bad init).
31504
+ * Set the current play-mode, reported as `com.blockcast-pm`. Throws on any
31505
+ * value outside {'full','base-only','single-track'} (CLAUDE.md: error on bad init).
31267
31506
  */
31268
31507
  updatePlayMode(mode) {
31269
31508
  if (typeof mode !== "string" || !PLAY_MODES.includes(mode)) {
@@ -31274,9 +31513,8 @@ var CmcdProducer = class {
31274
31513
  this.#playMode = mode;
31275
31514
  }
31276
31515
  /**
31277
- * Set the delta-track FEC block-failure rate (0..1 inclusive). Emitted as
31278
- * `x-bc-delta-fail-rate` on snapshots when play-mode is 'full' or 'base-only'.
31279
- * Omitted on single-track streams.
31516
+ * Set the delta-track FEC block-failure rate (0..1 inclusive), reported as
31517
+ * `com.blockcast-dfr` when play-mode is 'full' or 'base-only'.
31280
31518
  */
31281
31519
  updateDeltaFailRate(rate) {
31282
31520
  if (typeof rate !== "number" || !Number.isFinite(rate) || rate < 0 || rate > 1) {
@@ -31286,112 +31524,139 @@ var CmcdProducer = class {
31286
31524
  }
31287
31525
  this.#deltaFailRate = rate;
31288
31526
  }
31289
- /**
31290
- * Bump the cumulative switching-set misalignment counter. Emitted as
31291
- * `x-bc-switch-miscount` on snapshots when the counter is > 0.
31292
- */
31527
+ /** Bump the cumulative switching-set misalignment counter (`com.blockcast-sm`). */
31293
31528
  incrementSwitchMiscount() {
31294
31529
  this.#switchMiscount += 1;
31295
31530
  }
31296
31531
  /**
31297
- * Record that FEC failed closed at runtime (fec-01 6.3.9). Every later
31298
- * snapshot carries `x-bc-fec-fail-closed` (the reason code) and
31299
- * `x-bc-fec-fail-layer` (the held layer i whose total B_i was undercut), so a
31300
- * stream playing without FEC is told apart from one with no loss.
31532
+ * Record that FEC failed closed at runtime (fec-01 6.3.9). Reported at once
31533
+ * as `e=e` with the reason code in `ec`, and on every later counter report
31534
+ * as `com.blockcast-ffc` / `com.blockcast-ffl` (the held layer whose total
31535
+ * B_i was undercut), so a stream playing without FEC is told apart from one
31536
+ * with no loss.
31301
31537
  */
31302
31538
  updateFecFailClosed(reason) {
31539
+ const changed = this.#fecFailClosed?.code !== reason.code || this.#fecFailClosed?.layer !== reason.layer;
31303
31540
  this.#fecFailClosed = reason;
31541
+ if (changed) this.#post([{ ...this.#base("e"), ec: [reason.code] }]);
31304
31542
  }
31305
31543
  updateManifestVerification(stats) {
31306
31544
  this.#manifestStats = stats;
31307
31545
  }
31308
31546
  /**
31309
- * One-shot emit of `x-bc-switch-drift-us` for a single misalignment event.
31310
- * Does NOT mutate snapshot state — subsequent onSnapshot() calls will not
31311
- * include the drift key until the next emitSwitchDrift(). Mirrors the
31312
- * existing onTransition() pattern.
31547
+ * Count a video block's resolution from the repair lifecycle: recovered in
31548
+ * band (`com.blockcast-fbr`), recovered by repair FETCH (`-fbq`), or failed
31549
+ * as a keyframe-class block (`-fbfk`). Audio blocks are not counted, matching
31550
+ * the video-track `-fbf`. Until the player feeds this, the three are absent.
31313
31551
  */
31552
+ onRepairLifecycle(event) {
31553
+ this.#lifecycle ??= { inband: 0, fetch: 0, failedKeyframe: 0 };
31554
+ if (event.type === "recovered" && event.blockClass !== "audio") {
31555
+ if (event.via === "inband") this.#lifecycle.inband++;
31556
+ else this.#lifecycle.fetch++;
31557
+ } else if (event.type === "failed" && event.blockClass === "keyframe") {
31558
+ this.#lifecycle.failedKeyframe++;
31559
+ }
31560
+ }
31561
+ /** One-shot custom event `com.blockcast-switch-drift` for a single misalignment. */
31314
31562
  emitSwitchDrift(driftUs) {
31315
31563
  if (typeof driftUs !== "number" || !Number.isFinite(driftUs) || driftUs < 0) {
31316
31564
  throw new Error(
31317
31565
  `[CmcdProducer] invalid drift-us "${String(driftUs)}"; expected a non-negative finite number`
31318
31566
  );
31319
31567
  }
31320
- const params = {};
31321
- if (this.#lastStats) Object.assign(params, buildFecParams(this.#lastStats));
31322
- Object.assign(params, buildTransportParams(this.#transport));
31323
- if (this.#switchMiscount > 0) params["x-bc-switch-miscount"] = this.#switchMiscount;
31324
- params["x-bc-switch-drift-us"] = driftUs;
31325
- this.#emit(params);
31568
+ const record = { ...this.#base("ce"), cen: CUSTOM_PREFIX + "switch-drift" };
31569
+ putCustom(record, "sdu", Math.round(driftUs));
31570
+ this.#post([record]);
31326
31571
  }
31327
- /**
31328
- * Process a FecTrackStats snapshot. Finds the video track, builds
31329
- * x-bc-* vendor keys, and fires both delivery callbacks.
31330
- */
31572
+ /** Take a FecTrackStats snapshot (first video track) and send the heartbeat when due. */
31331
31573
  onSnapshot(data) {
31332
31574
  const videoTrack = data.tracks.find((t) => t.config.trackType === "video");
31333
31575
  if (!videoTrack) return;
31334
31576
  this.#lastStats = videoTrack;
31335
- const params = {
31336
- ...buildFecParams(videoTrack),
31337
- ...buildTransportParams(this.#transport)
31338
- };
31339
- if (this.#playMode !== null) {
31340
- params["x-bc-play-mode"] = this.#playMode;
31341
- }
31342
- if (this.#fecFailClosed) {
31343
- params["x-bc-fec-fail-closed"] = this.#fecFailClosed.code;
31344
- params["x-bc-fec-fail-layer"] = this.#fecFailClosed.layer;
31345
- }
31346
- if (this.#playMode !== null && this.#playMode !== "single-track" && this.#deltaFailRate !== null) {
31347
- params["x-bc-delta-fail-rate"] = parseFloat(this.#deltaFailRate.toFixed(4));
31348
- }
31349
- if (this.#switchMiscount > 0) {
31350
- params["x-bc-switch-miscount"] = this.#switchMiscount;
31351
- }
31352
- if (this.#manifestStats) {
31353
- params["x-bc-manifest-verified"] = this.#manifestStats.verified > 0;
31354
- params["x-bc-manifest-failures"] = Object.values(this.#manifestStats.failures).reduce((total, count) => total + count, 0);
31355
- }
31356
- this.#emit(params);
31577
+ this.#heartbeat();
31357
31578
  }
31358
31579
  /**
31359
- * Emit transport state without inventing FEC metrics. Cadence is owned by
31360
- * the caller because only the transport integration knows when state changed.
31580
+ * Take transport state for receivers that do not own FEC stats, and send the
31581
+ * heartbeat when due.
31361
31582
  */
31362
31583
  onTransportSnapshot(data) {
31363
31584
  if (!Number.isFinite(data.timestampMs) || data.timestampMs < 0) {
31364
31585
  throw new Error("[CmcdProducer] transport snapshot timestamp must be a non-negative finite number");
31365
31586
  }
31366
31587
  if (data.playMode !== void 0) this.updatePlayMode(data.playMode);
31367
- if (data.switchMiscount !== void 0 && (!Number.isSafeInteger(data.switchMiscount) || data.switchMiscount < 0)) {
31368
- throw new Error("[CmcdProducer] transport snapshot switch-miscount must be a non-negative safe integer");
31588
+ if (data.switchMiscount !== void 0) {
31589
+ if (!Number.isSafeInteger(data.switchMiscount) || data.switchMiscount < 0) {
31590
+ throw new Error("[CmcdProducer] transport snapshot switch-miscount must be a non-negative safe integer");
31591
+ }
31592
+ this.#switchMiscount = data.switchMiscount;
31369
31593
  }
31370
31594
  this.#transport = data.transport;
31371
- const params = buildTransportParams(data.transport);
31372
- if (this.#playMode !== null) params["x-bc-play-mode"] = this.#playMode;
31373
- const switchMiscount = data.switchMiscount ?? this.#switchMiscount;
31374
- if (switchMiscount > 0) params["x-bc-switch-miscount"] = switchMiscount;
31375
- this.#emit(params);
31595
+ this.#heartbeat();
31376
31596
  }
31377
- /**
31378
- * Signal a transport transition event. Fires an immediate out-of-band
31379
- * emit with x-bc-ct set. The subsequent onSnapshot() will NOT include
31380
- * x-bc-ct (one-shot).
31381
- */
31597
+ /** Report a transport transition at once, as custom event `com.blockcast-ct`. */
31382
31598
  onTransition(event) {
31383
- const params = {};
31384
- if (this.#lastStats) {
31385
- Object.assign(params, buildFecParams(this.#lastStats));
31599
+ const record = { ...this.#base("ce"), cen: CUSTOM_PREFIX + "ct" };
31600
+ putCustom(record, "ct", token(event));
31601
+ this.#putTransport(record);
31602
+ this.#post([record]);
31603
+ }
31604
+ #base(event) {
31605
+ return { e: token(event), sid: this.#sessionId, sn: this.#sn++, ts: Math.floor(this.#now()), v: 2 };
31606
+ }
31607
+ #heartbeat() {
31608
+ const now = this.#now();
31609
+ if (this.#lastHeartbeat !== null && now - this.#lastHeartbeat < this.#intervalMs) return;
31610
+ this.#lastHeartbeat = now;
31611
+ this.#post([this.#base("t"), this.#counters()]);
31612
+ }
31613
+ #putTransport(record) {
31614
+ const snap = this.#transport;
31615
+ if (!snap) return;
31616
+ putCustom(record, "tr", token(TRANSPORT_TOKEN[snap.transport]));
31617
+ if (snap.multicastGroup != null && snap.multicastSource != null) {
31618
+ putCustom(record, "sg", `${snap.multicastGroup}@${snap.multicastSource}`);
31619
+ }
31620
+ putCustom(record, "ar", snap.amtRelay);
31621
+ }
31622
+ /** The `e=ce` counter report. */
31623
+ #counters() {
31624
+ const record = { ...this.#base("ce"), cen: CUSTOM_PREFIX + "fec" };
31625
+ const stats = this.#lastStats;
31626
+ if (stats) {
31627
+ putCustom(record, "fa", stats.config.algorithm);
31628
+ putCustom(record, "fk", stats.config.k);
31629
+ putCustom(record, "fp", stats.config.repairCount);
31630
+ putCustom(record, "pr", stats.sourceSymbols);
31631
+ putCustom(record, "pl", stats.sourcePacketsMissing);
31632
+ putCustom(record, "pu", stats.sourcePacketsUnrecovered);
31633
+ putCustom(record, "fbf", stats.blocksFailed);
31634
+ }
31635
+ if (this.#lifecycle) {
31636
+ putCustom(record, "fbr", this.#lifecycle.inband);
31637
+ putCustom(record, "fbq", this.#lifecycle.fetch);
31638
+ putCustom(record, "fbfk", this.#lifecycle.failedKeyframe);
31639
+ }
31640
+ this.#putTransport(record);
31641
+ if (this.#playMode !== null) {
31642
+ putCustom(record, "pm", token(this.#playMode));
31643
+ if (this.#playMode !== "single-track" && this.#deltaFailRate !== null) {
31644
+ putCustom(record, "dfr", this.#deltaFailRate.toFixed(3));
31645
+ }
31646
+ }
31647
+ if (this.#fecFailClosed) {
31648
+ putCustom(record, "ffc", token(this.#fecFailClosed.code));
31649
+ putCustom(record, "ffl", this.#fecFailClosed.layer);
31386
31650
  }
31387
- Object.assign(params, buildTransportParams(this.#transport));
31388
- params["x-bc-ct"] = event;
31389
- this.#emit(params);
31651
+ if (this.#switchMiscount > 0) putCustom(record, "sm", this.#switchMiscount);
31652
+ if (this.#manifestStats) {
31653
+ putCustom(record, "mv", this.#manifestStats.verified);
31654
+ putCustom(record, "mf", Object.values(this.#manifestStats.failures).reduce((a, b) => a + b, 0));
31655
+ }
31656
+ return record;
31390
31657
  }
31391
- /** Fire both delivery callbacks with the given params. */
31392
- #emit(params) {
31393
- this.#onCmcdParams?.(params);
31394
- this.#onCmcdPost?.(formatQueryString(params));
31658
+ #post(records) {
31659
+ this.#onCmcdPost?.(records.map(encodeCmcdRecord).join("\n"));
31395
31660
  }
31396
31661
  };
31397
31662
 
@@ -31487,6 +31752,19 @@ function resolveRepairLayers(catalog, source) {
31487
31752
  totalBaseRepairSymbols: binding.totalBaseRepairSymbols
31488
31753
  };
31489
31754
  }
31755
+ function valueAt(catalog, pointer) {
31756
+ let node = catalog;
31757
+ for (const segment of pointer.split("/").slice(1)) {
31758
+ if (typeof node !== "object" || node === null) return void 0;
31759
+ node = node[segment.replace(/~1/g, "/").replace(/~0/g, "~")];
31760
+ }
31761
+ return node;
31762
+ }
31763
+ function catalogTrackDeliveryMode(catalog, source) {
31764
+ const binding = bindingOf(catalog, source);
31765
+ const entry = binding ? valueAt(catalog, binding.pointer) : void 0;
31766
+ return entry?.deliveryMode === "stream" ? "stream" : "datagram";
31767
+ }
31490
31768
  function moqtPriority2(value) {
31491
31769
  return Number.isSafeInteger(value) && value >= 0 && value <= 255 ? value : null;
31492
31770
  }
@@ -31530,6 +31808,7 @@ function fecParams(catalog, track, fps, sampleRate) {
31530
31808
  repairTrackName: base.repairTrack,
31531
31809
  repairLayers: repair.baseLayers,
31532
31810
  repairOverlays: repair.overlays,
31811
+ deliveryMode: catalogTrackDeliveryMode(catalog, track),
31533
31812
  ...mediaRate
31534
31813
  };
31535
31814
  }
@@ -32054,7 +32333,7 @@ var RepairLayerSubscriptions = class {
32054
32333
  * or interleaved sequences read as loss.
32055
32334
  */
32056
32335
  noteSourceSequence(seq) {
32057
- if (this.#disposed || this.#opts.delivery === "stream") return;
32336
+ if (this.#disposed || this.#delivery() === "stream") return;
32058
32337
  seq >>>= 0;
32059
32338
  if (this.#lastSeq === void 0) {
32060
32339
  this.#lastSeq = seq;
@@ -32087,12 +32366,12 @@ var RepairLayerSubscriptions = class {
32087
32366
  this.#expected = 0;
32088
32367
  this.#lost = 0;
32089
32368
  this.#wantFewer = 0;
32090
- this.#layerCount = Math.min(this.#layerCount, this.#layers().length);
32369
+ this.#layerCount = this.#delivery() === "stream" ? 0 : Math.min(this.#layerCount, this.#layers().length);
32091
32370
  this.#reconcile();
32092
32371
  }
32093
32372
  snapshot() {
32094
32373
  return {
32095
- delivery: this.#opts.delivery,
32374
+ delivery: this.#delivery(),
32096
32375
  layerCount: this.#layerCount,
32097
32376
  subscribedTracks: [...this.#subs.keys()],
32098
32377
  repairPackets: this.#repairPackets,
@@ -32106,6 +32385,9 @@ var RepairLayerSubscriptions = class {
32106
32385
  this.#closeAll();
32107
32386
  this.#disposed = true;
32108
32387
  }
32388
+ #delivery() {
32389
+ return this.#source.deliveryMode === "stream" ? "stream" : "datagram";
32390
+ }
32109
32391
  #layers() {
32110
32392
  return this.#source.repairLayers ?? [];
32111
32393
  }
@@ -32848,6 +33130,7 @@ export {
32848
33130
  catalogGroupDurationMs,
32849
33131
  catalogKeyframeCadenceMs,
32850
33132
  catalogRepairTarget,
33133
+ catalogTrackDeliveryMode,
32851
33134
  catalogTrackFecUnavailable,
32852
33135
  classifyH264Keyframe,
32853
33136
  classifyHevcKeyframe,
@@ -32868,6 +33151,7 @@ export {
32868
33151
  detectH264Keyframe,
32869
33152
  detectHevcKeyframe,
32870
33153
  driftCheck,
33154
+ encodeCmcdRecord,
32871
33155
  encodeDataSegment,
32872
33156
  encodeFecSourceInfo,
32873
33157
  encodeLocTimestamp,