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

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 (82) hide show
  1. package/README.md +29 -17
  2. package/dist/browser-runtime.js +1336 -302
  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 +70 -12
  16. package/dist/fec-manager.d.ts.map +1 -1
  17. package/dist/fec-manager.js +403 -58
  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 +4 -4
  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 +48 -13
  27. package/dist/iso-block-locator.d.ts.map +1 -1
  28. package/dist/iso-block-locator.js +97 -25
  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 +79 -10
  36. package/dist/mmtp-container-parser.d.ts.map +1 -1
  37. package/dist/mmtp-container-parser.js +207 -26
  38. package/dist/mmtp-container-parser.js.map +2 -2
  39. package/dist/mmtp-container-parser.no-modules.js +209 -29
  40. package/dist/mmtp-container-parser.no-modules.js.map +2 -2
  41. package/dist/mmtp-decode-pipeline.js +206 -26
  42. package/dist/mmtp-decode-pipeline.js.map +2 -2
  43. package/dist/mmtp-router.d.ts +34 -8
  44. package/dist/mmtp-router.d.ts.map +1 -1
  45. package/dist/mmtp-router.js +555 -79
  46. package/dist/mmtp-router.js.map +2 -2
  47. package/dist/multicast-manager.d.ts +13 -3
  48. package/dist/multicast-manager.d.ts.map +1 -1
  49. package/dist/multicast-manager.js +980 -146
  50. package/dist/multicast-manager.js.map +2 -2
  51. package/dist/multicast-transport.js +28 -18
  52. package/dist/multicast-transport.js.map +2 -2
  53. package/dist/no-modules.js +206 -26
  54. package/dist/no-modules.js.map +2 -2
  55. package/dist/repair-fetch.d.ts +36 -8
  56. package/dist/repair-fetch.d.ts.map +1 -1
  57. package/dist/repair-fetch.js +285 -26
  58. package/dist/repair-fetch.js.map +3 -3
  59. package/dist/repair-layer-subscriptions.d.ts +6 -2
  60. package/dist/repair-layer-subscriptions.d.ts.map +1 -1
  61. package/dist/repair-layer-subscriptions.js +6 -3
  62. package/dist/repair-layer-subscriptions.js.map +2 -2
  63. package/dist/track-session.d.ts +1 -1
  64. package/dist/track-session.d.ts.map +1 -1
  65. package/dist/track-session.js +1 -0
  66. package/dist/track-session.js.map +2 -2
  67. package/dist/wasm-mmtp-container-parser.js +206 -26
  68. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  69. package/package.json +8 -5
  70. package/src/catalog-fec-params.ts +36 -1
  71. package/src/cmcd-producer.ts +189 -179
  72. package/src/fec-manager.ts +433 -32
  73. package/src/fec-worker-setup.ts +2 -2
  74. package/src/index.ts +9 -2
  75. package/src/iso-block-locator.ts +133 -31
  76. package/src/iso-bmff-timing.ts +40 -1
  77. package/src/mmtp-container-parser.ts +306 -29
  78. package/src/mmtp-router.ts +548 -38
  79. package/src/multicast-manager.ts +44 -6
  80. package/src/repair-fetch.ts +363 -49
  81. package/src/repair-layer-subscriptions.ts +13 -5
  82. package/src/track-session.ts +4 -1
@@ -24,7 +24,7 @@ import {
24
24
  type MoqRepairFrame,
25
25
  } from "@blockcast/mmt-base";
26
26
  import { USER_AGENT_VERIFIED } from "@blockcast/mmt-transport";
27
- import type { FECManager, IsoHeldSource, WasmFecDecoder } from "./fec-manager.js";
27
+ import type { FECManager, IsoHeldSource, IsoSpanFailure, RebuiltSourceFeed, WasmFecDecoder } from "./fec-manager.js";
28
28
  import type { IsoPlacedSource, IsoRepairPlacement } from "./iso-block-locator.js";
29
29
  import { isMfuReassemblyError } from "./mfu-reassembler.js";
30
30
  import type {
@@ -48,10 +48,12 @@ import {
48
48
  } from "./cmaf-block-assembler.js";
49
49
  import {
50
50
  MmtpContainerParser,
51
+ authoredAdjacent,
51
52
  isMfuKeyframeMetadata,
52
53
  type FecReleaseGate,
53
54
  type FrameMeta as ParserFrameMeta,
54
55
  type ParsedMmtpPacket,
56
+ type MmtpRecoveredHeadTimingStats,
55
57
  } from "./mmtp-container-parser.js";
56
58
  import { type TrackRole } from "./carp-catalog.js";
57
59
  import {
@@ -212,6 +214,92 @@ export interface KeyframeOverlayStats {
212
214
  restarts: number;
213
215
  unsequencedDropped: number;
214
216
  protectedDropped: number;
217
+ /** Rebuilt packets fed to their base block's decoder (joint decoding). */
218
+ baseFed: number;
219
+ /** Rebuilt packets the base never took: no base SS_ID could be determined,
220
+ * or only the overlay's own evidence did, which anchors the release gate
221
+ * alone (BLO-52085). */
222
+ baseUndetermined: number;
223
+ }
224
+
225
+ /**
226
+ * The newest received overlay member of one MPU. `psnOffset` (overlay SS_ID
227
+ * minus wire packet_sequence_number) restores a rebuilt packet's wire
228
+ * sequence; the member's own SS_IDs anchor a rebuilt packet's base SS_ID
229
+ * (overlayBaseSsId).
230
+ */
231
+ interface OverlayMemberAnchor {
232
+ readonly psnOffset: number;
233
+ readonly overlaySsId: number;
234
+ /** The member's base SS_ID, from its Source FEC Payload ID. */
235
+ readonly baseSsId?: number;
236
+ }
237
+
238
+ /**
239
+ * The base SS_ID of a packet the overlay rebuilt at `overlaySsId`, which an
240
+ * overlay coordinate alone never yields (draft-ramadan-moq-mmt-02 7.1). Both
241
+ * SS_IDs advance by one per packet along a run of consecutive overlay
242
+ * members, so base minus overlay SS_ID is the anchor's along a run the wire
243
+ * proves to be all members: one over which the rebuilt packet's protected
244
+ * packet_sequence_number (`headerPsn`) advanced from the anchor's exactly as
245
+ * the overlay SS_ID did (`restoredPsn`), so no other packet lies inside it.
246
+ * A hop that re-stamped packet_sequence_number after protection fails the
247
+ * test. Undefined when it fails.
248
+ */
249
+ function overlayBaseSsId(
250
+ anchor: OverlayMemberAnchor,
251
+ overlaySsId: number,
252
+ restoredPsn: number,
253
+ headerPsn: number,
254
+ ): number | undefined {
255
+ if (anchor.baseSsId === undefined || headerPsn !== restoredPsn) return undefined;
256
+ return serial32Add(anchor.baseSsId, (overlaySsId - anchor.overlaySsId) | 0);
257
+ }
258
+
259
+ /**
260
+ * The base SS_ID of a rebuilt packet no received member of its MPU anchors,
261
+ * from its protected packet_sequence_number (`headerPsn`). SS_ID advances by
262
+ * one per FEC source packet (mmt-02 7.1) and psn by one per packet, so on a
263
+ * track whose every packet is an FEC source packet the sender keeps
264
+ * SS_ID - psn fixed. `offset` (SS_ID - psn of the stream's latest received
265
+ * source) is that sender value while evidence showed it (`unstampedOffset`):
266
+ * a base recovery's (#unstampedSsIdSeqOffsetByStream), or the overlay's
267
+ * alone (#overlayUnstampedSsIdSeqOffsetByStream). A hop that re-stamps psn
268
+ * moves the offset away from the one shown, which voids it, as does a packet
269
+ * with no SS_ID between the packet that showed it and the latest received
270
+ * source (the shown span). Undefined without both.
271
+ * ponytail: exact only if every packet from the rebuilt one to the latest
272
+ * received source is an FEC source packet, as FFmpeg's muxer sends a track
273
+ * it protects. fec-core 8.3 lets a publisher leave one unprotected
274
+ * (FEC Type 0): outside the shown span it goes unseen, and each one puts the
275
+ * rebuilt packet a base slot off. If such a publisher appears, have mmt-02
276
+ * require FEC Type 1 across a protected track, or refuse a headerPsn outside
277
+ * the shown span.
278
+ */
279
+ function unstampedBaseSsId(
280
+ offset: number | undefined,
281
+ unstampedOffset: number | undefined,
282
+ headerPsn: number,
283
+ ): number | undefined {
284
+ if (offset === undefined || offset !== unstampedOffset) return undefined;
285
+ return (headerPsn + offset) >>> 0;
286
+ }
287
+
288
+ /** Base SS_IDs of overlay-routed packets kept per stream (see OverlayToBase). */
289
+ const OVERLAY_ROUTED_LIMIT = 1024;
290
+ /** Rebuilt sources kept per stream for a base block no repair stated yet. */
291
+ const OVERLAY_PENDING_LIMIT = 256;
292
+
293
+ /**
294
+ * One stream's keyframe-overlay recoveries on their way into its base
295
+ * decoder (joint decoding, BLO-52085).
296
+ */
297
+ interface OverlayToBase {
298
+ /** Base SS_IDs of the packets an overlay recovery routed (as media, or held
299
+ * for their FT=1): a base recovery does not route them a second time. */
300
+ readonly routed: Set<number>;
301
+ /** Rebuilt sources, by base SS_ID, whose base block no repair stated yet. */
302
+ readonly pending: Map<number, Uint8Array>;
215
303
  }
216
304
 
217
305
  interface OverlayInstance {
@@ -223,14 +311,22 @@ interface OverlayInstance {
223
311
  readonly k: number;
224
312
  /** The newest overlay symbol seen: its overlay SS_ID and wire sequence. */
225
313
  last?: { ssId: number; psn: number };
226
- /** MPU sequence number → overlay SS_ID − wire packet_sequence_number. */
227
- readonly offsets: Map<number, number>;
314
+ /** MPU sequence number → its newest received member. */
315
+ readonly offsets: Map<number, OverlayMemberAnchor>;
228
316
  /** Overlay blocks a MoQ symbol fed (multicast-02 7.2.7). */
229
317
  readonly moqBlocks: Set<number>;
318
+ /** Overlay SS_ID → protected packet_sequence_number of each packet rebuilt
319
+ * with no base SS_ID, until its base copy lands (#feedOverlaySource). */
320
+ readonly rebuilt: Map<number, number>;
230
321
  }
231
322
 
232
323
  /** Offsets kept per overlay, one per MPU: a keyframe's fragments share one. */
233
324
  const OVERLAY_OFFSET_MPUS = 16;
325
+ /** Rebuilt packets kept per overlay whose base copy has not landed (OverlayInstance.rebuilt).
326
+ * ponytail: this cap is the only eviction for a base copy that never lands
327
+ * (oldest first; an overlay epoch reset clears all). Bound records by SS_ID
328
+ * age instead if stale ones ever crowd out live ones. */
329
+ const OVERLAY_REBUILT_LIMIT = 256;
234
330
  /** A MoQ-fed overlay block is forgotten once it starts this many K_overlay
235
331
  * behind the newest overlay SS_ID: the overlay locator, which keeps a fixed
236
332
  * few blocks (no catalog timing), has retired it long before, so it can no
@@ -511,6 +607,9 @@ export type RoutedPacketMeta = {
511
607
  mmtpTimestamp: bigint;
512
608
  /**
513
609
  * The MMTP header RAP flag: the packet starts a random access point. On a
610
+ * track under a keyframe overlay it is also set on the FT=0 and FT=1 data
611
+ * units that govern a covered keyframe (draft-ramadan-moq-mmt-02 7.4.2), so
612
+ * it can precede the keyframe's first MFU fragment. On a
514
613
  * `recovered` packet it is read from the FEC-reconstructed header, which is
515
614
  * byte-exact outside packet_sequence_number, so it is the flag the
516
615
  * publisher sent. The router's own gap decisions deliberately ignore it for
@@ -574,6 +673,7 @@ interface StreamLedgers {
574
673
  * the pair.
575
674
  */
576
675
  readonly duplicates: StreamDuplicateLedgers;
676
+ readonly overlayToBase: OverlayToBase;
577
677
  }
578
678
 
579
679
  const NO_FEC_MANAGER_HORIZON: DuplicateHorizon = Object.freeze({
@@ -661,6 +761,17 @@ export interface FECManagerLike {
661
761
  catalogTrackName?: string,
662
762
  ): Uint8Array | null;
663
763
 
764
+ /** Feed a source another FEC instance over the same packets rebuilt (a
765
+ * keyframe overlay) to its base block, which a repair must already have
766
+ * stated; not a network arrival (FECManager.feedRebuiltSourceSymbol).
767
+ * Optional: without it the overlay and the base decode apart. */
768
+ feedRebuiltSourceSymbol?(
769
+ packetId: number,
770
+ ssId: number,
771
+ symbolData: Uint8Array,
772
+ catalogTrackName?: string,
773
+ ): RebuiltSourceFeed;
774
+
664
775
  /** How long ago the stream's oldest open block with an FEC deadline took its first symbol (optional). */
665
776
  oldestOpenBlockAgeMs?(packetId: number, catalogTrackName: string): number | undefined;
666
777
 
@@ -732,13 +843,14 @@ export interface FECManagerLike {
732
843
 
733
844
  /** Track a first-arrival network source symbol for block completion and
734
845
  * classification. Refreshes the inactivity deadline and marks clean when
735
- * K arrive. */
846
+ * K arrive. `held`: a source held unplaced since it arrived, placed now. */
736
847
  trackBlock(
737
848
  sbn: number,
738
849
  packetId: number,
739
850
  catalogTrackName: string | undefined,
740
851
  esi: number,
741
852
  evidence: SourceSymbolEvidence,
853
+ held?: boolean,
742
854
  ): void;
743
855
 
744
856
  /** True once the packetId has a per-track FEC configuration. */
@@ -908,6 +1020,7 @@ export interface FECManagerLike {
908
1020
  packetId: number,
909
1021
  catalogTrackName: string | undefined,
910
1022
  blockClass: BlockClass,
1023
+ span?: IsoSpanFailure,
911
1024
  ) => void)
912
1025
  | null;
913
1026
 
@@ -976,6 +1089,7 @@ export class MmtpRouter {
976
1089
  readonly #provenance = new Map<string, ProvenanceEntry>();
977
1090
  #onFlowProtected: ((flow: string) => void) | null = null;
978
1091
  #unadmittedListedDrops = 0;
1092
+ #admittedSourceDatagrams = 0;
979
1093
  #unlistedRepairSetAside = 0;
980
1094
  readonly #assistBlocks = new Map<string, AssistBlock>();
981
1095
  readonly #assistFailures = new Map<string, number>();
@@ -1044,6 +1158,26 @@ export class MmtpRouter {
1044
1158
  * sequence is its SS_ID minus this; see #routeProcessedFecSymbols.
1045
1159
  */
1046
1160
  #ssIdSeqOffsetByStream = new Map<string, number>();
1161
+ /**
1162
+ * Per stream, the SS_ID offset at which a base recovery last showed the
1163
+ * sender's own packet_sequence_number: a recovered packet's header psn
1164
+ * equal to its SS_ID minus that offset. While it equals the stream's
1165
+ * current offset, no hop re-stamps the stream, and a packet's protected psn
1166
+ * names its base SS_ID within unstampedBaseSsId's limits (joint decoding of
1167
+ * an MPU no member of which arrived).
1168
+ */
1169
+ #unstampedSsIdSeqOffsetByStream = new Map<string, number>();
1170
+ /**
1171
+ * The same, shown by the overlay alone, before any base recovery did: a
1172
+ * packet it rebuilt whose base copy carries its protected psn
1173
+ * (#feedOverlaySource), or whose protected psn passes overlayBaseSsId's
1174
+ * test at that offset (#routeOverlayRecovered). A base SS_ID placed by it
1175
+ * anchors the release gate only (BLO-52085 D3): fed to the base, it would
1176
+ * make the base drop a late base copy of a keyframe the overlay never
1177
+ * finished, and lift a block's late-source cut, which nothing before
1178
+ * this offset did.
1179
+ */
1180
+ #overlayUnstampedSsIdSeqOffsetByStream = new Map<string, number>();
1047
1181
  /**
1048
1182
  * Streams whose multicast leg delivered a RAP-flagged source packet that was
1049
1183
  * not parsed, typically because its overlap copy was parsed first
@@ -1092,6 +1226,7 @@ export class MmtpRouter {
1092
1226
  packetId: number,
1093
1227
  catalogTrackName: string | undefined,
1094
1228
  blockClass: BlockClass,
1229
+ span?: IsoSpanFailure,
1095
1230
  ) => void)
1096
1231
  | null = null;
1097
1232
  /** Typed observer of every gap decision the release gate makes. */
@@ -1121,7 +1256,7 @@ export class MmtpRouter {
1121
1256
  #restampedStreams = new Set<string>();
1122
1257
  #overlayStats: KeyframeOverlayStats = {
1123
1258
  repairDropped: 0, ownerRefused: 0, recovered: 0, restarts: 0,
1124
- unsequencedDropped: 0, protectedDropped: 0,
1259
+ unsequencedDropped: 0, protectedDropped: 0, baseFed: 0, baseUndetermined: 0,
1125
1260
  };
1126
1261
  /** Callback fired when a delta-track FEC block completes or fails. */
1127
1262
  #onDeltaBlockCompleteCb:
@@ -1189,6 +1324,7 @@ export class MmtpRouter {
1189
1324
  packetId: number,
1190
1325
  catalogTrackName: string | undefined,
1191
1326
  blockClass: BlockClass,
1327
+ span?: IsoSpanFailure,
1192
1328
  ) => void;
1193
1329
  /** Internal gap-decision sink owned by the composing transport. */
1194
1330
  onFrameGap?: (decision: FrameGapDecision) => void;
@@ -1400,7 +1536,7 @@ export class MmtpRouter {
1400
1536
  const cb = this.#getFrameCallback(packetId);
1401
1537
  if (isVideo && !assembler) {
1402
1538
  if (frameMeta.recovered) {
1403
- this.#routeDirectRecoveredVideo(packetId, data, frameMeta, cb);
1539
+ this.#routeDirectRecoveredVideo(packetId, data, frameMeta, cb, meta.fecSbn);
1404
1540
  } else {
1405
1541
  this.#routeDirectSourceVideo(packetId, data, frameMeta, cb);
1406
1542
  }
@@ -1422,6 +1558,7 @@ export class MmtpRouter {
1422
1558
  packetId: number,
1423
1559
  catalogTrackName: string | undefined,
1424
1560
  blockClass: BlockClass,
1561
+ span?: IsoSpanFailure,
1425
1562
  ) => void)
1426
1563
  | undefined,
1427
1564
  ): void {
@@ -1474,6 +1611,7 @@ export class MmtpRouter {
1474
1611
  packetId: number,
1475
1612
  catalogTrackName: string | undefined,
1476
1613
  blockClass: BlockClass,
1614
+ span?: IsoSpanFailure,
1477
1615
  ) => {
1478
1616
  this.#mseAssemblerFor(packetId, catalogTrackName)?.failBlock(sbn);
1479
1617
  if (
@@ -1482,8 +1620,13 @@ export class MmtpRouter {
1482
1620
  ) {
1483
1621
  this.#onDeltaBlockCompleteCb?.(packetId, true); // failed=true
1484
1622
  }
1485
- onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass);
1486
- this.#onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass);
1623
+ if (span === undefined) {
1624
+ onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass);
1625
+ this.#onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass);
1626
+ } else {
1627
+ onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass, span);
1628
+ this.#onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass, span);
1629
+ }
1487
1630
  };
1488
1631
  }
1489
1632
  }
@@ -1536,8 +1679,9 @@ export class MmtpRouter {
1536
1679
 
1537
1680
  /**
1538
1681
  * FEC-recovered source symbols whose reconstructed header named a
1539
- * packet_sequence_number other than the wire sequence their SS_ID maps to:
1540
- * a hop re-stamped the sequence after the sender protected the packet.
1682
+ * packet_sequence_number other than the wire sequence their SS_ID maps to,
1683
+ * or, rebuilt by the overlay, than their base copy carries: a hop
1684
+ * re-stamped the sequence after the sender protected the packet.
1541
1685
  */
1542
1686
  get restampedRecoveredSequenceCount(): number {
1543
1687
  return this.#restampedRecoveredSeqs;
@@ -1761,6 +1905,11 @@ export class MmtpRouter {
1761
1905
  ): CmafBlockAssembler {
1762
1906
  const assemblerKey = this.#mseAssemblerKey(packetId, catalogTrackName);
1763
1907
  const previous = this.#mseAssemblers.get(assemblerKey);
1908
+ // The new assembler has no source cursor: the release gate must not hold
1909
+ // an overlay keyframe for one either (FecReleaseSequencer.#cursorPassed).
1910
+ // What the gate holds that unanchored had already released to the
1911
+ // assembler being replaced goes to it first, and plays with its flush.
1912
+ this.#parser.unsourceFecRelease(packetId, catalogTrackName);
1764
1913
  if (previous) {
1765
1914
  previous.flush();
1766
1915
  previous.dispose();
@@ -1832,6 +1981,7 @@ export class MmtpRouter {
1832
1981
  packetId: number,
1833
1982
  catalogTrackName: string | undefined,
1834
1983
  blockClass: BlockClass,
1984
+ span?: IsoSpanFailure,
1835
1985
  ) => void,
1836
1986
  ): void {
1837
1987
  this.#onBlockFailed = cb;
@@ -1915,6 +2065,11 @@ export class MmtpRouter {
1915
2065
  return { ...this.#overlayStats };
1916
2066
  }
1917
2067
 
2068
+ /** Recovered FT=2 heads held for their FT=1, and how each left the wait (BLO-53369). */
2069
+ get recoveredHeadsAwaitingTiming(): Readonly<MmtpRecoveredHeadTimingStats> {
2070
+ return this.#parser.recoveredHeadsAwaitingTiming;
2071
+ }
2072
+
1918
2073
  #isOverlayRepair(repairPacketId: number, repairTrackName?: string): boolean {
1919
2074
  return this.#overlayLegs.has(repairPacketId) ||
1920
2075
  (repairTrackName !== undefined && this.#overlayRepairNames.has(repairTrackName));
@@ -1931,7 +2086,7 @@ export class MmtpRouter {
1931
2086
  const leg = this.#overlayLegs.get(repairPacketId);
1932
2087
  if (!leg) throw new Error(`[MmtpRouter] packetId=${repairPacketId} is no known keyframe-overlay leg`);
1933
2088
  const inst: OverlayInstance = {
1934
- repairPacketId, streamName, leg: { ...leg }, fec, k, offsets: new Map(), moqBlocks: new Set(),
2089
+ repairPacketId, streamName, leg: { ...leg }, fec, k, offsets: new Map(), moqBlocks: new Set(), rebuilt: new Map(),
1935
2090
  };
1936
2091
  this.#overlays.set(repairPacketId, inst);
1937
2092
  return () => {
@@ -1966,6 +2121,13 @@ export class MmtpRouter {
1966
2121
  */
1967
2122
  resetKeyframeOverlays(sourcePacketId: number, catalogTrackName?: string): void {
1968
2123
  for (const inst of this.#overlaysForSource(sourcePacketId, catalogTrackName)) this.#resetOverlay(inst);
2124
+ // The base restarted with it: base SS_IDs kept for joint decoding name
2125
+ // the old epoch's packets.
2126
+ for (const [name, stream] of this.#streams.get(sourcePacketId) ?? []) {
2127
+ if (catalogTrackName !== undefined && name !== catalogTrackName) continue;
2128
+ stream.overlayToBase.routed.clear();
2129
+ stream.overlayToBase.pending.clear();
2130
+ }
1969
2131
  }
1970
2132
 
1971
2133
  #resetOverlay(inst: OverlayInstance): void {
@@ -1973,6 +2135,7 @@ export class MmtpRouter {
1973
2135
  inst.last = undefined;
1974
2136
  inst.offsets.clear();
1975
2137
  inst.moqBlocks.clear();
2138
+ inst.rebuilt.clear();
1976
2139
  }
1977
2140
 
1978
2141
  #overlaysForSource(sourcePacketId: number, catalogTrackName: string | undefined): OverlayInstance[] {
@@ -2043,7 +2206,15 @@ export class MmtpRouter {
2043
2206
  * A source packet that carries a 0x8B01 overlay SS_ID is also a symbol of
2044
2207
  * every overlay installed for its source, at that SS_ID, with the bytes the
2045
2208
  * base protects: the packet minus its 4-byte SS_ID trailer, extension
2046
- * included (7.1, 7.4.3).
2209
+ * included (7.1, 7.4.3). `baseSsId` is that trailer's SS_ID, when the base
2210
+ * FEC reads it.
2211
+ *
2212
+ * The base copy of a packet an overlay rebuilt with no base SS_ID shows
2213
+ * what a base recovery of it would (#routeProcessedFecSymbols, BLO-52085):
2214
+ * its wire packet_sequence_number equal to the protected one rebuilt shows
2215
+ * the stream unstamped at the SS_ID offset it states, for the release gate
2216
+ * (#overlayUnstampedSsIdSeqOffsetByStream); any other shows a hop that
2217
+ * re-stamps it, which is counted (restampedRecoveredSequenceCount).
2047
2218
  */
2048
2219
  #feedOverlaySource(
2049
2220
  packetId: number,
@@ -2053,16 +2224,35 @@ export class MmtpRouter {
2053
2224
  sequenceNumber: number,
2054
2225
  rapFlag: boolean,
2055
2226
  source: MmtpSourcePath,
2227
+ baseSsId: number | undefined,
2056
2228
  ): void {
2057
2229
  if (this.#overlays.size === 0) return;
2058
2230
  const ssId = keyframeOverlaySsId(header);
2059
2231
  if (ssId === undefined) return;
2232
+ let rebuiltPsn: number | undefined;
2060
2233
  for (const inst of this.#overlaysForSource(packetId, catalogTrackName)) {
2061
2234
  this.#noteOverlaySsId(inst, ssId, sequenceNumber);
2235
+ // After the restart check: a new epoch forgets the old one's packets.
2236
+ rebuiltPsn ??= inst.rebuilt.get(ssId);
2237
+ inst.rebuilt.delete(ssId);
2062
2238
  const identity = MmtpContainerParser.sourcePacketIdentity(packet, header);
2063
2239
  if (identity !== null) {
2240
+ // The newest member anchors its MPU; one with no base SS_ID leaves the
2241
+ // MPU no base anchor rather than keep an older member's. A member that
2242
+ // is no FEC source packet of the base takes an overlay SS_ID and a psn
2243
+ // but no base SS_ID, so past it an older anchor would put each rebuilt
2244
+ // packet one base slot high, and overlayBaseSsId's psn test would pass
2245
+ // it. That costs a conforming stream nothing: every member carries the
2246
+ // base trailer (7.4.3), unread only while the base track is
2247
+ // unconfigured (fecUnreadable), which joint decoding skips, and a base
2248
+ // release (MulticastManager) resets or removes the overlay, so no
2249
+ // readable member precedes an unread one in its MPU.
2064
2250
  inst.offsets.delete(identity.mpuSequenceNumber);
2065
- inst.offsets.set(identity.mpuSequenceNumber, (ssId - sequenceNumber) >>> 0);
2251
+ inst.offsets.set(identity.mpuSequenceNumber, {
2252
+ psnOffset: (ssId - sequenceNumber) >>> 0,
2253
+ overlaySsId: ssId,
2254
+ ...(baseSsId === undefined ? {} : { baseSsId }),
2255
+ });
2066
2256
  if (inst.offsets.size > OVERLAY_OFFSET_MPUS) inst.offsets.delete(inst.offsets.keys().next().value!);
2067
2257
  }
2068
2258
  const { fec, repairPacketId: pid, streamName: name } = inst;
@@ -2082,6 +2272,18 @@ export class MmtpRouter {
2082
2272
  const recovered = fec.feedSourceSymbol(pid, ssId, symbolData, name);
2083
2273
  if (recovered && !alreadyCompleted) this.#routeOverlayRecovered(inst, coords.sbn, recovered);
2084
2274
  }
2275
+ if (rebuiltPsn === undefined || this.#classifyPacketId(packetId) === null) return;
2276
+ const streamKey = this.#mediaStreamKey(packetId, catalogTrackName);
2277
+ if (rebuiltPsn !== sequenceNumber) {
2278
+ // Counted only. Marked re-stamped (#restampedStreams), the stream would
2279
+ // drop every later rebuilt packet no member anchors, which the overlay
2280
+ // route delivers unanchored until a base recovery shows the re-stamp:
2281
+ // a keyframe whose base copy is lost too would never play.
2282
+ this.#restampedRecoveredSeqs++;
2283
+ } else if (baseSsId !== undefined) {
2284
+ const offset = this.#ssIdSeqOffsetByStream.get(streamKey);
2285
+ if (offset === ((baseSsId - sequenceNumber) >>> 0)) this.#overlayUnstampedSsIdSeqOffsetByStream.set(streamKey, offset);
2286
+ }
2085
2287
  }
2086
2288
 
2087
2289
  #noteOverlayMoqBlock(inst: OverlayInstance, blockStart: number): void {
@@ -2122,11 +2324,16 @@ export class MmtpRouter {
2122
2324
 
2123
2325
  /**
2124
2326
  * Route an overlay-recovered block's packets as source media of the
2125
- * overlay's source (7.2: an overlay coordinate yields no base SS_ID). No
2126
- * base-SS_ID derivation runs: no release-gate anchor, no parse ledger, no
2127
- * base re-stamp, no block advance. Each packet's wire sequence is restored
2128
- * from its keyframe's own offset; without one, a stream the base never
2129
- * re-stamped keeps the header's sequence and any other drops the packet.
2327
+ * overlay's source (7.2: an overlay coordinate yields no base SS_ID). A
2328
+ * packet whose base SS_ID is determined independently (overlayBaseSsId,
2329
+ * unstampedBaseSsId) enters the release gate at that SS_ID, as a packet
2330
+ * base FEC recovered does, so it waits behind base frames still in flight,
2331
+ * though not for its own MPU's packets (FrameMeta.fecSbnFirst) and no
2332
+ * longer than unanchored; one whose base SS_ID is undetermined has no
2333
+ * release-gate anchor. No parse ledger, no base re-stamp, no block
2334
+ * advance. Each packet's wire sequence is restored from its keyframe's own
2335
+ * offset; without one, a stream the base never re-stamped keeps the
2336
+ * header's sequence and any other drops the packet.
2130
2337
  */
2131
2338
  #routeOverlayRecovered(inst: OverlayInstance, blockStart: number, recovered: Uint8Array): void {
2132
2339
  const { fec, repairPacketId: pid, streamName: name } = inst;
@@ -2149,27 +2356,181 @@ export class MmtpRouter {
2149
2356
  const streamKey = this.#mediaStreamKey(sourcePacketId, sourceName);
2150
2357
  const restamped = this.#restampedStreams.has(streamKey);
2151
2358
  const restored = new Set<number>();
2359
+ // The base SS_IDs determined independently, by overlay ESI; those the
2360
+ // overlay alone showed anchor the release gate, not the base decoder.
2361
+ const baseSsIds = new Map<number, number>();
2362
+ const gateOnly = new Set<number>();
2152
2363
  const symbols = result.symbols.flatMap((sym) => {
2153
- const offset = inst.offsets.get(sym.fragment.mpuSequenceNumber);
2154
- if (offset !== undefined) {
2155
- const packetSequenceNumber = (serial32Add(blockStart, sym.index) - offset) >>> 0;
2364
+ const overlaySsId = serial32Add(blockStart, sym.index);
2365
+ const anchor = inst.offsets.get(sym.fragment.mpuSequenceNumber);
2366
+ if (anchor !== undefined) {
2367
+ const packetSequenceNumber = (overlaySsId - anchor.psnOffset) >>> 0;
2156
2368
  restored.add(packetSequenceNumber);
2157
- return [{ ...sym, packetSequenceNumber }];
2369
+ const out = { ...sym, packetSequenceNumber };
2370
+ const baseSsId = overlayBaseSsId(anchor, overlaySsId, packetSequenceNumber, sym.packetSequenceNumber);
2371
+ if (baseSsId !== undefined) {
2372
+ baseSsIds.set(sym.index, baseSsId);
2373
+ // Its protected psn is the wire sequence its member gives it, at
2374
+ // the base SS_ID that member gives it: at the stream's current
2375
+ // SS_ID offset, the stream unstamped, as a base recovery shows it
2376
+ // (#routeProcessedFecSymbols). A re-stamping hop fails the psn test.
2377
+ // Only for a packet the base did not receive: a received member's
2378
+ // slot holds the bytes it arrived with (the decoder keeps a
2379
+ // received source as given), whose psn is the wire one whatever a
2380
+ // hop did, so it passes the test against itself.
2381
+ const offset = this.#ssIdSeqOffsetByStream.get(streamKey);
2382
+ if (!this.#sourceSeqReceived(sourcePacketId, packetSequenceNumber, sourceName) &&
2383
+ offset === ((baseSsId - packetSequenceNumber) >>> 0)) {
2384
+ this.#overlayUnstampedSsIdSeqOffsetByStream.set(streamKey, offset);
2385
+ }
2386
+ }
2387
+ return [out];
2158
2388
  }
2159
2389
  // A stream re-stamped by a bridge that no base recovery has shown yet
2160
2390
  // keeps the header's sequence too: from received packets alone the
2161
- // router cannot tell (their psn is the wire one). Base recovery or a
2162
- // FETCH still covers such a keyframe.
2163
- if (!restamped) return [sym];
2391
+ // router cannot tell (their psn is the wire one), and the base copy of
2392
+ // a packet the overlay rebuilt only counts a re-stamp. Base recovery or
2393
+ // a FETCH still covers such a keyframe.
2394
+ if (!restamped) {
2395
+ // No member of this MPU arrived to anchor a base SS_ID. Evidence that
2396
+ // the stream is unstamped at its current offset determines one from
2397
+ // the packet's protected psn instead (unstampedBaseSsId): a base
2398
+ // recovery's, or else the overlay's own, which anchors the release
2399
+ // gate only (#overlayUnstampedSsIdSeqOffsetByStream). Without it, the
2400
+ // packet's base copy, if it lands, shows whether it is
2401
+ // (#feedOverlaySource).
2402
+ const offset = this.#ssIdSeqOffsetByStream.get(streamKey);
2403
+ const fedSsId = unstampedBaseSsId(offset, this.#unstampedSsIdSeqOffsetByStream.get(streamKey), sym.packetSequenceNumber);
2404
+ const baseSsId = fedSsId ??
2405
+ unstampedBaseSsId(offset, this.#overlayUnstampedSsIdSeqOffsetByStream.get(streamKey), sym.packetSequenceNumber);
2406
+ if (baseSsId !== undefined) {
2407
+ baseSsIds.set(sym.index, baseSsId);
2408
+ if (fedSsId === undefined) gateOnly.add(sym.index);
2409
+ } else {
2410
+ inst.rebuilt.set(overlaySsId, sym.packetSequenceNumber);
2411
+ if (inst.rebuilt.size > OVERLAY_REBUILT_LIMIT) inst.rebuilt.delete(inst.rebuilt.keys().next().value!);
2412
+ }
2413
+ return [sym];
2414
+ }
2164
2415
  this.#overlayStats.unsequencedDropped++;
2165
2416
  return [];
2166
2417
  });
2167
2418
  this.#overlayStats.recovered++;
2419
+ // Each packet's MPU starts, in this decode, at the lowest base SS_ID
2420
+ // determined for it: the release gate's span ends there.
2421
+ const mpuFirst = new Map<number, number>();
2422
+ for (const sym of symbols) {
2423
+ const ssId = baseSsIds.get(sym.index);
2424
+ const mpu = sym.fragment.mpuSequenceNumber;
2425
+ const first = mpuFirst.get(mpu);
2426
+ if (ssId !== undefined && (first === undefined || serial32Precedes(ssId, first))) mpuFirst.set(mpu, ssId);
2427
+ }
2428
+ const firstSsIds = new Map<number, number>();
2429
+ for (const sym of symbols) {
2430
+ if (baseSsIds.has(sym.index)) firstSsIds.set(sym.index, mpuFirst.get(sym.fragment.mpuSequenceNumber)!);
2431
+ }
2168
2432
  this.#routeProcessedFecSymbols(blockStart, { ...result, symbols }, sourcePacketId, sourceName, {
2169
2433
  deliveryProvenance: inst.moqBlocks.has(blockStart) ? "mixed" : "multicast",
2434
+ baseSsIds,
2435
+ firstSsIds,
2170
2436
  });
2171
2437
  this.#noteOverlayRecoveredRaps(sourcePacketId, sourceName, streamKey, symbols.filter((sym) =>
2172
2438
  restored.has(sym.packetSequenceNumber)));
2439
+ this.#feedOverlayRecoveredToBase(inst, blockStart, symbols, gateOnly.size === 0
2440
+ ? baseSsIds
2441
+ : new Map([...baseSsIds].filter(([index]) => !gateOnly.has(index))));
2442
+ }
2443
+
2444
+ /**
2445
+ * Joint decoding (BLO-52085). A packet the overlay rebuilt is a source of
2446
+ * its base block too, once its base SS_ID is determined (overlayBaseSsId,
2447
+ * from a received member of its MPU, else unstampedBaseSsId at an offset a
2448
+ * base recovery showed; `baseSsIds` holds no other),
2449
+ * so it is fed to the base decoder as a rebuilt source
2450
+ * (FECManager.feedRebuiltSourceSymbol): the base block has one erasure
2451
+ * fewer to solve, and its repair can recover the block's other packets.
2452
+ * A base block no repair has stated yet takes it when one does
2453
+ * (#feedPendingRebuilt).
2454
+ *
2455
+ * The overlay route already handled each packet fed (delivered it, or
2456
+ * held an untimed head for its FT=1), so a base recovery routes none of
2457
+ * them again (#routeVerifiedFecSymbols): a second recovered copy of a
2458
+ * held head would wait in the parser for timing its first copy consumed.
2459
+ * Only an overlay block decoded from multicast symbols alone is fed: with
2460
+ * a MoQ symbol in it, the base block's recovery would depend on bytes
2461
+ * that crossed no multicast socket, which the provenance counted from the
2462
+ * block's own arrivals cannot show.
2463
+ */
2464
+ #feedOverlayRecoveredToBase(
2465
+ inst: OverlayInstance,
2466
+ blockStart: number,
2467
+ symbols: ProcessFecBlockResult["symbols"],
2468
+ baseSsIds: ReadonlyMap<number, number>,
2469
+ ): void {
2470
+ const fec = this.#fecManager;
2471
+ const owner = inst.leg.sourcePacketId;
2472
+ const name = inst.leg.sourceCatalogTrackName;
2473
+ if (!fec?.feedRebuiltSourceSymbol || inst.moqBlocks.has(blockStart)) return;
2474
+ if (this.#classifyPacketId(owner) === null || fec.isTrackConfigured?.(owner) === false) return;
2475
+ const { duplicates, overlayToBase: { routed } } = this.#stream(owner, name);
2476
+ const feeds: Array<readonly [number, Uint8Array]> = [];
2477
+ for (const sym of symbols) {
2478
+ // A packet the base received already sits in its block.
2479
+ if (duplicates.sourceSeqs.has(sym.packetSequenceNumber)) continue;
2480
+ const ssId = baseSsIds.get(sym.index);
2481
+ if (ssId === undefined) {
2482
+ this.#overlayStats.baseUndetermined++;
2483
+ continue;
2484
+ }
2485
+ routed.delete(ssId);
2486
+ routed.add(ssId);
2487
+ if (routed.size > OVERLAY_ROUTED_LIMIT) routed.delete(routed.values().next().value!);
2488
+ feeds.push([ssId, sym.rawSymbol]);
2489
+ }
2490
+ // Any feed can recover the base block, whose routing must already skip
2491
+ // every packet this recovery routed: all are registered before the first.
2492
+ for (const [ssId, symbolData] of feeds) {
2493
+ // A recovery routed above can retire the owner (an in-place switch).
2494
+ if (this.#classifyPacketId(owner) === null) return;
2495
+ this.#feedRebuiltToBase(owner, name, ssId, symbolData);
2496
+ }
2497
+ }
2498
+
2499
+ /** Feed one rebuilt source to its base block, or keep it until a repair states that block. */
2500
+ #feedRebuiltToBase(owner: number, name: string | undefined, ssId: number, symbolData: Uint8Array): void {
2501
+ const fec = this.#fecManager;
2502
+ if (!fec?.feedRebuiltSourceSymbol) return;
2503
+ const coords = fec.locateIsoSource?.(owner, name, ssId);
2504
+ if (coords == null) {
2505
+ const pending = this.#stream(owner, name).overlayToBase.pending;
2506
+ pending.delete(ssId);
2507
+ // Its own copy: the symbol is a view of the whole recovered block.
2508
+ pending.set(ssId, symbolData.slice());
2509
+ if (pending.size > OVERLAY_PENDING_LIMIT) pending.delete(pending.keys().next().value!);
2510
+ return;
2511
+ }
2512
+ const fed = fec.feedRebuiltSourceSymbol(owner, ssId, symbolData, name);
2513
+ if (!fed.taken) return;
2514
+ this.#overlayStats.baseFed++;
2515
+ if (fed.recovered === null) return;
2516
+ const sbn = coords.sbn;
2517
+ this.#routeRecoveredBlock(sbn, fed.recovered, owner, name, () => {
2518
+ fec.cancelBlockTimeout(sbn, owner, name);
2519
+ fec.countRecovery(owner, sbn, name);
2520
+ fec.markBlockCompleted(sbn, owner, name);
2521
+ });
2522
+ }
2523
+
2524
+ /** A repair stated `block`: the rebuilt sources that waited for it join it now. */
2525
+ #feedPendingRebuilt(owner: number, name: string | undefined, block: { start: number; length: number }): void {
2526
+ const pending = this.#streams.get(owner)?.get(name)?.overlayToBase.pending;
2527
+ if (pending === undefined || pending.size === 0) return;
2528
+ for (const [ssId, symbolData] of [...pending]) {
2529
+ if (this.#classifyPacketId(owner) === null) return;
2530
+ if (serial32Distance(block.start, ssId) >= block.length) continue;
2531
+ pending.delete(ssId);
2532
+ this.#feedRebuiltToBase(owner, name, ssId, symbolData);
2533
+ }
2173
2534
  }
2174
2535
 
2175
2536
  /** fec-multicast-00 §6.6: report each base block whose RAP MFU fragments
@@ -2205,6 +2566,9 @@ export class MmtpRouter {
2205
2566
  const mpu = sym.fragment.mpuSequenceNumber;
2206
2567
  if (sym.fragmentType !== 2 || !sym.fragment.rapFlag || !ft0.has(mpu) || !ft1.has(mpu)) continue;
2207
2568
  if (!single.has(mpu) && !(first.has(mpu) && last.has(mpu))) continue;
2569
+ // Not overlayBaseSsId: a wrong sbn only keeps or wrongly stops a keyframe
2570
+ // FETCH (§6.6, a SHOULD; no symbol moves), and wire psn plus offset holds
2571
+ // under a constant re-stamp shift, which overlayBaseSsId's psn test refuses.
2208
2572
  const ssId = (sym.packetSequenceNumber + ssIdSeqOffset) >>> 0;
2209
2573
  // ponytail: a base block no repair has stated yet (an undelimited hole) is not reported.
2210
2574
  const sbn = this.#fecManager?.locateIsoSource?.(packetId, catalogTrackName, ssId)?.sbn;
@@ -2355,6 +2719,11 @@ export class MmtpRouter {
2355
2719
  return this.#unadmittedListedDrops;
2356
2720
  }
2357
2721
 
2722
+ /** Source datagrams (multicast or MoQ) a protected flow's bc-provenance v2 receiver released: admitted at once or held, then admitted by a record. Repair symbols and user-agent-admitted datagrams are not counted. Counted at release, before this router's own track checks: a released datagram those drop, which only a track change between hold and release can cause, still counts, so this is not a count of routed media. */
2723
+ get admittedSourceDatagramCount(): number {
2724
+ return this.#admittedSourceDatagrams;
2725
+ }
2726
+
2358
2727
  /** MoQ repair symbols of an unlisted track kept out of a governed FEC instance (7.2.7 step 2). */
2359
2728
  get unlistedRepairSetAsideCount(): number {
2360
2729
  return this.#unlistedRepairSetAside;
@@ -2989,6 +3358,11 @@ export class MmtpRouter {
2989
3358
  )) return;
2990
3359
  fec.noteBlockActivity?.(blockStart, ownerPacketId, catalogTrackName);
2991
3360
  fec.noteRepairGroupLength?.(blockStart, ownerPacketId, repair.rsbLength, catalogTrackName);
3361
+ // Rebuilt sources that waited for this block join it once the block took
3362
+ // a repair, so it has a deadline; still ahead of the repair, as the held
3363
+ // sources above are.
3364
+ this.#feedPendingRebuilt(ownerPacketId, catalogTrackName, placement.block);
3365
+ if (this.#classifyPacketId(ownerPacketId) === null) return;
2992
3366
  const alreadyCompleted = fec.isBlockCompleted(blockStart, ownerPacketId, catalogTrackName);
2993
3367
  const recovered = fec.feedRepairSymbol(
2994
3368
  ownerPacketId,
@@ -3051,7 +3425,7 @@ export class MmtpRouter {
3051
3425
  catalogTrackName,
3052
3426
  )) continue;
3053
3427
  if (fec.isTrackConfigured?.(ownerPacketId) !== false) {
3054
- fec.trackBlock(blockStart, ownerPacketId, catalogTrackName, esi, payload.evidence);
3428
+ fec.trackBlock(blockStart, ownerPacketId, catalogTrackName, esi, payload.evidence, true);
3055
3429
  this.#requestFecPoke(ownerPacketId, catalogTrackName);
3056
3430
  }
3057
3431
  if (payload.oversized) continue;
@@ -3169,6 +3543,8 @@ export class MmtpRouter {
3169
3543
  this.#droppedPreSyncVideo.delete(streamKey);
3170
3544
  this.#latestVideoSeqByStream.delete(streamKey);
3171
3545
  this.#ssIdSeqOffsetByStream.delete(streamKey);
3546
+ this.#unstampedSsIdSeqOffsetByStream.delete(streamKey);
3547
+ this.#overlayUnstampedSsIdSeqOffsetByStream.delete(streamKey);
3172
3548
  this.#multicastRapTwinByStream.delete(streamKey);
3173
3549
  this.#lastDirectSourceVideoTsByStream.delete(streamKey);
3174
3550
  this.#lastDirectEmittedVideoTsByStream.delete(streamKey);
@@ -3300,6 +3676,12 @@ export class MmtpRouter {
3300
3676
  * the outgoing stream stops. The hold is bounded by the incoming stream's
3301
3677
  * own RAP cadence, so no timer is involved.
3302
3678
  *
3679
+ * While the switch is staged, the FEC release gate lets a keyframe the
3680
+ * overlay rebuilt reach it, on either side, when the keyframe did before
3681
+ * the gate anchored it (FecReleaseGate.releaseUnanchored): the switch
3682
+ * decides on what each side delivered, and the commit discards the gate's
3683
+ * frames of the outgoing stream.
3684
+ *
3303
3685
  * The commit is one synchronous transition inside the frame path: the
3304
3686
  * outgoing stream's already-admitted frames are released (its queued FEC
3305
3687
  * gap fills only up to the RAP, since recovery can queue a frame ahead of
@@ -3352,6 +3734,16 @@ export class MmtpRouter {
3352
3734
  droppedIncomingFrames: 0,
3353
3735
  };
3354
3736
  this.#staged = state;
3737
+ // Release what the outgoing stream's gate held only because it was
3738
+ // anchored (FecReleaseGate.releaseUnanchored): unanchored, it had already
3739
+ // reached the switch. Only a gate that holds a frame the overlay rebuilt
3740
+ // is poked: any other holds nothing for that reason, and a poke would
3741
+ // still force out a frame held past the hold limit, which before the
3742
+ // anchor waited for the next push. The incoming stream's gate is not
3743
+ // drained here: a RAP it released would commit before this call returns.
3744
+ if (this.#parser.fecReleaseHoldsRebuilt(from.packetId, from.catalogTrackName)) {
3745
+ this.#requestFecPoke(from.packetId, from.catalogTrackName);
3746
+ }
3355
3747
  return {
3356
3748
  cancel: () => {
3357
3749
  if (this.#staged !== state) return false;
@@ -3446,7 +3838,24 @@ export class MmtpRouter {
3446
3838
  // same call. Nothing registers a packet ID mid-frame, so this drops
3447
3839
  // only frames that lost their registration.
3448
3840
  if (this.#classifyPacketId(meta.packetId) === null) return;
3449
- if (this.#staged !== null && this.#interceptSwitchFrame(data, meta)) return;
3841
+ if (this.#staged !== null) {
3842
+ // A switch staged from this frame's commit (onCommit: the ladder walk)
3843
+ // pokes its outgoing stream's gate, the stream just committed to, whose
3844
+ // drain may be what released this frame. The poke waits until the
3845
+ // commit has delivered that stream's init, held GOP and RAP. Run now,
3846
+ // it would release the frames behind them ahead of them: a delta is
3847
+ // dropped there (its stream is not synced yet), and a keyframe plays
3848
+ // ahead of the init and leaves the commit's frames behind it.
3849
+ let consumed = false;
3850
+ this.#fecPokeDepth++;
3851
+ try {
3852
+ consumed = this.#interceptSwitchFrame(data, meta);
3853
+ } finally {
3854
+ this.#fecPokeDepth--;
3855
+ this.#flushPendingFecPokes();
3856
+ }
3857
+ if (consumed) return;
3858
+ }
3450
3859
  this.#deliverParsedFrame(data, meta);
3451
3860
  }
3452
3861
 
@@ -3639,12 +4048,17 @@ export class MmtpRouter {
3639
4048
  this.#parser.deferFecRelease(work);
3640
4049
  } finally {
3641
4050
  this.#fecPokeDepth--;
3642
- if (this.#fecPokeDepth === 0 && this.#pendingFecPokes.size > 0 && !this.#disposed) {
3643
- const pokes = [...this.#pendingFecPokes.values()];
3644
- this.#pendingFecPokes.clear();
3645
- for (const poke of pokes) {
3646
- this.#parser.pokeFecRelease(poke.packetId, poke.catalogTrackName);
3647
- }
4051
+ this.#flushPendingFecPokes();
4052
+ }
4053
+ }
4054
+
4055
+ /** Run the pokes requested while #fecPokeDepth was raised, once it is 0. */
4056
+ #flushPendingFecPokes(): void {
4057
+ if (this.#fecPokeDepth === 0 && this.#pendingFecPokes.size > 0 && !this.#disposed) {
4058
+ const pokes = [...this.#pendingFecPokes.values()];
4059
+ this.#pendingFecPokes.clear();
4060
+ for (const poke of pokes) {
4061
+ this.#parser.pokeFecRelease(poke.packetId, poke.catalogTrackName);
3648
4062
  }
3649
4063
  }
3650
4064
  }
@@ -3733,6 +4147,14 @@ export class MmtpRouter {
3733
4147
  onRap: (packetId, catalogTrackName, ssId) => {
3734
4148
  for (const observer of this.#fecRapObservers) observer(packetId, catalogTrackName, ssId);
3735
4149
  },
4150
+ // A staged switch commits on when each side's frames reach it
4151
+ // (#interceptSwitchFrame): both sides' overlay keyframes reach it when
4152
+ // they did unanchored.
4153
+ releaseUnanchored: (packetId, catalogTrackName) => {
4154
+ const staged = this.#staged;
4155
+ const stream = { packetId, ...(catalogTrackName === undefined ? {} : { catalogTrackName }) };
4156
+ return staged !== null && (isStream(stream, staged.from) || isStream(stream, staged.to));
4157
+ },
3736
4158
  onReleased: (data, meta) => {
3737
4159
  if (meta.trackType !== "video" || meta.mpuSequenceNumber === undefined) return;
3738
4160
  const timing = meta.authoredTiming;
@@ -3928,6 +4350,8 @@ export class MmtpRouter {
3928
4350
  const parsed = this.#parser.parsePacket(packet);
3929
4351
  if (!parsed) return;
3930
4352
  const info = parsed.info;
4353
+ // manifestAdmitted is set only by a receiver release or the user agent's own verification.
4354
+ if (manifestAdmitted && !info.isRepair && ingress?.admittedBy !== USER_AGENT_VERIFIED) this.#admittedSourceDatagrams++;
3931
4355
  // Every multicast packet is a delivery-gap sample, including one dropped
3932
4356
  // below: the gap is a property of the link the FEC deadlines run on, not
3933
4357
  // of which packets this router routes. The re-entry after a manifest
@@ -4391,6 +4815,7 @@ export class MmtpRouter {
4391
4815
  if (!overlapCopy && this.#classifyPacketId(info.packetId) !== null) {
4392
4816
  this.#feedOverlaySource(
4393
4817
  info.packetId, catalogTrackName, packet, parsed.header, info.sequenceNumber, info.rapFlag, source,
4818
+ fecPayloadId?.ssId,
4394
4819
  );
4395
4820
  }
4396
4821
  }
@@ -4415,6 +4840,7 @@ export class MmtpRouter {
4415
4840
  deliveryProvenance?: MmtpDeliveryProvenance;
4416
4841
  catalogTrackName?: string;
4417
4842
  fecSbn?: number;
4843
+ fecSbnFirst?: number;
4418
4844
  },
4419
4845
  parsed?: ParsedMmtpPacket,
4420
4846
  ): void {
@@ -4438,6 +4864,7 @@ export class MmtpRouter {
4438
4864
  deliveryProvenance: parserOptions?.deliveryProvenance,
4439
4865
  catalogTrackName: parserOptions?.catalogTrackName,
4440
4866
  ...(parserOptions?.fecSbn === undefined ? {} : { fecSbn: parserOptions.fecSbn }),
4867
+ ...(parserOptions?.fecSbnFirst === undefined ? {} : { fecSbnFirst: parserOptions.fecSbnFirst }),
4441
4868
  };
4442
4869
  if (parsed) this.#parser.feedParsed(packet, parsed, feedOptions);
4443
4870
  else this.#parser.feed(packet, feedOptions);
@@ -4619,6 +5046,7 @@ export class MmtpRouter {
4619
5046
  stream = {
4620
5047
  key: JSON.stringify([packetId, catalogTrackName ?? null]),
4621
5048
  duplicates: new StreamDuplicateLedgers(() => this.duplicateHorizonFor(packetId)),
5049
+ overlayToBase: { routed: new Set(), pending: new Map() },
4622
5050
  };
4623
5051
  byName.set(catalogTrackName, stream);
4624
5052
  }
@@ -4817,8 +5245,29 @@ export class MmtpRouter {
4817
5245
  data: Uint8Array,
4818
5246
  meta: RoutedFrameMeta,
4819
5247
  cb: FrameCallback | null,
5248
+ /** The base SS_ID the FEC release gate ordered the frame by, if any. */
5249
+ fecSbn: number | undefined,
4820
5250
  ): void {
4821
5251
  const streamKey = this.#mediaStreamKey(packetId, meta.catalogTrackName);
5252
+ // The FEC release gate let this frame out in order: only once every
5253
+ // block between it and the frame before it resolved, because it starts
5254
+ // where that frame ended, or (one the overlay rebuilt) once the base
5255
+ // path passed it. Nothing still in flight can precede it then, so it
5256
+ // plays now, as a source frame does; holding it for the next
5257
+ // intact frame cost every repaired burst its length in decoder input
5258
+ // (BLO-52085). An overlay recovery whose base SS_ID is undetermined names
5259
+ // no base block for the gate to wait on, so past a gap it may be ahead of
5260
+ // frames still in flight (an overlay that outran the wire, BLO-53342): it
5261
+ // keeps waiting for the source cursor below, unless it starts where the
5262
+ // last emitted frame ended (on this direct path, the delivered high water).
5263
+ if (this.#isFecTrack(packetId)) {
5264
+ const high = this.#deliveredVideoHighWaterByStream.get(streamKey);
5265
+ if (fecSbn !== undefined || (high !== undefined && authoredAdjacent(high, meta))) {
5266
+ this.#drainDirectRecoveredVideo(streamKey, packetId, frame => frame.timestamp <= meta.timestamp, cb);
5267
+ this.#emitDirectVideo(packetId, data, meta, cb);
5268
+ return;
5269
+ }
5270
+ }
4822
5271
  this.#enqueueDirectRecoveredVideo(packetId, data, meta);
4823
5272
  const lastSourceTs = this.#lastDirectSourceVideoTsByStream.get(streamKey);
4824
5273
  this.#drainDirectRecoveredVideo(
@@ -5056,6 +5505,17 @@ export class MmtpRouter {
5056
5505
  complete?: () => void,
5057
5506
  assisted = false,
5058
5507
  ): boolean {
5508
+ // A packet a keyframe overlay recovery routed before it fed the packet to
5509
+ // this block (#feedOverlayRecoveredToBase) is not routed again.
5510
+ const overlayRouted = ownerPacketId == null
5511
+ ? undefined
5512
+ : this.#streams.get(ownerPacketId)?.get(catalogTrackName)?.overlayToBase.routed;
5513
+ if (overlayRouted !== undefined && overlayRouted.size > 0) {
5514
+ result = {
5515
+ ...result,
5516
+ symbols: result.symbols.filter((sym) => !overlayRouted.has(this.#fecAnchorForRecovered(blockId, sym.index))),
5517
+ };
5518
+ }
5059
5519
  const entry = ownerPacketId == null ? undefined : this.#provenanceFor(ownerPacketId, catalogTrackName);
5060
5520
  const receiver = entry === undefined || entry === "ambiguous" ? entry : entry.receiver;
5061
5521
  const userAgent = !assisted && entry !== undefined && entry !== "ambiguous" && entry.protected === true && entry.userAgent === true;
@@ -5125,15 +5585,25 @@ export class MmtpRouter {
5125
5585
  catalogTrackName?: string,
5126
5586
  /** Set for a keyframe-overlay recovery: `blockId` is an overlay
5127
5587
  * coordinate, which yields no base SS_ID (mmt-02 7.2), and the packets'
5128
- * wire sequences are already restored. */
5129
- overlay?: { deliveryProvenance: MmtpDeliveryProvenance },
5588
+ * wire sequences are already restored. `baseSsIds` holds, by ESI, the
5589
+ * base SS_IDs determined independently: a packet's release-gate anchor;
5590
+ * `firstSsIds` the lowest of them in its MPU (FrameMeta.fecSbnFirst). */
5591
+ overlay?: {
5592
+ deliveryProvenance: MmtpDeliveryProvenance;
5593
+ baseSsIds: ReadonlyMap<number, number>;
5594
+ firstSsIds: ReadonlyMap<number, number>;
5595
+ },
5130
5596
  ): void {
5131
5597
  // Route recovered symbols to the track's callback. The ownerPacketId tells us
5132
5598
  // which track this block belongs to — resolves per-track, then legacy fallback.
5133
5599
  if (ownerPacketId == null) return; // no track association
5134
5600
  if (this.#classifyPacketId(ownerPacketId) === null) return; // unknown track
5135
- const fecSbnOf = (index: number): { fecSbn?: number } =>
5136
- overlay ? {} : { fecSbn: this.#fecAnchorForRecovered(blockId, index) };
5601
+ const fecSbnOf = (index: number): { fecSbn?: number; fecSbnFirst?: number } => {
5602
+ if (!overlay) return { fecSbn: this.#fecAnchorForRecovered(blockId, index) };
5603
+ const fecSbn = overlay.baseSsIds.get(index);
5604
+ const fecSbnFirst = overlay.firstSsIds.get(index);
5605
+ return fecSbn === undefined || fecSbnFirst === undefined ? {} : { fecSbn, fecSbnFirst };
5606
+ };
5137
5607
  // A recovered symbol's header names the sequence the sender protected. A
5138
5608
  // hop that re-stamps packet_sequence_number after protection (cast's
5139
5609
  // publisher-rotation bridge) leaves the decode mixing every received
@@ -5149,7 +5619,10 @@ export class MmtpRouter {
5149
5619
  ...result,
5150
5620
  symbols: result.symbols.map((sym) => {
5151
5621
  const wire = (this.#fecAnchorForRecovered(blockId, sym.index) - ssIdSeqOffset) >>> 0;
5152
- if (wire === sym.packetSequenceNumber) return sym;
5622
+ if (wire === sym.packetSequenceNumber) {
5623
+ this.#unstampedSsIdSeqOffsetByStream.set(streamKey, ssIdSeqOffset);
5624
+ return sym;
5625
+ }
5153
5626
  this.#restampedRecoveredSeqs++;
5154
5627
  this.#restampedStreams.add(streamKey);
5155
5628
  return { ...sym, packetSequenceNumber: wire };
@@ -5206,6 +5679,43 @@ export class MmtpRouter {
5206
5679
  );
5207
5680
  }
5208
5681
 
5682
+ // A recovered head whose sample FT=1 has not named yet (an overlay that
5683
+ // outran the source path) would complete its data unit untimed: the unit
5684
+ // is dropped, marked completed, and the timed source copy is then refused
5685
+ // (BLO-53342). Hand it to the parser instead, which holds it in its
5686
+ // awaiting-timing queue and feeds it again when FT=1 lands; its recovered
5687
+ // continuations park in the reassembler meanwhile. If the source path
5688
+ // lost the head, this held copy is the keyframe (BLO-53369).
5689
+ const untimedHead = (sym: (typeof result.symbols)[number]): boolean =>
5690
+ sym.fragmentType === 2 &&
5691
+ sym.fragment.fragmentationIndicator === 1 &&
5692
+ sym.fragment.movieFragmentSequenceNumber !== undefined &&
5693
+ sym.fragment.sampleNumber !== undefined &&
5694
+ !this.#parser.hasAuthoredTimingFor(
5695
+ ownerPacketId,
5696
+ sym.fragment.mpuSequenceNumber,
5697
+ sym.fragment.movieFragmentSequenceNumber,
5698
+ sym.fragment.sampleNumber,
5699
+ catalogTrackName,
5700
+ );
5701
+ for (const sym of result.symbols) {
5702
+ if (!untimedHead(sym) ||
5703
+ this.#sourceSeqReceived(ownerPacketId, sym.packetSequenceNumber, catalogTrackName)) continue;
5704
+ this.#feedViaParser(
5705
+ sym.rawSymbol,
5706
+ { packetId: ownerPacketId, sequenceNumber: sym.packetSequenceNumber },
5707
+ undefined,
5708
+ {
5709
+ skipFecPayloadId: true,
5710
+ recovered: true,
5711
+ deliveryProvenance,
5712
+ ...fecSbnOf(sym.index),
5713
+ ...(catalogTrackName === undefined ? {} : { catalogTrackName }),
5714
+ },
5715
+ );
5716
+ }
5717
+ result = { ...result, symbols: result.symbols.filter((sym) => !untimedHead(sym)) };
5718
+
5209
5719
  // MSE assembler path: route recovered block as pre-assembled frames (skip FT=0)
5210
5720
  // BLO-3035 layer 3: recovered symbols are RAW MFU fragments, not complete
5211
5721
  // frames. A keyframe can span multiple packets (FI=1/2/3); pushing each