@blockcast/mmt-container 2.1.0 → 2.2.0-main.079cd7664c0b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +29 -17
  2. package/dist/browser-runtime.js +1271 -247
  3. package/dist/browser-runtime.js.map +4 -4
  4. package/dist/carp-catalog.js.map +1 -1
  5. package/dist/catalog-fec-params.d.ts +17 -1
  6. package/dist/catalog-fec-params.d.ts.map +1 -1
  7. package/dist/catalog-fec-params.js +15 -0
  8. package/dist/catalog-fec-params.js.map +2 -2
  9. package/dist/catalog-network-source.js.map +1 -1
  10. package/dist/cmcd-producer.d.ts +65 -47
  11. package/dist/cmcd-producer.d.ts.map +1 -1
  12. package/dist/cmcd-producer.js +138 -109
  13. package/dist/cmcd-producer.js.map +2 -2
  14. package/dist/delivery-catalog.js +26 -16
  15. package/dist/delivery-catalog.js.map +2 -2
  16. package/dist/delta-fallback-config.js.map +1 -1
  17. package/dist/fec-manager.d.ts +33 -1
  18. package/dist/fec-manager.d.ts.map +1 -1
  19. package/dist/fec-manager.js +213 -6
  20. package/dist/fec-manager.js.map +2 -2
  21. package/dist/fec-state.js.map +1 -1
  22. package/dist/fec-worker-setup.d.ts +1 -1
  23. package/dist/fec-worker-setup.js +1 -1
  24. package/dist/fec-worker-setup.js.map +1 -1
  25. package/dist/held-layer-admission.js.map +1 -1
  26. package/dist/index.d.ts +5 -4
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +9 -1
  29. package/dist/index.js.map +2 -2
  30. package/dist/iso-block-locator.d.ts +9 -0
  31. package/dist/iso-block-locator.d.ts.map +1 -1
  32. package/dist/iso-block-locator.js +17 -0
  33. package/dist/iso-block-locator.js.map +2 -2
  34. package/dist/iso-bmff-timing.d.ts +10 -1
  35. package/dist/iso-bmff-timing.d.ts.map +1 -1
  36. package/dist/iso-bmff-timing.js +36 -2
  37. package/dist/iso-bmff-timing.js.map +2 -2
  38. package/dist/media-router.js.map +1 -1
  39. package/dist/mfu-reassembler.js +12 -19
  40. package/dist/mfu-reassembler.js.map +2 -2
  41. package/dist/mmtp-container-parser.d.ts +37 -2
  42. package/dist/mmtp-container-parser.d.ts.map +1 -1
  43. package/dist/mmtp-container-parser.js +98 -28
  44. package/dist/mmtp-container-parser.js.map +2 -2
  45. package/dist/mmtp-container-parser.no-modules.js +222 -45
  46. package/dist/mmtp-container-parser.no-modules.js.map +3 -3
  47. package/dist/mmtp-decode-pipeline.js +97 -28
  48. package/dist/mmtp-decode-pipeline.js.map +2 -2
  49. package/dist/mmtp-fec-session.d.ts +41 -3
  50. package/dist/mmtp-fec-session.d.ts.map +1 -1
  51. package/dist/mmtp-fec-session.js +118 -13
  52. package/dist/mmtp-fec-session.js.map +3 -3
  53. package/dist/mmtp-router.d.ts +25 -2
  54. package/dist/mmtp-router.d.ts.map +1 -1
  55. package/dist/mmtp-router.js +1718 -54
  56. package/dist/mmtp-router.js.map +4 -4
  57. package/dist/multicast-auth-catalog.js.map +1 -1
  58. package/dist/multicast-input.js.map +1 -1
  59. package/dist/multicast-manager.d.ts +15 -2
  60. package/dist/multicast-manager.d.ts.map +1 -1
  61. package/dist/multicast-manager.js +15652 -14939
  62. package/dist/multicast-manager.js.map +4 -4
  63. package/dist/multicast-transport.d.ts +11 -3
  64. package/dist/multicast-transport.d.ts.map +1 -1
  65. package/dist/multicast-transport.js +281 -35
  66. package/dist/multicast-transport.js.map +4 -4
  67. package/dist/multicast-unwrap.js +12 -19
  68. package/dist/multicast-unwrap.js.map +2 -2
  69. package/dist/no-modules.js +215 -42
  70. package/dist/no-modules.js.map +3 -3
  71. package/dist/repair-fetch.js.map +1 -1
  72. package/dist/repair-layer-subscriptions.d.ts +82 -0
  73. package/dist/repair-layer-subscriptions.d.ts.map +1 -0
  74. package/dist/repair-layer-subscriptions.js +192 -0
  75. package/dist/repair-layer-subscriptions.js.map +7 -0
  76. package/dist/signaling.js.map +2 -2
  77. package/dist/timestamp-codec.js +12 -19
  78. package/dist/timestamp-codec.js.map +2 -2
  79. package/dist/track-session.d.ts +1 -1
  80. package/dist/track-session.js.map +1 -1
  81. package/dist/wasm-mfu-reassembler.js.map +1 -1
  82. package/dist/wasm-mmtp-container-parser.js +97 -28
  83. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  84. package/package.json +8 -5
  85. package/schemas/catalog.schema.json +4 -0
  86. package/src/catalog-fec-params.ts +36 -1
  87. package/src/cmcd-producer.ts +194 -179
  88. package/src/fec-manager.ts +256 -6
  89. package/src/fec-worker-setup.ts +2 -2
  90. package/src/index.ts +18 -2
  91. package/src/iso-block-locator.ts +19 -0
  92. package/src/iso-bmff-timing.ts +40 -1
  93. package/src/mmtp-container-parser.ts +96 -8
  94. package/src/mmtp-fec-session.ts +115 -15
  95. package/src/mmtp-router.ts +344 -27
  96. package/src/multicast-manager.ts +26 -5
  97. package/src/multicast-transport.ts +44 -20
  98. package/src/repair-layer-subscriptions.ts +261 -0
  99. package/src/track-session.ts +1 -1
@@ -23,7 +23,8 @@ import {
23
23
  type MMTPHeader,
24
24
  type MoqRepairFrame,
25
25
  } from "@blockcast/mmt-base";
26
- import type { FECManager, IsoHeldSource, WasmFecDecoder } from "./fec-manager.js";
26
+ import { USER_AGENT_VERIFIED } from "@blockcast/mmt-transport";
27
+ import type { FECManager, IsoHeldSource, RebuiltSourceFeed, WasmFecDecoder } from "./fec-manager.js";
27
28
  import type { IsoPlacedSource, IsoRepairPlacement } from "./iso-block-locator.js";
28
29
  import { isMfuReassemblyError } from "./mfu-reassembler.js";
29
30
  import type {
@@ -47,10 +48,12 @@ import {
47
48
  } from "./cmaf-block-assembler.js";
48
49
  import {
49
50
  MmtpContainerParser,
51
+ authoredAdjacent,
50
52
  isMfuKeyframeMetadata,
51
53
  type FecReleaseGate,
52
54
  type FrameMeta as ParserFrameMeta,
53
55
  type ParsedMmtpPacket,
56
+ type MmtpRecoveredHeadTimingStats,
54
57
  } from "./mmtp-container-parser.js";
55
58
  import { type TrackRole } from "./carp-catalog.js";
56
59
  import {
@@ -211,6 +214,61 @@ export interface KeyframeOverlayStats {
211
214
  restarts: number;
212
215
  unsequencedDropped: number;
213
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
+ baseUndetermined: number;
221
+ }
222
+
223
+ /**
224
+ * The newest received overlay member of one MPU. `psnOffset` (overlay SS_ID
225
+ * minus wire packet_sequence_number) restores a rebuilt packet's wire
226
+ * sequence; the member's own SS_IDs anchor a rebuilt packet's base SS_ID
227
+ * (overlayBaseSsId).
228
+ */
229
+ interface OverlayMemberAnchor {
230
+ readonly psnOffset: number;
231
+ readonly overlaySsId: number;
232
+ /** The member's base SS_ID, from its Source FEC Payload ID. */
233
+ readonly baseSsId?: number;
234
+ }
235
+
236
+ /**
237
+ * The base SS_ID of a packet the overlay rebuilt at `overlaySsId`, which an
238
+ * overlay coordinate alone never yields (draft-ramadan-moq-mmt-02 7.1). Both
239
+ * SS_IDs advance by one per packet along a run of consecutive overlay
240
+ * members, so base minus overlay SS_ID is the anchor's along a run the wire
241
+ * proves to be all members: one over which the rebuilt packet's protected
242
+ * packet_sequence_number (`headerPsn`) advanced from the anchor's exactly as
243
+ * the overlay SS_ID did (`restoredPsn`), so no other packet lies inside it.
244
+ * A hop that re-stamped packet_sequence_number after protection fails the
245
+ * test. Undefined when it fails.
246
+ */
247
+ function overlayBaseSsId(
248
+ anchor: OverlayMemberAnchor,
249
+ overlaySsId: number,
250
+ restoredPsn: number,
251
+ headerPsn: number,
252
+ ): number | undefined {
253
+ if (anchor.baseSsId === undefined || headerPsn !== restoredPsn) return undefined;
254
+ return serial32Add(anchor.baseSsId, (overlaySsId - anchor.overlaySsId) | 0);
255
+ }
256
+
257
+ /** Base SS_IDs of overlay-routed packets kept per stream (see OverlayToBase). */
258
+ const OVERLAY_ROUTED_LIMIT = 1024;
259
+ /** Rebuilt sources kept per stream for a base block no repair stated yet. */
260
+ const OVERLAY_PENDING_LIMIT = 256;
261
+
262
+ /**
263
+ * One stream's keyframe-overlay recoveries on their way into its base
264
+ * decoder (joint decoding, BLO-52085).
265
+ */
266
+ interface OverlayToBase {
267
+ /** Base SS_IDs of the packets an overlay recovery routed (as media, or held
268
+ * for their FT=1): a base recovery does not route them a second time. */
269
+ readonly routed: Set<number>;
270
+ /** Rebuilt sources, by base SS_ID, whose base block no repair stated yet. */
271
+ readonly pending: Map<number, Uint8Array>;
214
272
  }
215
273
 
216
274
  interface OverlayInstance {
@@ -222,8 +280,8 @@ interface OverlayInstance {
222
280
  readonly k: number;
223
281
  /** The newest overlay symbol seen: its overlay SS_ID and wire sequence. */
224
282
  last?: { ssId: number; psn: number };
225
- /** MPU sequence number → overlay SS_ID − wire packet_sequence_number. */
226
- readonly offsets: Map<number, number>;
283
+ /** MPU sequence number → its newest received member. */
284
+ readonly offsets: Map<number, OverlayMemberAnchor>;
227
285
  /** Overlay blocks a MoQ symbol fed (multicast-02 7.2.7). */
228
286
  readonly moqBlocks: Set<number>;
229
287
  }
@@ -245,6 +303,14 @@ export interface MmtpIngressContext {
245
303
  * the same ingress and `feedPacket` splits them off.
246
304
  */
247
305
  flow?: string;
306
+ /**
307
+ * `USER_AGENT_VERIFIED` when the datagram came from the browser's own
308
+ * MulticastReceiver, which verified the flow's records itself and delivers
309
+ * only admitted data (`userAgentVerifiedIngress` in
310
+ * `@blockcast/mmt-transport`). A protected multicast datagram carrying it
311
+ * is admitted without a record. Any other value means nothing.
312
+ */
313
+ admittedBy?: symbol;
248
314
  }
249
315
 
250
316
  /** One physical stream: an MMTP packet ID and the catalog track it carries. */
@@ -502,6 +568,9 @@ export type RoutedPacketMeta = {
502
568
  mmtpTimestamp: bigint;
503
569
  /**
504
570
  * The MMTP header RAP flag: the packet starts a random access point. On a
571
+ * track under a keyframe overlay it is also set on the FT=0 and FT=1 data
572
+ * units that govern a covered keyframe (draft-ramadan-moq-mmt-02 7.4.2), so
573
+ * it can precede the keyframe's first MFU fragment. On a
505
574
  * `recovered` packet it is read from the FEC-reconstructed header, which is
506
575
  * byte-exact outside packet_sequence_number, so it is the flag the
507
576
  * publisher sent. The router's own gap decisions deliberately ignore it for
@@ -565,6 +634,7 @@ interface StreamLedgers {
565
634
  * the pair.
566
635
  */
567
636
  readonly duplicates: StreamDuplicateLedgers;
637
+ readonly overlayToBase: OverlayToBase;
568
638
  }
569
639
 
570
640
  const NO_FEC_MANAGER_HORIZON: DuplicateHorizon = Object.freeze({
@@ -652,6 +722,17 @@ export interface FECManagerLike {
652
722
  catalogTrackName?: string,
653
723
  ): Uint8Array | null;
654
724
 
725
+ /** Feed a source another FEC instance over the same packets rebuilt (a
726
+ * keyframe overlay) to its base block, which a repair must already have
727
+ * stated; not a network arrival (FECManager.feedRebuiltSourceSymbol).
728
+ * Optional: without it the overlay and the base decode apart. */
729
+ feedRebuiltSourceSymbol?(
730
+ packetId: number,
731
+ ssId: number,
732
+ symbolData: Uint8Array,
733
+ catalogTrackName?: string,
734
+ ): RebuiltSourceFeed;
735
+
655
736
  /** How long ago the stream's oldest open block with an FEC deadline took its first symbol (optional). */
656
737
  oldestOpenBlockAgeMs?(packetId: number, catalogTrackName: string): number | undefined;
657
738
 
@@ -946,6 +1027,15 @@ function sourceSymbolEvidence(
946
1027
  };
947
1028
  }
948
1029
 
1030
+ /** One protected flow: its 7.2.7 receiver, and the ingress its datagrams arrive with. */
1031
+ interface ProvenanceEntry {
1032
+ receiver: BcProvenanceV2Receiver;
1033
+ ingress: MmtpIngressContext;
1034
+ protected?: boolean;
1035
+ /** A datagram the user agent verified admitted the flow (`admittedBy`). */
1036
+ userAgent?: boolean;
1037
+ }
1038
+
949
1039
  export class MmtpRouter {
950
1040
  readonly #parser: MmtpContainerParser;
951
1041
  // Source AND FEC-recovered MFU fragments share ONE reassembler — the parser's
@@ -955,10 +1045,7 @@ export class MmtpRouter {
955
1045
  #defaultFecMapper: FecBlockMapper | null;
956
1046
  #fecMappersByPacketId = new Map<number, FecBlockMapper>();
957
1047
  /** Protected flows: one 7.2.7 receiver each, and the ingress its datagrams arrive with. */
958
- readonly #provenance = new Map<
959
- string,
960
- { receiver: BcProvenanceV2Receiver; ingress: MmtpIngressContext; protected?: boolean }
961
- >();
1048
+ readonly #provenance = new Map<string, ProvenanceEntry>();
962
1049
  #onFlowProtected: ((flow: string) => void) | null = null;
963
1050
  #unadmittedListedDrops = 0;
964
1051
  #unlistedRepairSetAside = 0;
@@ -1106,7 +1193,7 @@ export class MmtpRouter {
1106
1193
  #restampedStreams = new Set<string>();
1107
1194
  #overlayStats: KeyframeOverlayStats = {
1108
1195
  repairDropped: 0, ownerRefused: 0, recovered: 0, restarts: 0,
1109
- unsequencedDropped: 0, protectedDropped: 0,
1196
+ unsequencedDropped: 0, protectedDropped: 0, baseFed: 0, baseUndetermined: 0,
1110
1197
  };
1111
1198
  /** Callback fired when a delta-track FEC block completes or fails. */
1112
1199
  #onDeltaBlockCompleteCb:
@@ -1385,7 +1472,7 @@ export class MmtpRouter {
1385
1472
  const cb = this.#getFrameCallback(packetId);
1386
1473
  if (isVideo && !assembler) {
1387
1474
  if (frameMeta.recovered) {
1388
- this.#routeDirectRecoveredVideo(packetId, data, frameMeta, cb);
1475
+ this.#routeDirectRecoveredVideo(packetId, data, frameMeta, cb, meta.fecSbn);
1389
1476
  } else {
1390
1477
  this.#routeDirectSourceVideo(packetId, data, frameMeta, cb);
1391
1478
  }
@@ -1900,6 +1987,11 @@ export class MmtpRouter {
1900
1987
  return { ...this.#overlayStats };
1901
1988
  }
1902
1989
 
1990
+ /** Recovered FT=2 heads held for their FT=1, and how each left the wait (BLO-53369). */
1991
+ get recoveredHeadsAwaitingTiming(): Readonly<MmtpRecoveredHeadTimingStats> {
1992
+ return this.#parser.recoveredHeadsAwaitingTiming;
1993
+ }
1994
+
1903
1995
  #isOverlayRepair(repairPacketId: number, repairTrackName?: string): boolean {
1904
1996
  return this.#overlayLegs.has(repairPacketId) ||
1905
1997
  (repairTrackName !== undefined && this.#overlayRepairNames.has(repairTrackName));
@@ -1951,6 +2043,13 @@ export class MmtpRouter {
1951
2043
  */
1952
2044
  resetKeyframeOverlays(sourcePacketId: number, catalogTrackName?: string): void {
1953
2045
  for (const inst of this.#overlaysForSource(sourcePacketId, catalogTrackName)) this.#resetOverlay(inst);
2046
+ // The base restarted with it: base SS_IDs kept for joint decoding name
2047
+ // the old epoch's packets.
2048
+ for (const [name, stream] of this.#streams.get(sourcePacketId) ?? []) {
2049
+ if (catalogTrackName !== undefined && name !== catalogTrackName) continue;
2050
+ stream.overlayToBase.routed.clear();
2051
+ stream.overlayToBase.pending.clear();
2052
+ }
1954
2053
  }
1955
2054
 
1956
2055
  #resetOverlay(inst: OverlayInstance): void {
@@ -2028,7 +2127,8 @@ export class MmtpRouter {
2028
2127
  * A source packet that carries a 0x8B01 overlay SS_ID is also a symbol of
2029
2128
  * every overlay installed for its source, at that SS_ID, with the bytes the
2030
2129
  * base protects: the packet minus its 4-byte SS_ID trailer, extension
2031
- * included (7.1, 7.4.3).
2130
+ * included (7.1, 7.4.3). `baseSsId` is that trailer's SS_ID, when the base
2131
+ * FEC reads it.
2032
2132
  */
2033
2133
  #feedOverlaySource(
2034
2134
  packetId: number,
@@ -2038,6 +2138,7 @@ export class MmtpRouter {
2038
2138
  sequenceNumber: number,
2039
2139
  rapFlag: boolean,
2040
2140
  source: MmtpSourcePath,
2141
+ baseSsId: number | undefined,
2041
2142
  ): void {
2042
2143
  if (this.#overlays.size === 0) return;
2043
2144
  const ssId = keyframeOverlaySsId(header);
@@ -2047,7 +2148,11 @@ export class MmtpRouter {
2047
2148
  const identity = MmtpContainerParser.sourcePacketIdentity(packet, header);
2048
2149
  if (identity !== null) {
2049
2150
  inst.offsets.delete(identity.mpuSequenceNumber);
2050
- inst.offsets.set(identity.mpuSequenceNumber, (ssId - sequenceNumber) >>> 0);
2151
+ inst.offsets.set(identity.mpuSequenceNumber, {
2152
+ psnOffset: (ssId - sequenceNumber) >>> 0,
2153
+ overlaySsId: ssId,
2154
+ ...(baseSsId === undefined ? {} : { baseSsId }),
2155
+ });
2051
2156
  if (inst.offsets.size > OVERLAY_OFFSET_MPUS) inst.offsets.delete(inst.offsets.keys().next().value!);
2052
2157
  }
2053
2158
  const { fec, repairPacketId: pid, streamName: name } = inst;
@@ -2134,12 +2239,17 @@ export class MmtpRouter {
2134
2239
  const streamKey = this.#mediaStreamKey(sourcePacketId, sourceName);
2135
2240
  const restamped = this.#restampedStreams.has(streamKey);
2136
2241
  const restored = new Set<number>();
2242
+ const baseSsIds = new Map<ProcessFecBlockResult["symbols"][number], number>();
2137
2243
  const symbols = result.symbols.flatMap((sym) => {
2138
- const offset = inst.offsets.get(sym.fragment.mpuSequenceNumber);
2139
- if (offset !== undefined) {
2140
- const packetSequenceNumber = (serial32Add(blockStart, sym.index) - offset) >>> 0;
2244
+ const anchor = inst.offsets.get(sym.fragment.mpuSequenceNumber);
2245
+ if (anchor !== undefined) {
2246
+ const overlaySsId = serial32Add(blockStart, sym.index);
2247
+ const packetSequenceNumber = (overlaySsId - anchor.psnOffset) >>> 0;
2141
2248
  restored.add(packetSequenceNumber);
2142
- return [{ ...sym, packetSequenceNumber }];
2249
+ const out = { ...sym, packetSequenceNumber };
2250
+ const baseSsId = overlayBaseSsId(anchor, overlaySsId, packetSequenceNumber, sym.packetSequenceNumber);
2251
+ if (baseSsId !== undefined) baseSsIds.set(out, baseSsId);
2252
+ return [out];
2143
2253
  }
2144
2254
  // A stream re-stamped by a bridge that no base recovery has shown yet
2145
2255
  // keeps the header's sequence too: from received packets alone the
@@ -2155,6 +2265,91 @@ export class MmtpRouter {
2155
2265
  });
2156
2266
  this.#noteOverlayRecoveredRaps(sourcePacketId, sourceName, streamKey, symbols.filter((sym) =>
2157
2267
  restored.has(sym.packetSequenceNumber)));
2268
+ this.#feedOverlayRecoveredToBase(inst, blockStart, symbols, baseSsIds);
2269
+ }
2270
+
2271
+ /**
2272
+ * Joint decoding (BLO-52085). A packet the overlay rebuilt is a source of
2273
+ * its base block too, once its base SS_ID is determined (overlayBaseSsId),
2274
+ * so it is fed to the base decoder as a rebuilt source
2275
+ * (FECManager.feedRebuiltSourceSymbol): the base block has one erasure
2276
+ * fewer to solve, and its repair can recover the block's other packets.
2277
+ * A base block no repair has stated yet takes it when one does
2278
+ * (#feedPendingRebuilt).
2279
+ *
2280
+ * The overlay route already handled each packet fed (delivered it, or
2281
+ * held an untimed head for its FT=1), so a base recovery routes none of
2282
+ * them again (#routeVerifiedFecSymbols): a second recovered copy of a
2283
+ * held head would wait in the parser for timing its first copy consumed.
2284
+ * Only an overlay block decoded from multicast symbols alone is fed: with
2285
+ * a MoQ symbol in it, the base block's recovery would depend on bytes
2286
+ * that crossed no multicast socket, which the provenance counted from the
2287
+ * block's own arrivals cannot show.
2288
+ */
2289
+ #feedOverlayRecoveredToBase(
2290
+ inst: OverlayInstance,
2291
+ blockStart: number,
2292
+ symbols: ProcessFecBlockResult["symbols"],
2293
+ baseSsIds: ReadonlyMap<ProcessFecBlockResult["symbols"][number], number>,
2294
+ ): void {
2295
+ const fec = this.#fecManager;
2296
+ const owner = inst.leg.sourcePacketId;
2297
+ const name = inst.leg.sourceCatalogTrackName;
2298
+ if (!fec?.feedRebuiltSourceSymbol || inst.moqBlocks.has(blockStart)) return;
2299
+ if (this.#classifyPacketId(owner) === null || fec.isTrackConfigured?.(owner) === false) return;
2300
+ const { duplicates, overlayToBase: { routed } } = this.#stream(owner, name);
2301
+ const feeds: Array<readonly [number, Uint8Array]> = [];
2302
+ for (const sym of symbols) {
2303
+ // A packet the base received already sits in its block.
2304
+ if (duplicates.sourceSeqs.has(sym.packetSequenceNumber)) continue;
2305
+ const ssId = baseSsIds.get(sym);
2306
+ if (ssId === undefined) {
2307
+ this.#overlayStats.baseUndetermined++;
2308
+ continue;
2309
+ }
2310
+ routed.delete(ssId);
2311
+ routed.add(ssId);
2312
+ if (routed.size > OVERLAY_ROUTED_LIMIT) routed.delete(routed.values().next().value!);
2313
+ feeds.push([ssId, sym.rawSymbol]);
2314
+ }
2315
+ // Any feed can recover the base block, whose routing must already skip
2316
+ // every packet this recovery routed: all are registered before the first.
2317
+ for (const [ssId, symbolData] of feeds) this.#feedRebuiltToBase(owner, name, ssId, symbolData);
2318
+ }
2319
+
2320
+ /** Feed one rebuilt source to its base block, or keep it until a repair states that block. */
2321
+ #feedRebuiltToBase(owner: number, name: string | undefined, ssId: number, symbolData: Uint8Array): void {
2322
+ const fec = this.#fecManager;
2323
+ if (!fec?.feedRebuiltSourceSymbol) return;
2324
+ const coords = fec.locateIsoSource?.(owner, name, ssId);
2325
+ if (coords == null) {
2326
+ const pending = this.#stream(owner, name).overlayToBase.pending;
2327
+ pending.delete(ssId);
2328
+ pending.set(ssId, symbolData);
2329
+ if (pending.size > OVERLAY_PENDING_LIMIT) pending.delete(pending.keys().next().value!);
2330
+ return;
2331
+ }
2332
+ const fed = fec.feedRebuiltSourceSymbol(owner, ssId, symbolData, name);
2333
+ if (!fed.taken) return;
2334
+ this.#overlayStats.baseFed++;
2335
+ if (fed.recovered === null) return;
2336
+ const sbn = coords.sbn;
2337
+ this.#routeRecoveredBlock(sbn, fed.recovered, owner, name, () => {
2338
+ fec.cancelBlockTimeout(sbn, owner, name);
2339
+ fec.countRecovery(owner, sbn, name);
2340
+ fec.markBlockCompleted(sbn, owner, name);
2341
+ });
2342
+ }
2343
+
2344
+ /** A repair stated `block`: the rebuilt sources that waited for it join it now. */
2345
+ #feedPendingRebuilt(owner: number, name: string | undefined, block: { start: number; length: number }): void {
2346
+ const pending = this.#streams.get(owner)?.get(name)?.overlayToBase.pending;
2347
+ if (pending === undefined || pending.size === 0) return;
2348
+ for (const [ssId, symbolData] of [...pending]) {
2349
+ if (serial32Distance(block.start, ssId) >= block.length) continue;
2350
+ pending.delete(ssId);
2351
+ this.#feedRebuiltToBase(owner, name, ssId, symbolData);
2352
+ }
2158
2353
  }
2159
2354
 
2160
2355
  /** fec-multicast-00 §6.6: report each base block whose RAP MFU fragments
@@ -2329,12 +2524,13 @@ export class MmtpRouter {
2329
2524
  if (!entry) return;
2330
2525
  entry.receiver.release();
2331
2526
  entry.protected = false;
2527
+ entry.userAgent = false;
2332
2528
  // ponytail: drops every governed block's record, not only this flow's; per-flow if joins multiply.
2333
2529
  this.#assistBlocks.clear();
2334
2530
  this.#assistFailures.clear();
2335
2531
  }
2336
2532
 
2337
- /** MoQ packets of a track a protected flow lists, dropped on its unicast path: duplicate, overflow, or listed by more than one flow. */
2533
+ /** Packets of a track a protected flow lists, dropped unread: a MoQ copy (duplicate, overflow, or listed by more than one flow) or a multicast source datagram that names no flow, of a track a user-agent-admitted flow lists. */
2338
2534
  get unadmittedListedDropCount(): number {
2339
2535
  return this.#unadmittedListedDrops;
2340
2536
  }
@@ -2384,6 +2580,14 @@ export class MmtpRouter {
2384
2580
  return false;
2385
2581
  }
2386
2582
 
2583
+ /** Whether a protected flow the user agent admitted lists `packetId`. */
2584
+ #userAgentGoverns(packetId: number): boolean {
2585
+ for (const entry of this.#provenance.values()) {
2586
+ if (entry.protected && entry.userAgent && ingressHasPacketId(entry.ingress, packetId)) return true;
2587
+ }
2588
+ return false;
2589
+ }
2590
+
2387
2591
  /**
2388
2592
  * A MoQ repair symbol no protected flow lists, owned by a source track one
2389
2593
  * does: its FEC instance is governed (7.2.7 step 2) and takes only admitted
@@ -2503,7 +2707,7 @@ export class MmtpRouter {
2503
2707
  fec.cancelBlockTimeout(blockStart, ownerPacketId, catalogTrackName);
2504
2708
  fec.countRecovery(ownerPacketId, blockStart, catalogTrackName);
2505
2709
  fec.markBlockCompleted(blockStart, ownerPacketId, catalogTrackName);
2506
- })) {
2710
+ }, true)) {
2507
2711
  this.#unlistedAssistedRecoveries++;
2508
2712
  this.#assistBlocks.delete(key);
2509
2713
  return;
@@ -2955,6 +3159,8 @@ export class MmtpRouter {
2955
3159
  }
2956
3160
  this.#replayIsoSources(ownerPacketId, catalogTrackName, blockStart, placement.placed);
2957
3161
  if (this.#classifyPacketId(ownerPacketId) === null) return;
3162
+ this.#feedPendingRebuilt(ownerPacketId, catalogTrackName, placement.block);
3163
+ if (this.#classifyPacketId(ownerPacketId) === null) return;
2958
3164
  if (!this.#feedFecTransportSymbol(
2959
3165
  ownerPacketId,
2960
3166
  blockStart,
@@ -3959,6 +4165,15 @@ export class MmtpRouter {
3959
4165
  debugRouter(`[MmtpRouter] Dropping multicast packet pid=${info.packetId}: flow ${ingress.flow} has no receiver`);
3960
4166
  return;
3961
4167
  }
4168
+ // The user agent verified this datagram against the flow's records
4169
+ // itself, and the records never reach the page: admitted as is. The
4170
+ // flow stays registered, so its MoQ copies are still checked.
4171
+ if (ingress.admittedBy === USER_AGENT_VERIFIED) {
4172
+ this.#noteFlowAdmitted(ingress.flow);
4173
+ protectedFlow.userAgent = true;
4174
+ this.#processMMTP(packet, source, true, ingress, overlapCopy);
4175
+ return;
4176
+ }
3962
4177
  // Held: released by a record (feedProvenanceRecord) or expired. Duplicate / overflow: dropped.
3963
4178
  const { verdict } = protectedFlow.receiver.onDatagram(packet, "multicast");
3964
4179
  if (verdict === "admitted") {
@@ -3983,6 +4198,18 @@ export class MmtpRouter {
3983
4198
  }
3984
4199
  }
3985
4200
 
4201
+ // A multicast datagram that names no flow is unverified. Of a track a
4202
+ // user-agent-admitted flow lists it would feed that flow's live decoder,
4203
+ // whose recoveries are kept without a record (#routeVerifiedFecSymbols):
4204
+ // its source is dropped here and its repair set aside below, as for
4205
+ // unlisted MoQ. Under a record-only flow it is read as before: those
4206
+ // recoveries still need a record.
4207
+ const flowless = source === "multicast" && !manifestAdmitted && ingress?.flow === undefined;
4208
+ if (flowless && !info.isRepair && this.#userAgentGoverns(info.packetId)) {
4209
+ this.#unadmittedListedDrops++;
4210
+ return;
4211
+ }
4212
+
3986
4213
  if (info.isRepair) {
3987
4214
  // Repair packet — per ISO 23008-1 §C.5.3, carries (SS_Start, SSB_length, RS_ID).
3988
4215
  // Read from the header parsed above rather than parsing it again
@@ -4003,9 +4230,10 @@ export class MmtpRouter {
4003
4230
  );
4004
4231
  if (!repair) return;
4005
4232
  if (ownerPacketId != null && this.#fecManager) {
4006
- // An unadmitted MoQ symbol reaching here is unlisted (#admitUnicast).
4233
+ // An unadmitted MoQ symbol reaching here is unlisted (#admitUnicast);
4234
+ // a flow-less multicast one is unverified.
4007
4235
  if (
4008
- source === "moq" && !manifestAdmitted &&
4236
+ ((flowless && this.#userAgentGoverns(ownerPacketId)) || (source === "moq" && !manifestAdmitted)) &&
4009
4237
  this.#setAsideUnlistedRepair(ownerPacketId, catalogTrackNameForPacket(ingress, ownerPacketId), repair)
4010
4238
  ) return;
4011
4239
  this.#repairPidCounts[info.packetId] = (this.#repairPidCounts[info.packetId] || 0) + 1;
@@ -4345,6 +4573,7 @@ export class MmtpRouter {
4345
4573
  if (!overlapCopy && this.#classifyPacketId(info.packetId) !== null) {
4346
4574
  this.#feedOverlaySource(
4347
4575
  info.packetId, catalogTrackName, packet, parsed.header, info.sequenceNumber, info.rapFlag, source,
4576
+ fecPayloadId?.ssId,
4348
4577
  );
4349
4578
  }
4350
4579
  }
@@ -4573,6 +4802,7 @@ export class MmtpRouter {
4573
4802
  stream = {
4574
4803
  key: JSON.stringify([packetId, catalogTrackName ?? null]),
4575
4804
  duplicates: new StreamDuplicateLedgers(() => this.duplicateHorizonFor(packetId)),
4805
+ overlayToBase: { routed: new Set(), pending: new Map() },
4576
4806
  };
4577
4807
  byName.set(catalogTrackName, stream);
4578
4808
  }
@@ -4771,8 +5001,28 @@ export class MmtpRouter {
4771
5001
  data: Uint8Array,
4772
5002
  meta: RoutedFrameMeta,
4773
5003
  cb: FrameCallback | null,
5004
+ /** The base SS_ID the FEC release gate ordered the frame by, if any. */
5005
+ fecSbn: number | undefined,
4774
5006
  ): void {
4775
5007
  const streamKey = this.#mediaStreamKey(packetId, meta.catalogTrackName);
5008
+ // The FEC release gate let this frame out in order: only once every
5009
+ // block between it and the frame before it resolved, or because it
5010
+ // starts where that frame ended. Nothing still in flight can precede it
5011
+ // then, so it plays now, as a source frame does; holding it for the next
5012
+ // intact frame cost every repaired burst its length in decoder input
5013
+ // (BLO-52085). An overlay recovery names no base block for the gate to
5014
+ // wait on, so past a gap it may be ahead of frames still in flight (an
5015
+ // overlay that outran the wire, BLO-53342): it keeps waiting for the
5016
+ // source cursor below, unless it starts where the last emitted frame
5017
+ // ended (on this direct path, the delivered high water).
5018
+ if (this.#isFecTrack(packetId)) {
5019
+ const high = this.#deliveredVideoHighWaterByStream.get(streamKey);
5020
+ if (fecSbn !== undefined || (high !== undefined && authoredAdjacent(high, meta))) {
5021
+ this.#drainDirectRecoveredVideo(streamKey, packetId, frame => frame.timestamp <= meta.timestamp, cb);
5022
+ this.#emitDirectVideo(packetId, data, meta, cb);
5023
+ return;
5024
+ }
5025
+ }
4776
5026
  this.#enqueueDirectRecoveredVideo(packetId, data, meta);
4777
5027
  const lastSourceTs = this.#lastDirectSourceVideoTsByStream.get(streamKey);
4778
5028
  this.#drainDirectRecoveredVideo(
@@ -4950,6 +5200,7 @@ export class MmtpRouter {
4950
5200
  ownerPacketId?: number,
4951
5201
  catalogTrackName?: string,
4952
5202
  complete?: () => void,
5203
+ assisted = false,
4953
5204
  ): boolean {
4954
5205
  // MMTP path: use processFecBlock for MMTP-wrapped recovery.
4955
5206
  // Recovered symbols are individual MMTP packets that may be fragments
@@ -4974,7 +5225,7 @@ export class MmtpRouter {
4974
5225
  ownerPacketId,
4975
5226
  blockProfile,
4976
5227
  });
4977
- return this.#routeVerifiedFecSymbols(blockId, result, ownerPacketId, catalogTrackName, complete);
5228
+ return this.#routeVerifiedFecSymbols(blockId, result, ownerPacketId, catalogTrackName, complete, assisted);
4978
5229
  }
4979
5230
 
4980
5231
  /**
@@ -4987,6 +5238,19 @@ export class MmtpRouter {
4987
5238
  * recovered packets is refused is unrecovered (7.2.7): its caller leaves it
4988
5239
  * open, so it reaches its FEC deadline and fails by class. Always true
4989
5240
  * outside a protected flow.
5241
+ *
5242
+ * On a flow the user agent admitted, the live decoder's recoveries are kept
5243
+ * without a record: while the flow is protected that decoder takes only
5244
+ * symbols the user agent or a record verified (a flow-less multicast
5245
+ * datagram of a listed track is dropped or set aside in #processMMTP), so
5246
+ * what it recovers is as verified as they are. An `assisted` (scratch-decoder) recovery mixes in
5247
+ * unlisted MoQ repair and still needs a record.
5248
+ * ponytail: per-flow flag; per-block provenance only if a protected flow
5249
+ * ever mixes verified and unverified live inputs.
5250
+ * ponytail: a worker frame computed before the protection reset can still
5251
+ * arrive after it (frames carry no epoch); its inputs are MoQ copies read
5252
+ * and played before protection, so it admits nothing new. Drop pre-reset
5253
+ * frames by epoch only if pre-protection input ever matters.
4990
5254
  */
4991
5255
  #routeVerifiedFecSymbols(
4992
5256
  blockId: number,
@@ -4994,8 +5258,22 @@ export class MmtpRouter {
4994
5258
  ownerPacketId?: number,
4995
5259
  catalogTrackName?: string,
4996
5260
  complete?: () => void,
5261
+ assisted = false,
4997
5262
  ): boolean {
4998
- const receiver = ownerPacketId == null ? undefined : this.#provenanceFor(ownerPacketId, catalogTrackName);
5263
+ // A packet a keyframe overlay recovery routed before it fed the packet to
5264
+ // this block (#feedOverlayRecoveredToBase) is not routed again.
5265
+ const overlayRouted = ownerPacketId == null
5266
+ ? undefined
5267
+ : this.#streams.get(ownerPacketId)?.get(catalogTrackName)?.overlayToBase.routed;
5268
+ if (overlayRouted !== undefined && overlayRouted.size > 0) {
5269
+ result = {
5270
+ ...result,
5271
+ symbols: result.symbols.filter((sym) => !overlayRouted.has(this.#fecAnchorForRecovered(blockId, sym.index))),
5272
+ };
5273
+ }
5274
+ const entry = ownerPacketId == null ? undefined : this.#provenanceFor(ownerPacketId, catalogTrackName);
5275
+ const receiver = entry === undefined || entry === "ambiguous" ? entry : entry.receiver;
5276
+ const userAgent = !assisted && entry !== undefined && entry !== "ambiguous" && entry.protected === true && entry.userAgent === true;
4999
5277
  let recovered = true;
5000
5278
  if (receiver) {
5001
5279
  const parsed = this.#stream(ownerPacketId!, catalogTrackName).duplicates.parsedSymbols;
@@ -5008,6 +5286,8 @@ export class MmtpRouter {
5008
5286
  let admitted = false;
5009
5287
  if (receiver === "ambiguous") {
5010
5288
  this.#ambiguousRecoveredDrops++;
5289
+ } else if (userAgent) {
5290
+ admitted = true;
5011
5291
  } else {
5012
5292
  const wire = new Uint8Array(sym.rawSymbol.byteLength + 4);
5013
5293
  wire.set(sym.rawSymbol);
@@ -5033,7 +5313,7 @@ export class MmtpRouter {
5033
5313
  #provenanceFor(
5034
5314
  packetId: number,
5035
5315
  catalogTrackName: string | undefined,
5036
- ): BcProvenanceV2Receiver | "ambiguous" | undefined {
5316
+ ): ProvenanceEntry | "ambiguous" | undefined {
5037
5317
  // A caller that names no track matches every name the packet id was admitted under.
5038
5318
  const byName = this.#provenanceStreamFlow.get(packetId);
5039
5319
  const flows = new Set(
@@ -5042,12 +5322,12 @@ export class MmtpRouter {
5042
5322
  : byName.has(catalogTrackName) ? [byName.get(catalogTrackName)!] : [],
5043
5323
  );
5044
5324
  if (flows.size > 1) return "ambiguous";
5045
- for (const bound of flows) return this.#provenance.get(bound)?.receiver ?? "ambiguous";
5046
- let match: BcProvenanceV2Receiver | undefined;
5047
- for (const { receiver, ingress } of this.#provenance.values()) {
5048
- if (ingress.tracks.some((t) => t.packetId === packetId && (catalogTrackName === undefined || t.name === catalogTrackName))) {
5325
+ for (const bound of flows) return this.#provenance.get(bound) ?? "ambiguous";
5326
+ let match: ProvenanceEntry | undefined;
5327
+ for (const entry of this.#provenance.values()) {
5328
+ if (entry.ingress.tracks.some((t) => t.packetId === packetId && (catalogTrackName === undefined || t.name === catalogTrackName))) {
5049
5329
  if (match) return "ambiguous";
5050
- match = receiver;
5330
+ match = entry;
5051
5331
  }
5052
5332
  }
5053
5333
  return match;
@@ -5141,6 +5421,43 @@ export class MmtpRouter {
5141
5421
  );
5142
5422
  }
5143
5423
 
5424
+ // A recovered head whose sample FT=1 has not named yet (an overlay that
5425
+ // outran the source path) would complete its data unit untimed: the unit
5426
+ // is dropped, marked completed, and the timed source copy is then refused
5427
+ // (BLO-53342). Hand it to the parser instead, which holds it in its
5428
+ // awaiting-timing queue and feeds it again when FT=1 lands; its recovered
5429
+ // continuations park in the reassembler meanwhile. If the source path
5430
+ // lost the head, this held copy is the keyframe (BLO-53369).
5431
+ const untimedHead = (sym: (typeof result.symbols)[number]): boolean =>
5432
+ sym.fragmentType === 2 &&
5433
+ sym.fragment.fragmentationIndicator === 1 &&
5434
+ sym.fragment.movieFragmentSequenceNumber !== undefined &&
5435
+ sym.fragment.sampleNumber !== undefined &&
5436
+ !this.#parser.hasAuthoredTimingFor(
5437
+ ownerPacketId,
5438
+ sym.fragment.mpuSequenceNumber,
5439
+ sym.fragment.movieFragmentSequenceNumber,
5440
+ sym.fragment.sampleNumber,
5441
+ catalogTrackName,
5442
+ );
5443
+ for (const sym of result.symbols) {
5444
+ if (!untimedHead(sym) ||
5445
+ this.#sourceSeqReceived(ownerPacketId, sym.packetSequenceNumber, catalogTrackName)) continue;
5446
+ this.#feedViaParser(
5447
+ sym.rawSymbol,
5448
+ { packetId: ownerPacketId, sequenceNumber: sym.packetSequenceNumber },
5449
+ undefined,
5450
+ {
5451
+ skipFecPayloadId: true,
5452
+ recovered: true,
5453
+ deliveryProvenance,
5454
+ ...fecSbnOf(sym.index),
5455
+ ...(catalogTrackName === undefined ? {} : { catalogTrackName }),
5456
+ },
5457
+ );
5458
+ }
5459
+ result = { ...result, symbols: result.symbols.filter((sym) => !untimedHead(sym)) };
5460
+
5144
5461
  // MSE assembler path: route recovered block as pre-assembled frames (skip FT=0)
5145
5462
  // BLO-3035 layer 3: recovered symbols are RAW MFU fragments, not complete
5146
5463
  // frames. A keyframe can span multiple packets (FI=1/2/3); pushing each