@blockcast/mmt-container 2.2.0-main.75b39a2cdddf → 2.2.0-main.7e8a9d13c01c

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 (52) hide show
  1. package/dist/browser-runtime.js +634 -129
  2. package/dist/browser-runtime.js.map +3 -3
  3. package/dist/delta-fallback-config.js.map +1 -1
  4. package/dist/fec-manager.d.ts +37 -11
  5. package/dist/fec-manager.d.ts.map +1 -1
  6. package/dist/fec-manager.js +181 -52
  7. package/dist/fec-manager.js.map +2 -2
  8. package/dist/fec-worker-setup.js.map +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js.map +2 -2
  12. package/dist/iso-block-locator.d.ts +39 -13
  13. package/dist/iso-block-locator.d.ts.map +1 -1
  14. package/dist/iso-block-locator.js +80 -25
  15. package/dist/iso-block-locator.js.map +2 -2
  16. package/dist/media-router.js.map +1 -1
  17. package/dist/mmtp-container-parser.d.ts +21 -0
  18. package/dist/mmtp-container-parser.d.ts.map +1 -1
  19. package/dist/mmtp-container-parser.js +114 -11
  20. package/dist/mmtp-container-parser.js.map +2 -2
  21. package/dist/mmtp-container-parser.no-modules.js +113 -12
  22. package/dist/mmtp-container-parser.no-modules.js.map +2 -2
  23. package/dist/mmtp-decode-pipeline.js +114 -11
  24. package/dist/mmtp-decode-pipeline.js.map +2 -2
  25. package/dist/mmtp-router.d.ts +18 -9
  26. package/dist/mmtp-router.d.ts.map +1 -1
  27. package/dist/mmtp-router.js +254 -44
  28. package/dist/mmtp-router.js.map +2 -2
  29. package/dist/multicast-manager.d.ts +13 -3
  30. package/dist/multicast-manager.d.ts.map +1 -1
  31. package/dist/multicast-manager.js +455 -103
  32. package/dist/multicast-manager.js.map +2 -2
  33. package/dist/no-modules.js +114 -11
  34. package/dist/no-modules.js.map +2 -2
  35. package/dist/repair-fetch.d.ts +36 -8
  36. package/dist/repair-fetch.d.ts.map +1 -1
  37. package/dist/repair-fetch.js +285 -26
  38. package/dist/repair-fetch.js.map +3 -3
  39. package/dist/track-session.d.ts.map +1 -1
  40. package/dist/track-session.js +1 -0
  41. package/dist/track-session.js.map +2 -2
  42. package/dist/wasm-mmtp-container-parser.js +114 -11
  43. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  44. package/package.json +5 -5
  45. package/src/fec-manager.ts +156 -26
  46. package/src/index.ts +2 -0
  47. package/src/iso-block-locator.ts +114 -31
  48. package/src/mmtp-container-parser.ts +181 -10
  49. package/src/mmtp-router.ts +231 -58
  50. package/src/multicast-manager.ts +44 -6
  51. package/src/repair-fetch.ts +363 -49
  52. package/src/track-session.ts +3 -0
@@ -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, RebuiltSourceFeed, 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 {
@@ -216,7 +216,9 @@ export interface KeyframeOverlayStats {
216
216
  protectedDropped: number;
217
217
  /** Rebuilt packets fed to their base block's decoder (joint decoding). */
218
218
  baseFed: number;
219
- /** Rebuilt packets the base never took: no base SS_ID could be determined. */
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). */
220
222
  baseUndetermined: number;
221
223
  }
222
224
 
@@ -260,12 +262,12 @@ function overlayBaseSsId(
260
262
  * one per FEC source packet (mmt-02 7.1) and psn by one per packet, so on a
261
263
  * track whose every packet is an FEC source packet the sender keeps
262
264
  * SS_ID - psn fixed. `offset` (SS_ID - psn of the stream's latest received
263
- * source) is that sender value while a base recovery showed it
264
- * (`unstampedOffset`): a recovered packet's header psn equal to its SS_ID
265
- * minus the same offset. A hop that re-stamps psn moves the offset away from
266
- * the one shown, which voids it, as does a packet with no SS_ID between the
267
- * recovered packet that showed it and the latest received source (the shown
268
- * span). Undefined without both.
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.
269
271
  * ponytail: exact only if every packet from the rebuilt one to the latest
270
272
  * received source is an FEC source packet, as FFmpeg's muxer sends a track
271
273
  * it protects. fec-core 8.3 lets a publisher leave one unprotected
@@ -313,10 +315,18 @@ interface OverlayInstance {
313
315
  readonly offsets: Map<number, OverlayMemberAnchor>;
314
316
  /** Overlay blocks a MoQ symbol fed (multicast-02 7.2.7). */
315
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>;
316
321
  }
317
322
 
318
323
  /** Offsets kept per overlay, one per MPU: a keyframe's fragments share one. */
319
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;
320
330
  /** A MoQ-fed overlay block is forgotten once it starts this many K_overlay
321
331
  * behind the newest overlay SS_ID: the overlay locator, which keeps a fixed
322
332
  * few blocks (no catalog timing), has retired it long before, so it can no
@@ -833,13 +843,14 @@ export interface FECManagerLike {
833
843
 
834
844
  /** Track a first-arrival network source symbol for block completion and
835
845
  * classification. Refreshes the inactivity deadline and marks clean when
836
- * K arrive. */
846
+ * K arrive. `held`: a source held unplaced since it arrived, placed now. */
837
847
  trackBlock(
838
848
  sbn: number,
839
849
  packetId: number,
840
850
  catalogTrackName: string | undefined,
841
851
  esi: number,
842
852
  evidence: SourceSymbolEvidence,
853
+ held?: boolean,
843
854
  ): void;
844
855
 
845
856
  /** True once the packetId has a per-track FEC configuration. */
@@ -1009,6 +1020,7 @@ export interface FECManagerLike {
1009
1020
  packetId: number,
1010
1021
  catalogTrackName: string | undefined,
1011
1022
  blockClass: BlockClass,
1023
+ span?: IsoSpanFailure,
1012
1024
  ) => void)
1013
1025
  | null;
1014
1026
 
@@ -1155,6 +1167,17 @@ export class MmtpRouter {
1155
1167
  * an MPU no member of which arrived).
1156
1168
  */
1157
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>();
1158
1181
  /**
1159
1182
  * Streams whose multicast leg delivered a RAP-flagged source packet that was
1160
1183
  * not parsed, typically because its overlap copy was parsed first
@@ -1203,6 +1226,7 @@ export class MmtpRouter {
1203
1226
  packetId: number,
1204
1227
  catalogTrackName: string | undefined,
1205
1228
  blockClass: BlockClass,
1229
+ span?: IsoSpanFailure,
1206
1230
  ) => void)
1207
1231
  | null = null;
1208
1232
  /** Typed observer of every gap decision the release gate makes. */
@@ -1300,6 +1324,7 @@ export class MmtpRouter {
1300
1324
  packetId: number,
1301
1325
  catalogTrackName: string | undefined,
1302
1326
  blockClass: BlockClass,
1327
+ span?: IsoSpanFailure,
1303
1328
  ) => void;
1304
1329
  /** Internal gap-decision sink owned by the composing transport. */
1305
1330
  onFrameGap?: (decision: FrameGapDecision) => void;
@@ -1533,6 +1558,7 @@ export class MmtpRouter {
1533
1558
  packetId: number,
1534
1559
  catalogTrackName: string | undefined,
1535
1560
  blockClass: BlockClass,
1561
+ span?: IsoSpanFailure,
1536
1562
  ) => void)
1537
1563
  | undefined,
1538
1564
  ): void {
@@ -1585,6 +1611,7 @@ export class MmtpRouter {
1585
1611
  packetId: number,
1586
1612
  catalogTrackName: string | undefined,
1587
1613
  blockClass: BlockClass,
1614
+ span?: IsoSpanFailure,
1588
1615
  ) => {
1589
1616
  this.#mseAssemblerFor(packetId, catalogTrackName)?.failBlock(sbn);
1590
1617
  if (
@@ -1593,8 +1620,13 @@ export class MmtpRouter {
1593
1620
  ) {
1594
1621
  this.#onDeltaBlockCompleteCb?.(packetId, true); // failed=true
1595
1622
  }
1596
- onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass);
1597
- 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
+ }
1598
1630
  };
1599
1631
  }
1600
1632
  }
@@ -1647,8 +1679,9 @@ export class MmtpRouter {
1647
1679
 
1648
1680
  /**
1649
1681
  * FEC-recovered source symbols whose reconstructed header named a
1650
- * packet_sequence_number other than the wire sequence their SS_ID maps to:
1651
- * 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.
1652
1685
  */
1653
1686
  get restampedRecoveredSequenceCount(): number {
1654
1687
  return this.#restampedRecoveredSeqs;
@@ -1872,6 +1905,11 @@ export class MmtpRouter {
1872
1905
  ): CmafBlockAssembler {
1873
1906
  const assemblerKey = this.#mseAssemblerKey(packetId, catalogTrackName);
1874
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);
1875
1913
  if (previous) {
1876
1914
  previous.flush();
1877
1915
  previous.dispose();
@@ -1943,6 +1981,7 @@ export class MmtpRouter {
1943
1981
  packetId: number,
1944
1982
  catalogTrackName: string | undefined,
1945
1983
  blockClass: BlockClass,
1984
+ span?: IsoSpanFailure,
1946
1985
  ) => void,
1947
1986
  ): void {
1948
1987
  this.#onBlockFailed = cb;
@@ -2047,7 +2086,7 @@ export class MmtpRouter {
2047
2086
  const leg = this.#overlayLegs.get(repairPacketId);
2048
2087
  if (!leg) throw new Error(`[MmtpRouter] packetId=${repairPacketId} is no known keyframe-overlay leg`);
2049
2088
  const inst: OverlayInstance = {
2050
- 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(),
2051
2090
  };
2052
2091
  this.#overlays.set(repairPacketId, inst);
2053
2092
  return () => {
@@ -2096,6 +2135,7 @@ export class MmtpRouter {
2096
2135
  inst.last = undefined;
2097
2136
  inst.offsets.clear();
2098
2137
  inst.moqBlocks.clear();
2138
+ inst.rebuilt.clear();
2099
2139
  }
2100
2140
 
2101
2141
  #overlaysForSource(sourcePacketId: number, catalogTrackName: string | undefined): OverlayInstance[] {
@@ -2168,6 +2208,13 @@ export class MmtpRouter {
2168
2208
  * base protects: the packet minus its 4-byte SS_ID trailer, extension
2169
2209
  * included (7.1, 7.4.3). `baseSsId` is that trailer's SS_ID, when the base
2170
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).
2171
2218
  */
2172
2219
  #feedOverlaySource(
2173
2220
  packetId: number,
@@ -2182,8 +2229,12 @@ export class MmtpRouter {
2182
2229
  if (this.#overlays.size === 0) return;
2183
2230
  const ssId = keyframeOverlaySsId(header);
2184
2231
  if (ssId === undefined) return;
2232
+ let rebuiltPsn: number | undefined;
2185
2233
  for (const inst of this.#overlaysForSource(packetId, catalogTrackName)) {
2186
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);
2187
2238
  const identity = MmtpContainerParser.sourcePacketIdentity(packet, header);
2188
2239
  if (identity !== null) {
2189
2240
  // The newest member anchors its MPU; one with no base SS_ID leaves the
@@ -2221,6 +2272,18 @@ export class MmtpRouter {
2221
2272
  const recovered = fec.feedSourceSymbol(pid, ssId, symbolData, name);
2222
2273
  if (recovered && !alreadyCompleted) this.#routeOverlayRecovered(inst, coords.sbn, recovered);
2223
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
+ }
2224
2287
  }
2225
2288
 
2226
2289
  #noteOverlayMoqBlock(inst: OverlayInstance, blockStart: number): void {
@@ -2261,11 +2324,16 @@ export class MmtpRouter {
2261
2324
 
2262
2325
  /**
2263
2326
  * Route an overlay-recovered block's packets as source media of the
2264
- * overlay's source (7.2: an overlay coordinate yields no base SS_ID). No
2265
- * base-SS_ID derivation runs: no release-gate anchor, no parse ledger, no
2266
- * base re-stamp, no block advance. Each packet's wire sequence is restored
2267
- * from its keyframe's own offset; without one, a stream the base never
2268
- * 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.
2269
2337
  */
2270
2338
  #routeOverlayRecovered(inst: OverlayInstance, blockStart: number, recovered: Uint8Array): void {
2271
2339
  const { fec, repairPacketId: pid, streamName: name } = inst;
@@ -2288,51 +2356,96 @@ export class MmtpRouter {
2288
2356
  const streamKey = this.#mediaStreamKey(sourcePacketId, sourceName);
2289
2357
  const restamped = this.#restampedStreams.has(streamKey);
2290
2358
  const restored = new Set<number>();
2291
- const baseSsIds = new Map<ProcessFecBlockResult["symbols"][number], 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>();
2292
2363
  const symbols = result.symbols.flatMap((sym) => {
2364
+ const overlaySsId = serial32Add(blockStart, sym.index);
2293
2365
  const anchor = inst.offsets.get(sym.fragment.mpuSequenceNumber);
2294
2366
  if (anchor !== undefined) {
2295
- const overlaySsId = serial32Add(blockStart, sym.index);
2296
2367
  const packetSequenceNumber = (overlaySsId - anchor.psnOffset) >>> 0;
2297
2368
  restored.add(packetSequenceNumber);
2298
2369
  const out = { ...sym, packetSequenceNumber };
2299
2370
  const baseSsId = overlayBaseSsId(anchor, overlaySsId, packetSequenceNumber, sym.packetSequenceNumber);
2300
- if (baseSsId !== undefined) baseSsIds.set(out, baseSsId);
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
+ }
2301
2387
  return [out];
2302
2388
  }
2303
2389
  // A stream re-stamped by a bridge that no base recovery has shown yet
2304
2390
  // keeps the header's sequence too: from received packets alone the
2305
- // router cannot tell (their psn is the wire one). Base recovery or a
2306
- // FETCH still covers such a keyframe.
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.
2307
2394
  if (!restamped) {
2308
- // No member of this MPU arrived to anchor a base SS_ID. A base
2309
- // recovery that showed the stream unstamped at its current offset
2310
- // determines one from the packet's protected psn instead
2311
- // (unstampedBaseSsId).
2312
- const baseSsId = unstampedBaseSsId(
2313
- this.#ssIdSeqOffsetByStream.get(streamKey),
2314
- this.#unstampedSsIdSeqOffsetByStream.get(streamKey),
2315
- sym.packetSequenceNumber,
2316
- );
2317
- if (baseSsId !== undefined) baseSsIds.set(sym, baseSsId);
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
+ }
2318
2413
  return [sym];
2319
2414
  }
2320
2415
  this.#overlayStats.unsequencedDropped++;
2321
2416
  return [];
2322
2417
  });
2323
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
+ }
2324
2432
  this.#routeProcessedFecSymbols(blockStart, { ...result, symbols }, sourcePacketId, sourceName, {
2325
2433
  deliveryProvenance: inst.moqBlocks.has(blockStart) ? "mixed" : "multicast",
2434
+ baseSsIds,
2435
+ firstSsIds,
2326
2436
  });
2327
2437
  this.#noteOverlayRecoveredRaps(sourcePacketId, sourceName, streamKey, symbols.filter((sym) =>
2328
2438
  restored.has(sym.packetSequenceNumber)));
2329
- this.#feedOverlayRecoveredToBase(inst, blockStart, symbols, baseSsIds);
2439
+ this.#feedOverlayRecoveredToBase(inst, blockStart, symbols, gateOnly.size === 0
2440
+ ? baseSsIds
2441
+ : new Map([...baseSsIds].filter(([index]) => !gateOnly.has(index))));
2330
2442
  }
2331
2443
 
2332
2444
  /**
2333
2445
  * Joint decoding (BLO-52085). A packet the overlay rebuilt is a source of
2334
2446
  * its base block too, once its base SS_ID is determined (overlayBaseSsId,
2335
- * from a received member of its MPU, else unstampedBaseSsId),
2447
+ * from a received member of its MPU, else unstampedBaseSsId at an offset a
2448
+ * base recovery showed; `baseSsIds` holds no other),
2336
2449
  * so it is fed to the base decoder as a rebuilt source
2337
2450
  * (FECManager.feedRebuiltSourceSymbol): the base block has one erasure
2338
2451
  * fewer to solve, and its repair can recover the block's other packets.
@@ -2352,7 +2465,7 @@ export class MmtpRouter {
2352
2465
  inst: OverlayInstance,
2353
2466
  blockStart: number,
2354
2467
  symbols: ProcessFecBlockResult["symbols"],
2355
- baseSsIds: ReadonlyMap<ProcessFecBlockResult["symbols"][number], number>,
2468
+ baseSsIds: ReadonlyMap<number, number>,
2356
2469
  ): void {
2357
2470
  const fec = this.#fecManager;
2358
2471
  const owner = inst.leg.sourcePacketId;
@@ -2364,7 +2477,7 @@ export class MmtpRouter {
2364
2477
  for (const sym of symbols) {
2365
2478
  // A packet the base received already sits in its block.
2366
2479
  if (duplicates.sourceSeqs.has(sym.packetSequenceNumber)) continue;
2367
- const ssId = baseSsIds.get(sym);
2480
+ const ssId = baseSsIds.get(sym.index);
2368
2481
  if (ssId === undefined) {
2369
2482
  this.#overlayStats.baseUndetermined++;
2370
2483
  continue;
@@ -3312,7 +3425,7 @@ export class MmtpRouter {
3312
3425
  catalogTrackName,
3313
3426
  )) continue;
3314
3427
  if (fec.isTrackConfigured?.(ownerPacketId) !== false) {
3315
- fec.trackBlock(blockStart, ownerPacketId, catalogTrackName, esi, payload.evidence);
3428
+ fec.trackBlock(blockStart, ownerPacketId, catalogTrackName, esi, payload.evidence, true);
3316
3429
  this.#requestFecPoke(ownerPacketId, catalogTrackName);
3317
3430
  }
3318
3431
  if (payload.oversized) continue;
@@ -3431,6 +3544,7 @@ export class MmtpRouter {
3431
3544
  this.#latestVideoSeqByStream.delete(streamKey);
3432
3545
  this.#ssIdSeqOffsetByStream.delete(streamKey);
3433
3546
  this.#unstampedSsIdSeqOffsetByStream.delete(streamKey);
3547
+ this.#overlayUnstampedSsIdSeqOffsetByStream.delete(streamKey);
3434
3548
  this.#multicastRapTwinByStream.delete(streamKey);
3435
3549
  this.#lastDirectSourceVideoTsByStream.delete(streamKey);
3436
3550
  this.#lastDirectEmittedVideoTsByStream.delete(streamKey);
@@ -3562,6 +3676,12 @@ export class MmtpRouter {
3562
3676
  * the outgoing stream stops. The hold is bounded by the incoming stream's
3563
3677
  * own RAP cadence, so no timer is involved.
3564
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
+ *
3565
3685
  * The commit is one synchronous transition inside the frame path: the
3566
3686
  * outgoing stream's already-admitted frames are released (its queued FEC
3567
3687
  * gap fills only up to the RAP, since recovery can queue a frame ahead of
@@ -3614,6 +3734,16 @@ export class MmtpRouter {
3614
3734
  droppedIncomingFrames: 0,
3615
3735
  };
3616
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
+ }
3617
3747
  return {
3618
3748
  cancel: () => {
3619
3749
  if (this.#staged !== state) return false;
@@ -3708,7 +3838,24 @@ export class MmtpRouter {
3708
3838
  // same call. Nothing registers a packet ID mid-frame, so this drops
3709
3839
  // only frames that lost their registration.
3710
3840
  if (this.#classifyPacketId(meta.packetId) === null) return;
3711
- 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
+ }
3712
3859
  this.#deliverParsedFrame(data, meta);
3713
3860
  }
3714
3861
 
@@ -3901,12 +4048,17 @@ export class MmtpRouter {
3901
4048
  this.#parser.deferFecRelease(work);
3902
4049
  } finally {
3903
4050
  this.#fecPokeDepth--;
3904
- if (this.#fecPokeDepth === 0 && this.#pendingFecPokes.size > 0 && !this.#disposed) {
3905
- const pokes = [...this.#pendingFecPokes.values()];
3906
- this.#pendingFecPokes.clear();
3907
- for (const poke of pokes) {
3908
- this.#parser.pokeFecRelease(poke.packetId, poke.catalogTrackName);
3909
- }
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);
3910
4062
  }
3911
4063
  }
3912
4064
  }
@@ -3995,6 +4147,14 @@ export class MmtpRouter {
3995
4147
  onRap: (packetId, catalogTrackName, ssId) => {
3996
4148
  for (const observer of this.#fecRapObservers) observer(packetId, catalogTrackName, ssId);
3997
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
+ },
3998
4158
  onReleased: (data, meta) => {
3999
4159
  if (meta.trackType !== "video" || meta.mpuSequenceNumber === undefined) return;
4000
4160
  const timing = meta.authoredTiming;
@@ -4680,6 +4840,7 @@ export class MmtpRouter {
4680
4840
  deliveryProvenance?: MmtpDeliveryProvenance;
4681
4841
  catalogTrackName?: string;
4682
4842
  fecSbn?: number;
4843
+ fecSbnFirst?: number;
4683
4844
  },
4684
4845
  parsed?: ParsedMmtpPacket,
4685
4846
  ): void {
@@ -4703,6 +4864,7 @@ export class MmtpRouter {
4703
4864
  deliveryProvenance: parserOptions?.deliveryProvenance,
4704
4865
  catalogTrackName: parserOptions?.catalogTrackName,
4705
4866
  ...(parserOptions?.fecSbn === undefined ? {} : { fecSbn: parserOptions.fecSbn }),
4867
+ ...(parserOptions?.fecSbnFirst === undefined ? {} : { fecSbnFirst: parserOptions.fecSbnFirst }),
4706
4868
  };
4707
4869
  if (parsed) this.#parser.feedParsed(packet, parsed, feedOptions);
4708
4870
  else this.#parser.feed(packet, feedOptions);
@@ -5088,15 +5250,16 @@ export class MmtpRouter {
5088
5250
  ): void {
5089
5251
  const streamKey = this.#mediaStreamKey(packetId, meta.catalogTrackName);
5090
5252
  // The FEC release gate let this frame out in order: only once every
5091
- // block between it and the frame before it resolved, or because it
5092
- // starts where that frame ended. Nothing still in flight can precede it
5093
- // then, so it plays now, as a source frame does; holding it for the next
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
5094
5257
  // intact frame cost every repaired burst its length in decoder input
5095
- // (BLO-52085). An overlay recovery names no base block for the gate to
5096
- // wait on, so past a gap it may be ahead of frames still in flight (an
5097
- // overlay that outran the wire, BLO-53342): it keeps waiting for the
5098
- // source cursor below, unless it starts where the last emitted frame
5099
- // ended (on this direct path, the delivered high water).
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).
5100
5263
  if (this.#isFecTrack(packetId)) {
5101
5264
  const high = this.#deliveredVideoHighWaterByStream.get(streamKey);
5102
5265
  if (fecSbn !== undefined || (high !== undefined && authoredAdjacent(high, meta))) {
@@ -5422,15 +5585,25 @@ export class MmtpRouter {
5422
5585
  catalogTrackName?: string,
5423
5586
  /** Set for a keyframe-overlay recovery: `blockId` is an overlay
5424
5587
  * coordinate, which yields no base SS_ID (mmt-02 7.2), and the packets'
5425
- * wire sequences are already restored. */
5426
- 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
+ },
5427
5596
  ): void {
5428
5597
  // Route recovered symbols to the track's callback. The ownerPacketId tells us
5429
5598
  // which track this block belongs to — resolves per-track, then legacy fallback.
5430
5599
  if (ownerPacketId == null) return; // no track association
5431
5600
  if (this.#classifyPacketId(ownerPacketId) === null) return; // unknown track
5432
- const fecSbnOf = (index: number): { fecSbn?: number } =>
5433
- 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
+ };
5434
5607
  // A recovered symbol's header names the sequence the sender protected. A
5435
5608
  // hop that re-stamps packet_sequence_number after protection (cast's
5436
5609
  // publisher-rotation bridge) leaves the decode mixing every received