@blockcast/mmt-container 2.2.0-main.c4fa878f4ff4 → 2.2.0-main.e75bbd7576a6

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 (73) hide show
  1. package/README.md +29 -17
  2. package/dist/browser-runtime.js +703 -175
  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 +33 -1
  16. package/dist/fec-manager.d.ts.map +1 -1
  17. package/dist/fec-manager.js +222 -6
  18. package/dist/fec-manager.js.map +2 -2
  19. package/dist/fec-worker-setup.d.ts +1 -1
  20. package/dist/fec-worker-setup.js +1 -1
  21. package/dist/fec-worker-setup.js.map +1 -1
  22. package/dist/index.d.ts +3 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +5 -1
  25. package/dist/index.js.map +2 -2
  26. package/dist/iso-block-locator.d.ts +9 -0
  27. package/dist/iso-block-locator.d.ts.map +1 -1
  28. package/dist/iso-block-locator.js +17 -0
  29. package/dist/iso-block-locator.js.map +2 -2
  30. package/dist/iso-bmff-timing.d.ts +10 -1
  31. package/dist/iso-bmff-timing.d.ts.map +1 -1
  32. package/dist/iso-bmff-timing.js +36 -2
  33. package/dist/iso-bmff-timing.js.map +2 -2
  34. package/dist/media-router.js.map +1 -1
  35. package/dist/mmtp-container-parser.d.ts +37 -2
  36. package/dist/mmtp-container-parser.d.ts.map +1 -1
  37. package/dist/mmtp-container-parser.js +86 -9
  38. package/dist/mmtp-container-parser.js.map +2 -2
  39. package/dist/mmtp-container-parser.no-modules.js +89 -11
  40. package/dist/mmtp-container-parser.no-modules.js.map +2 -2
  41. package/dist/mmtp-decode-pipeline.js +85 -9
  42. package/dist/mmtp-decode-pipeline.js.map +2 -2
  43. package/dist/mmtp-router.d.ts +18 -1
  44. package/dist/mmtp-router.d.ts.map +1 -1
  45. package/dist/mmtp-router.js +304 -39
  46. package/dist/mmtp-router.js.map +2 -2
  47. package/dist/multicast-manager.js +526 -45
  48. package/dist/multicast-manager.js.map +2 -2
  49. package/dist/multicast-transport.js +26 -16
  50. package/dist/multicast-transport.js.map +2 -2
  51. package/dist/no-modules.js +85 -9
  52. package/dist/no-modules.js.map +2 -2
  53. package/dist/repair-layer-subscriptions.d.ts +6 -2
  54. package/dist/repair-layer-subscriptions.d.ts.map +1 -1
  55. package/dist/repair-layer-subscriptions.js +6 -3
  56. package/dist/repair-layer-subscriptions.js.map +2 -2
  57. package/dist/track-session.d.ts +1 -1
  58. package/dist/track-session.js.map +1 -1
  59. package/dist/wasm-mmtp-container-parser.js +85 -9
  60. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  61. package/package.json +8 -5
  62. package/schemas/catalog.schema.json +4 -0
  63. package/src/catalog-fec-params.ts +36 -1
  64. package/src/cmcd-producer.ts +189 -179
  65. package/src/fec-manager.ts +277 -6
  66. package/src/fec-worker-setup.ts +2 -2
  67. package/src/index.ts +7 -2
  68. package/src/iso-block-locator.ts +19 -0
  69. package/src/iso-bmff-timing.ts +40 -1
  70. package/src/mmtp-container-parser.ts +96 -8
  71. package/src/mmtp-router.ts +350 -13
  72. package/src/repair-layer-subscriptions.ts +13 -5
  73. package/src/track-session.ts +1 -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, 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,90 @@ 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
+ 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
+ /**
258
+ * The base SS_ID of a rebuilt packet no received member of its MPU anchors,
259
+ * from its protected packet_sequence_number (`headerPsn`). SS_ID advances by
260
+ * one per FEC source packet (mmt-02 7.1) and psn by one per packet, so on a
261
+ * track whose every packet is an FEC source packet the sender keeps
262
+ * 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.
269
+ * ponytail: exact only if every packet from the rebuilt one to the latest
270
+ * received source is an FEC source packet, as FFmpeg's muxer sends a track
271
+ * it protects. fec-core 8.3 lets a publisher leave one unprotected
272
+ * (FEC Type 0): outside the shown span it goes unseen, and each one puts the
273
+ * rebuilt packet a base slot off. If such a publisher appears, have mmt-02
274
+ * require FEC Type 1 across a protected track, or refuse a headerPsn outside
275
+ * the shown span.
276
+ */
277
+ function unstampedBaseSsId(
278
+ offset: number | undefined,
279
+ unstampedOffset: number | undefined,
280
+ headerPsn: number,
281
+ ): number | undefined {
282
+ if (offset === undefined || offset !== unstampedOffset) return undefined;
283
+ return (headerPsn + offset) >>> 0;
284
+ }
285
+
286
+ /** Base SS_IDs of overlay-routed packets kept per stream (see OverlayToBase). */
287
+ const OVERLAY_ROUTED_LIMIT = 1024;
288
+ /** Rebuilt sources kept per stream for a base block no repair stated yet. */
289
+ const OVERLAY_PENDING_LIMIT = 256;
290
+
291
+ /**
292
+ * One stream's keyframe-overlay recoveries on their way into its base
293
+ * decoder (joint decoding, BLO-52085).
294
+ */
295
+ interface OverlayToBase {
296
+ /** Base SS_IDs of the packets an overlay recovery routed (as media, or held
297
+ * for their FT=1): a base recovery does not route them a second time. */
298
+ readonly routed: Set<number>;
299
+ /** Rebuilt sources, by base SS_ID, whose base block no repair stated yet. */
300
+ readonly pending: Map<number, Uint8Array>;
215
301
  }
216
302
 
217
303
  interface OverlayInstance {
@@ -223,8 +309,8 @@ interface OverlayInstance {
223
309
  readonly k: number;
224
310
  /** The newest overlay symbol seen: its overlay SS_ID and wire sequence. */
225
311
  last?: { ssId: number; psn: number };
226
- /** MPU sequence number → overlay SS_ID − wire packet_sequence_number. */
227
- readonly offsets: Map<number, number>;
312
+ /** MPU sequence number → its newest received member. */
313
+ readonly offsets: Map<number, OverlayMemberAnchor>;
228
314
  /** Overlay blocks a MoQ symbol fed (multicast-02 7.2.7). */
229
315
  readonly moqBlocks: Set<number>;
230
316
  }
@@ -511,6 +597,9 @@ export type RoutedPacketMeta = {
511
597
  mmtpTimestamp: bigint;
512
598
  /**
513
599
  * The MMTP header RAP flag: the packet starts a random access point. On a
600
+ * track under a keyframe overlay it is also set on the FT=0 and FT=1 data
601
+ * units that govern a covered keyframe (draft-ramadan-moq-mmt-02 7.4.2), so
602
+ * it can precede the keyframe's first MFU fragment. On a
514
603
  * `recovered` packet it is read from the FEC-reconstructed header, which is
515
604
  * byte-exact outside packet_sequence_number, so it is the flag the
516
605
  * publisher sent. The router's own gap decisions deliberately ignore it for
@@ -574,6 +663,7 @@ interface StreamLedgers {
574
663
  * the pair.
575
664
  */
576
665
  readonly duplicates: StreamDuplicateLedgers;
666
+ readonly overlayToBase: OverlayToBase;
577
667
  }
578
668
 
579
669
  const NO_FEC_MANAGER_HORIZON: DuplicateHorizon = Object.freeze({
@@ -661,6 +751,17 @@ export interface FECManagerLike {
661
751
  catalogTrackName?: string,
662
752
  ): Uint8Array | null;
663
753
 
754
+ /** Feed a source another FEC instance over the same packets rebuilt (a
755
+ * keyframe overlay) to its base block, which a repair must already have
756
+ * stated; not a network arrival (FECManager.feedRebuiltSourceSymbol).
757
+ * Optional: without it the overlay and the base decode apart. */
758
+ feedRebuiltSourceSymbol?(
759
+ packetId: number,
760
+ ssId: number,
761
+ symbolData: Uint8Array,
762
+ catalogTrackName?: string,
763
+ ): RebuiltSourceFeed;
764
+
664
765
  /** How long ago the stream's oldest open block with an FEC deadline took its first symbol (optional). */
665
766
  oldestOpenBlockAgeMs?(packetId: number, catalogTrackName: string): number | undefined;
666
767
 
@@ -976,6 +1077,7 @@ export class MmtpRouter {
976
1077
  readonly #provenance = new Map<string, ProvenanceEntry>();
977
1078
  #onFlowProtected: ((flow: string) => void) | null = null;
978
1079
  #unadmittedListedDrops = 0;
1080
+ #admittedSourceDatagrams = 0;
979
1081
  #unlistedRepairSetAside = 0;
980
1082
  readonly #assistBlocks = new Map<string, AssistBlock>();
981
1083
  readonly #assistFailures = new Map<string, number>();
@@ -1044,6 +1146,15 @@ export class MmtpRouter {
1044
1146
  * sequence is its SS_ID minus this; see #routeProcessedFecSymbols.
1045
1147
  */
1046
1148
  #ssIdSeqOffsetByStream = new Map<string, number>();
1149
+ /**
1150
+ * Per stream, the SS_ID offset at which a base recovery last showed the
1151
+ * sender's own packet_sequence_number: a recovered packet's header psn
1152
+ * equal to its SS_ID minus that offset. While it equals the stream's
1153
+ * current offset, no hop re-stamps the stream, and a packet's protected psn
1154
+ * names its base SS_ID within unstampedBaseSsId's limits (joint decoding of
1155
+ * an MPU no member of which arrived).
1156
+ */
1157
+ #unstampedSsIdSeqOffsetByStream = new Map<string, number>();
1047
1158
  /**
1048
1159
  * Streams whose multicast leg delivered a RAP-flagged source packet that was
1049
1160
  * not parsed, typically because its overlap copy was parsed first
@@ -1121,7 +1232,7 @@ export class MmtpRouter {
1121
1232
  #restampedStreams = new Set<string>();
1122
1233
  #overlayStats: KeyframeOverlayStats = {
1123
1234
  repairDropped: 0, ownerRefused: 0, recovered: 0, restarts: 0,
1124
- unsequencedDropped: 0, protectedDropped: 0,
1235
+ unsequencedDropped: 0, protectedDropped: 0, baseFed: 0, baseUndetermined: 0,
1125
1236
  };
1126
1237
  /** Callback fired when a delta-track FEC block completes or fails. */
1127
1238
  #onDeltaBlockCompleteCb:
@@ -1400,7 +1511,7 @@ export class MmtpRouter {
1400
1511
  const cb = this.#getFrameCallback(packetId);
1401
1512
  if (isVideo && !assembler) {
1402
1513
  if (frameMeta.recovered) {
1403
- this.#routeDirectRecoveredVideo(packetId, data, frameMeta, cb);
1514
+ this.#routeDirectRecoveredVideo(packetId, data, frameMeta, cb, meta.fecSbn);
1404
1515
  } else {
1405
1516
  this.#routeDirectSourceVideo(packetId, data, frameMeta, cb);
1406
1517
  }
@@ -1915,6 +2026,11 @@ export class MmtpRouter {
1915
2026
  return { ...this.#overlayStats };
1916
2027
  }
1917
2028
 
2029
+ /** Recovered FT=2 heads held for their FT=1, and how each left the wait (BLO-53369). */
2030
+ get recoveredHeadsAwaitingTiming(): Readonly<MmtpRecoveredHeadTimingStats> {
2031
+ return this.#parser.recoveredHeadsAwaitingTiming;
2032
+ }
2033
+
1918
2034
  #isOverlayRepair(repairPacketId: number, repairTrackName?: string): boolean {
1919
2035
  return this.#overlayLegs.has(repairPacketId) ||
1920
2036
  (repairTrackName !== undefined && this.#overlayRepairNames.has(repairTrackName));
@@ -1966,6 +2082,13 @@ export class MmtpRouter {
1966
2082
  */
1967
2083
  resetKeyframeOverlays(sourcePacketId: number, catalogTrackName?: string): void {
1968
2084
  for (const inst of this.#overlaysForSource(sourcePacketId, catalogTrackName)) this.#resetOverlay(inst);
2085
+ // The base restarted with it: base SS_IDs kept for joint decoding name
2086
+ // the old epoch's packets.
2087
+ for (const [name, stream] of this.#streams.get(sourcePacketId) ?? []) {
2088
+ if (catalogTrackName !== undefined && name !== catalogTrackName) continue;
2089
+ stream.overlayToBase.routed.clear();
2090
+ stream.overlayToBase.pending.clear();
2091
+ }
1969
2092
  }
1970
2093
 
1971
2094
  #resetOverlay(inst: OverlayInstance): void {
@@ -2043,7 +2166,8 @@ export class MmtpRouter {
2043
2166
  * A source packet that carries a 0x8B01 overlay SS_ID is also a symbol of
2044
2167
  * every overlay installed for its source, at that SS_ID, with the bytes the
2045
2168
  * base protects: the packet minus its 4-byte SS_ID trailer, extension
2046
- * included (7.1, 7.4.3).
2169
+ * included (7.1, 7.4.3). `baseSsId` is that trailer's SS_ID, when the base
2170
+ * FEC reads it.
2047
2171
  */
2048
2172
  #feedOverlaySource(
2049
2173
  packetId: number,
@@ -2053,6 +2177,7 @@ export class MmtpRouter {
2053
2177
  sequenceNumber: number,
2054
2178
  rapFlag: boolean,
2055
2179
  source: MmtpSourcePath,
2180
+ baseSsId: number | undefined,
2056
2181
  ): void {
2057
2182
  if (this.#overlays.size === 0) return;
2058
2183
  const ssId = keyframeOverlaySsId(header);
@@ -2061,8 +2186,22 @@ export class MmtpRouter {
2061
2186
  this.#noteOverlaySsId(inst, ssId, sequenceNumber);
2062
2187
  const identity = MmtpContainerParser.sourcePacketIdentity(packet, header);
2063
2188
  if (identity !== null) {
2189
+ // The newest member anchors its MPU; one with no base SS_ID leaves the
2190
+ // MPU no base anchor rather than keep an older member's. A member that
2191
+ // is no FEC source packet of the base takes an overlay SS_ID and a psn
2192
+ // but no base SS_ID, so past it an older anchor would put each rebuilt
2193
+ // packet one base slot high, and overlayBaseSsId's psn test would pass
2194
+ // it. That costs a conforming stream nothing: every member carries the
2195
+ // base trailer (7.4.3), unread only while the base track is
2196
+ // unconfigured (fecUnreadable), which joint decoding skips, and a base
2197
+ // release (MulticastManager) resets or removes the overlay, so no
2198
+ // readable member precedes an unread one in its MPU.
2064
2199
  inst.offsets.delete(identity.mpuSequenceNumber);
2065
- inst.offsets.set(identity.mpuSequenceNumber, (ssId - sequenceNumber) >>> 0);
2200
+ inst.offsets.set(identity.mpuSequenceNumber, {
2201
+ psnOffset: (ssId - sequenceNumber) >>> 0,
2202
+ overlaySsId: ssId,
2203
+ ...(baseSsId === undefined ? {} : { baseSsId }),
2204
+ });
2066
2205
  if (inst.offsets.size > OVERLAY_OFFSET_MPUS) inst.offsets.delete(inst.offsets.keys().next().value!);
2067
2206
  }
2068
2207
  const { fec, repairPacketId: pid, streamName: name } = inst;
@@ -2149,18 +2288,35 @@ export class MmtpRouter {
2149
2288
  const streamKey = this.#mediaStreamKey(sourcePacketId, sourceName);
2150
2289
  const restamped = this.#restampedStreams.has(streamKey);
2151
2290
  const restored = new Set<number>();
2291
+ const baseSsIds = new Map<ProcessFecBlockResult["symbols"][number], number>();
2152
2292
  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;
2293
+ const anchor = inst.offsets.get(sym.fragment.mpuSequenceNumber);
2294
+ if (anchor !== undefined) {
2295
+ const overlaySsId = serial32Add(blockStart, sym.index);
2296
+ const packetSequenceNumber = (overlaySsId - anchor.psnOffset) >>> 0;
2156
2297
  restored.add(packetSequenceNumber);
2157
- return [{ ...sym, packetSequenceNumber }];
2298
+ const out = { ...sym, packetSequenceNumber };
2299
+ const baseSsId = overlayBaseSsId(anchor, overlaySsId, packetSequenceNumber, sym.packetSequenceNumber);
2300
+ if (baseSsId !== undefined) baseSsIds.set(out, baseSsId);
2301
+ return [out];
2158
2302
  }
2159
2303
  // A stream re-stamped by a bridge that no base recovery has shown yet
2160
2304
  // keeps the header's sequence too: from received packets alone the
2161
2305
  // router cannot tell (their psn is the wire one). Base recovery or a
2162
2306
  // FETCH still covers such a keyframe.
2163
- if (!restamped) return [sym];
2307
+ 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);
2318
+ return [sym];
2319
+ }
2164
2320
  this.#overlayStats.unsequencedDropped++;
2165
2321
  return [];
2166
2322
  });
@@ -2170,6 +2326,98 @@ export class MmtpRouter {
2170
2326
  });
2171
2327
  this.#noteOverlayRecoveredRaps(sourcePacketId, sourceName, streamKey, symbols.filter((sym) =>
2172
2328
  restored.has(sym.packetSequenceNumber)));
2329
+ this.#feedOverlayRecoveredToBase(inst, blockStart, symbols, baseSsIds);
2330
+ }
2331
+
2332
+ /**
2333
+ * Joint decoding (BLO-52085). A packet the overlay rebuilt is a source of
2334
+ * its base block too, once its base SS_ID is determined (overlayBaseSsId,
2335
+ * from a received member of its MPU, else unstampedBaseSsId),
2336
+ * so it is fed to the base decoder as a rebuilt source
2337
+ * (FECManager.feedRebuiltSourceSymbol): the base block has one erasure
2338
+ * fewer to solve, and its repair can recover the block's other packets.
2339
+ * A base block no repair has stated yet takes it when one does
2340
+ * (#feedPendingRebuilt).
2341
+ *
2342
+ * The overlay route already handled each packet fed (delivered it, or
2343
+ * held an untimed head for its FT=1), so a base recovery routes none of
2344
+ * them again (#routeVerifiedFecSymbols): a second recovered copy of a
2345
+ * held head would wait in the parser for timing its first copy consumed.
2346
+ * Only an overlay block decoded from multicast symbols alone is fed: with
2347
+ * a MoQ symbol in it, the base block's recovery would depend on bytes
2348
+ * that crossed no multicast socket, which the provenance counted from the
2349
+ * block's own arrivals cannot show.
2350
+ */
2351
+ #feedOverlayRecoveredToBase(
2352
+ inst: OverlayInstance,
2353
+ blockStart: number,
2354
+ symbols: ProcessFecBlockResult["symbols"],
2355
+ baseSsIds: ReadonlyMap<ProcessFecBlockResult["symbols"][number], number>,
2356
+ ): void {
2357
+ const fec = this.#fecManager;
2358
+ const owner = inst.leg.sourcePacketId;
2359
+ const name = inst.leg.sourceCatalogTrackName;
2360
+ if (!fec?.feedRebuiltSourceSymbol || inst.moqBlocks.has(blockStart)) return;
2361
+ if (this.#classifyPacketId(owner) === null || fec.isTrackConfigured?.(owner) === false) return;
2362
+ const { duplicates, overlayToBase: { routed } } = this.#stream(owner, name);
2363
+ const feeds: Array<readonly [number, Uint8Array]> = [];
2364
+ for (const sym of symbols) {
2365
+ // A packet the base received already sits in its block.
2366
+ if (duplicates.sourceSeqs.has(sym.packetSequenceNumber)) continue;
2367
+ const ssId = baseSsIds.get(sym);
2368
+ if (ssId === undefined) {
2369
+ this.#overlayStats.baseUndetermined++;
2370
+ continue;
2371
+ }
2372
+ routed.delete(ssId);
2373
+ routed.add(ssId);
2374
+ if (routed.size > OVERLAY_ROUTED_LIMIT) routed.delete(routed.values().next().value!);
2375
+ feeds.push([ssId, sym.rawSymbol]);
2376
+ }
2377
+ // Any feed can recover the base block, whose routing must already skip
2378
+ // every packet this recovery routed: all are registered before the first.
2379
+ for (const [ssId, symbolData] of feeds) {
2380
+ // A recovery routed above can retire the owner (an in-place switch).
2381
+ if (this.#classifyPacketId(owner) === null) return;
2382
+ this.#feedRebuiltToBase(owner, name, ssId, symbolData);
2383
+ }
2384
+ }
2385
+
2386
+ /** Feed one rebuilt source to its base block, or keep it until a repair states that block. */
2387
+ #feedRebuiltToBase(owner: number, name: string | undefined, ssId: number, symbolData: Uint8Array): void {
2388
+ const fec = this.#fecManager;
2389
+ if (!fec?.feedRebuiltSourceSymbol) return;
2390
+ const coords = fec.locateIsoSource?.(owner, name, ssId);
2391
+ if (coords == null) {
2392
+ const pending = this.#stream(owner, name).overlayToBase.pending;
2393
+ pending.delete(ssId);
2394
+ // Its own copy: the symbol is a view of the whole recovered block.
2395
+ pending.set(ssId, symbolData.slice());
2396
+ if (pending.size > OVERLAY_PENDING_LIMIT) pending.delete(pending.keys().next().value!);
2397
+ return;
2398
+ }
2399
+ const fed = fec.feedRebuiltSourceSymbol(owner, ssId, symbolData, name);
2400
+ if (!fed.taken) return;
2401
+ this.#overlayStats.baseFed++;
2402
+ if (fed.recovered === null) return;
2403
+ const sbn = coords.sbn;
2404
+ this.#routeRecoveredBlock(sbn, fed.recovered, owner, name, () => {
2405
+ fec.cancelBlockTimeout(sbn, owner, name);
2406
+ fec.countRecovery(owner, sbn, name);
2407
+ fec.markBlockCompleted(sbn, owner, name);
2408
+ });
2409
+ }
2410
+
2411
+ /** A repair stated `block`: the rebuilt sources that waited for it join it now. */
2412
+ #feedPendingRebuilt(owner: number, name: string | undefined, block: { start: number; length: number }): void {
2413
+ const pending = this.#streams.get(owner)?.get(name)?.overlayToBase.pending;
2414
+ if (pending === undefined || pending.size === 0) return;
2415
+ for (const [ssId, symbolData] of [...pending]) {
2416
+ if (this.#classifyPacketId(owner) === null) return;
2417
+ if (serial32Distance(block.start, ssId) >= block.length) continue;
2418
+ pending.delete(ssId);
2419
+ this.#feedRebuiltToBase(owner, name, ssId, symbolData);
2420
+ }
2173
2421
  }
2174
2422
 
2175
2423
  /** fec-multicast-00 §6.6: report each base block whose RAP MFU fragments
@@ -2205,6 +2453,9 @@ export class MmtpRouter {
2205
2453
  const mpu = sym.fragment.mpuSequenceNumber;
2206
2454
  if (sym.fragmentType !== 2 || !sym.fragment.rapFlag || !ft0.has(mpu) || !ft1.has(mpu)) continue;
2207
2455
  if (!single.has(mpu) && !(first.has(mpu) && last.has(mpu))) continue;
2456
+ // Not overlayBaseSsId: a wrong sbn only keeps or wrongly stops a keyframe
2457
+ // FETCH (§6.6, a SHOULD; no symbol moves), and wire psn plus offset holds
2458
+ // under a constant re-stamp shift, which overlayBaseSsId's psn test refuses.
2208
2459
  const ssId = (sym.packetSequenceNumber + ssIdSeqOffset) >>> 0;
2209
2460
  // ponytail: a base block no repair has stated yet (an undelimited hole) is not reported.
2210
2461
  const sbn = this.#fecManager?.locateIsoSource?.(packetId, catalogTrackName, ssId)?.sbn;
@@ -2355,6 +2606,11 @@ export class MmtpRouter {
2355
2606
  return this.#unadmittedListedDrops;
2356
2607
  }
2357
2608
 
2609
+ /** 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. */
2610
+ get admittedSourceDatagramCount(): number {
2611
+ return this.#admittedSourceDatagrams;
2612
+ }
2613
+
2358
2614
  /** MoQ repair symbols of an unlisted track kept out of a governed FEC instance (7.2.7 step 2). */
2359
2615
  get unlistedRepairSetAsideCount(): number {
2360
2616
  return this.#unlistedRepairSetAside;
@@ -2989,6 +3245,11 @@ export class MmtpRouter {
2989
3245
  )) return;
2990
3246
  fec.noteBlockActivity?.(blockStart, ownerPacketId, catalogTrackName);
2991
3247
  fec.noteRepairGroupLength?.(blockStart, ownerPacketId, repair.rsbLength, catalogTrackName);
3248
+ // Rebuilt sources that waited for this block join it once the block took
3249
+ // a repair, so it has a deadline; still ahead of the repair, as the held
3250
+ // sources above are.
3251
+ this.#feedPendingRebuilt(ownerPacketId, catalogTrackName, placement.block);
3252
+ if (this.#classifyPacketId(ownerPacketId) === null) return;
2992
3253
  const alreadyCompleted = fec.isBlockCompleted(blockStart, ownerPacketId, catalogTrackName);
2993
3254
  const recovered = fec.feedRepairSymbol(
2994
3255
  ownerPacketId,
@@ -3169,6 +3430,7 @@ export class MmtpRouter {
3169
3430
  this.#droppedPreSyncVideo.delete(streamKey);
3170
3431
  this.#latestVideoSeqByStream.delete(streamKey);
3171
3432
  this.#ssIdSeqOffsetByStream.delete(streamKey);
3433
+ this.#unstampedSsIdSeqOffsetByStream.delete(streamKey);
3172
3434
  this.#multicastRapTwinByStream.delete(streamKey);
3173
3435
  this.#lastDirectSourceVideoTsByStream.delete(streamKey);
3174
3436
  this.#lastDirectEmittedVideoTsByStream.delete(streamKey);
@@ -3928,6 +4190,8 @@ export class MmtpRouter {
3928
4190
  const parsed = this.#parser.parsePacket(packet);
3929
4191
  if (!parsed) return;
3930
4192
  const info = parsed.info;
4193
+ // manifestAdmitted is set only by a receiver release or the user agent's own verification.
4194
+ if (manifestAdmitted && !info.isRepair && ingress?.admittedBy !== USER_AGENT_VERIFIED) this.#admittedSourceDatagrams++;
3931
4195
  // Every multicast packet is a delivery-gap sample, including one dropped
3932
4196
  // below: the gap is a property of the link the FEC deadlines run on, not
3933
4197
  // of which packets this router routes. The re-entry after a manifest
@@ -4391,6 +4655,7 @@ export class MmtpRouter {
4391
4655
  if (!overlapCopy && this.#classifyPacketId(info.packetId) !== null) {
4392
4656
  this.#feedOverlaySource(
4393
4657
  info.packetId, catalogTrackName, packet, parsed.header, info.sequenceNumber, info.rapFlag, source,
4658
+ fecPayloadId?.ssId,
4394
4659
  );
4395
4660
  }
4396
4661
  }
@@ -4619,6 +4884,7 @@ export class MmtpRouter {
4619
4884
  stream = {
4620
4885
  key: JSON.stringify([packetId, catalogTrackName ?? null]),
4621
4886
  duplicates: new StreamDuplicateLedgers(() => this.duplicateHorizonFor(packetId)),
4887
+ overlayToBase: { routed: new Set(), pending: new Map() },
4622
4888
  };
4623
4889
  byName.set(catalogTrackName, stream);
4624
4890
  }
@@ -4817,8 +5083,28 @@ export class MmtpRouter {
4817
5083
  data: Uint8Array,
4818
5084
  meta: RoutedFrameMeta,
4819
5085
  cb: FrameCallback | null,
5086
+ /** The base SS_ID the FEC release gate ordered the frame by, if any. */
5087
+ fecSbn: number | undefined,
4820
5088
  ): void {
4821
5089
  const streamKey = this.#mediaStreamKey(packetId, meta.catalogTrackName);
5090
+ // 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
5094
+ // 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).
5100
+ if (this.#isFecTrack(packetId)) {
5101
+ const high = this.#deliveredVideoHighWaterByStream.get(streamKey);
5102
+ if (fecSbn !== undefined || (high !== undefined && authoredAdjacent(high, meta))) {
5103
+ this.#drainDirectRecoveredVideo(streamKey, packetId, frame => frame.timestamp <= meta.timestamp, cb);
5104
+ this.#emitDirectVideo(packetId, data, meta, cb);
5105
+ return;
5106
+ }
5107
+ }
4822
5108
  this.#enqueueDirectRecoveredVideo(packetId, data, meta);
4823
5109
  const lastSourceTs = this.#lastDirectSourceVideoTsByStream.get(streamKey);
4824
5110
  this.#drainDirectRecoveredVideo(
@@ -5056,6 +5342,17 @@ export class MmtpRouter {
5056
5342
  complete?: () => void,
5057
5343
  assisted = false,
5058
5344
  ): boolean {
5345
+ // A packet a keyframe overlay recovery routed before it fed the packet to
5346
+ // this block (#feedOverlayRecoveredToBase) is not routed again.
5347
+ const overlayRouted = ownerPacketId == null
5348
+ ? undefined
5349
+ : this.#streams.get(ownerPacketId)?.get(catalogTrackName)?.overlayToBase.routed;
5350
+ if (overlayRouted !== undefined && overlayRouted.size > 0) {
5351
+ result = {
5352
+ ...result,
5353
+ symbols: result.symbols.filter((sym) => !overlayRouted.has(this.#fecAnchorForRecovered(blockId, sym.index))),
5354
+ };
5355
+ }
5059
5356
  const entry = ownerPacketId == null ? undefined : this.#provenanceFor(ownerPacketId, catalogTrackName);
5060
5357
  const receiver = entry === undefined || entry === "ambiguous" ? entry : entry.receiver;
5061
5358
  const userAgent = !assisted && entry !== undefined && entry !== "ambiguous" && entry.protected === true && entry.userAgent === true;
@@ -5149,7 +5446,10 @@ export class MmtpRouter {
5149
5446
  ...result,
5150
5447
  symbols: result.symbols.map((sym) => {
5151
5448
  const wire = (this.#fecAnchorForRecovered(blockId, sym.index) - ssIdSeqOffset) >>> 0;
5152
- if (wire === sym.packetSequenceNumber) return sym;
5449
+ if (wire === sym.packetSequenceNumber) {
5450
+ this.#unstampedSsIdSeqOffsetByStream.set(streamKey, ssIdSeqOffset);
5451
+ return sym;
5452
+ }
5153
5453
  this.#restampedRecoveredSeqs++;
5154
5454
  this.#restampedStreams.add(streamKey);
5155
5455
  return { ...sym, packetSequenceNumber: wire };
@@ -5206,6 +5506,43 @@ export class MmtpRouter {
5206
5506
  );
5207
5507
  }
5208
5508
 
5509
+ // A recovered head whose sample FT=1 has not named yet (an overlay that
5510
+ // outran the source path) would complete its data unit untimed: the unit
5511
+ // is dropped, marked completed, and the timed source copy is then refused
5512
+ // (BLO-53342). Hand it to the parser instead, which holds it in its
5513
+ // awaiting-timing queue and feeds it again when FT=1 lands; its recovered
5514
+ // continuations park in the reassembler meanwhile. If the source path
5515
+ // lost the head, this held copy is the keyframe (BLO-53369).
5516
+ const untimedHead = (sym: (typeof result.symbols)[number]): boolean =>
5517
+ sym.fragmentType === 2 &&
5518
+ sym.fragment.fragmentationIndicator === 1 &&
5519
+ sym.fragment.movieFragmentSequenceNumber !== undefined &&
5520
+ sym.fragment.sampleNumber !== undefined &&
5521
+ !this.#parser.hasAuthoredTimingFor(
5522
+ ownerPacketId,
5523
+ sym.fragment.mpuSequenceNumber,
5524
+ sym.fragment.movieFragmentSequenceNumber,
5525
+ sym.fragment.sampleNumber,
5526
+ catalogTrackName,
5527
+ );
5528
+ for (const sym of result.symbols) {
5529
+ if (!untimedHead(sym) ||
5530
+ this.#sourceSeqReceived(ownerPacketId, sym.packetSequenceNumber, catalogTrackName)) continue;
5531
+ this.#feedViaParser(
5532
+ sym.rawSymbol,
5533
+ { packetId: ownerPacketId, sequenceNumber: sym.packetSequenceNumber },
5534
+ undefined,
5535
+ {
5536
+ skipFecPayloadId: true,
5537
+ recovered: true,
5538
+ deliveryProvenance,
5539
+ ...fecSbnOf(sym.index),
5540
+ ...(catalogTrackName === undefined ? {} : { catalogTrackName }),
5541
+ },
5542
+ );
5543
+ }
5544
+ result = { ...result, symbols: result.symbols.filter((sym) => !untimedHead(sym)) };
5545
+
5209
5546
  // MSE assembler path: route recovered block as pre-assembled frames (skip FT=0)
5210
5547
  // BLO-3035 layer 3: recovered symbols are RAW MFU fragments, not complete
5211
5548
  // frames. A keyframe can span multiple packets (FI=1/2/3); pushing each
@@ -15,7 +15,10 @@
15
15
  * base (`MmtpRouter.feedRepairPacket(..., repairTrackName)`).
16
16
  *
17
17
  * `stream` delivery means QUIC retransmits: no repair track is ever
18
- * subscribed (FEC only for unreliable transports).
18
+ * subscribed (FEC only for unreliable transports). The mode is the source's
19
+ * `deliveryMode` (`catalogTrackDeliveryMode`); absent is `datagram`. It is
20
+ * read from the current source, so on a catalog refresh re-feed the updated
21
+ * source through `switchSource` for a changed `deliveryMode` to take effect.
19
22
  *
20
23
  * ABR switch R -> R' (`switchSource`): R's subscriptions are closed, the layer
21
24
  * count carries to R', and any packet R's subscriptions deliver after the
@@ -35,6 +38,8 @@ export interface RepairLayerSource {
35
38
  repairLayers?: readonly RepairLayerParams[]
36
39
  /** Keyframe overlays (`TrackFecParams.repairOverlays`); at most one is used. */
37
40
  repairOverlays?: readonly RepairOverlayParams[]
41
+ /** The publisher's MoQ delivery mode; absent is `datagram`. */
42
+ deliveryMode?: MoqRepairDelivery
38
43
  }
39
44
 
40
45
  export interface RepairTrackSubscription {
@@ -43,7 +48,6 @@ export interface RepairTrackSubscription {
43
48
 
44
49
  export interface RepairLayerSubscriptionsOptions {
45
50
  source: RepairLayerSource
46
- delivery: MoqRepairDelivery
47
51
  /** Subscribe one repair track; every object payload goes to `onPacket`. */
48
52
  subscribe(
49
53
  repairTrackName: string,
@@ -110,7 +114,7 @@ export class RepairLayerSubscriptions {
110
114
  * or interleaved sequences read as loss.
111
115
  */
112
116
  noteSourceSequence(seq: number): void {
113
- if (this.#disposed || this.#opts.delivery === 'stream') return
117
+ if (this.#disposed || this.#delivery() === 'stream') return
114
118
  seq >>>= 0
115
119
  if (this.#lastSeq === undefined) {
116
120
  this.#lastSeq = seq
@@ -152,13 +156,13 @@ export class RepairLayerSubscriptions {
152
156
  this.#expected = 0
153
157
  this.#lost = 0
154
158
  this.#wantFewer = 0
155
- this.#layerCount = Math.min(this.#layerCount, this.#layers().length)
159
+ this.#layerCount = this.#delivery() === 'stream' ? 0 : Math.min(this.#layerCount, this.#layers().length)
156
160
  this.#reconcile()
157
161
  }
158
162
 
159
163
  snapshot(): RepairLayerSubscriptionsSnapshot {
160
164
  return {
161
- delivery: this.#opts.delivery,
165
+ delivery: this.#delivery(),
162
166
  layerCount: this.#layerCount,
163
167
  subscribedTracks: [...this.#subs.keys()],
164
168
  repairPackets: this.#repairPackets,
@@ -174,6 +178,10 @@ export class RepairLayerSubscriptions {
174
178
  this.#disposed = true
175
179
  }
176
180
 
181
+ #delivery(): MoqRepairDelivery {
182
+ return this.#source.deliveryMode === 'stream' ? 'stream' : 'datagram'
183
+ }
184
+
177
185
  #layers(): readonly RepairLayerParams[] {
178
186
  return this.#source.repairLayers ?? []
179
187
  }
@@ -58,7 +58,7 @@ export interface TrackSessionConfig {
58
58
  * This is runtime join state, not catalog geometry: a receiver on a clean
59
59
  * path joins layer 0 only while a lossy one adds layers, so the catalog's
60
60
  * total P is not what this receiver is receiving. Diagnostics that report P
61
- * (the worker snapshot, CMCD `x-bc-fec-p`) must read this.
61
+ * (the worker snapshot, CMCD `com.blockcast-fp`) must read this.
62
62
  *
63
63
  * Absent when the caller joins no layers explicitly, in which case P_0 is
64
64
  * what was joined. That is an identity on a single-layer track, not a