@blockcast/mmt-container 2.1.0 → 2.2.0-main.02321582f1ca

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 +1302 -249
  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 +1749 -56
  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 +15683 -14941
  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 +414 -29
  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,82 @@ 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
+ /**
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, or a packet with no
266
+ * SS_ID, moves the offset away from the one shown, which voids it. Undefined
267
+ * without both.
268
+ */
269
+ function unstampedBaseSsId(
270
+ offset: number | undefined,
271
+ unstampedOffset: number | undefined,
272
+ headerPsn: number,
273
+ ): number | undefined {
274
+ if (offset === undefined || offset !== unstampedOffset) return undefined;
275
+ return (headerPsn + offset) >>> 0;
276
+ }
277
+
278
+ /** Base SS_IDs of overlay-routed packets kept per stream (see OverlayToBase). */
279
+ const OVERLAY_ROUTED_LIMIT = 1024;
280
+ /** Rebuilt sources kept per stream for a base block no repair stated yet. */
281
+ const OVERLAY_PENDING_LIMIT = 256;
282
+
283
+ /**
284
+ * One stream's keyframe-overlay recoveries on their way into its base
285
+ * decoder (joint decoding, BLO-52085).
286
+ */
287
+ interface OverlayToBase {
288
+ /** Base SS_IDs of the packets an overlay recovery routed (as media, or held
289
+ * for their FT=1): a base recovery does not route them a second time. */
290
+ readonly routed: Set<number>;
291
+ /** Rebuilt sources, by base SS_ID, whose base block no repair stated yet. */
292
+ readonly pending: Map<number, Uint8Array>;
214
293
  }
215
294
 
216
295
  interface OverlayInstance {
@@ -222,8 +301,8 @@ interface OverlayInstance {
222
301
  readonly k: number;
223
302
  /** The newest overlay symbol seen: its overlay SS_ID and wire sequence. */
224
303
  last?: { ssId: number; psn: number };
225
- /** MPU sequence number → overlay SS_ID − wire packet_sequence_number. */
226
- readonly offsets: Map<number, number>;
304
+ /** MPU sequence number → its newest received member. */
305
+ readonly offsets: Map<number, OverlayMemberAnchor>;
227
306
  /** Overlay blocks a MoQ symbol fed (multicast-02 7.2.7). */
228
307
  readonly moqBlocks: Set<number>;
229
308
  }
@@ -245,6 +324,14 @@ export interface MmtpIngressContext {
245
324
  * the same ingress and `feedPacket` splits them off.
246
325
  */
247
326
  flow?: string;
327
+ /**
328
+ * `USER_AGENT_VERIFIED` when the datagram came from the browser's own
329
+ * MulticastReceiver, which verified the flow's records itself and delivers
330
+ * only admitted data (`userAgentVerifiedIngress` in
331
+ * `@blockcast/mmt-transport`). A protected multicast datagram carrying it
332
+ * is admitted without a record. Any other value means nothing.
333
+ */
334
+ admittedBy?: symbol;
248
335
  }
249
336
 
250
337
  /** One physical stream: an MMTP packet ID and the catalog track it carries. */
@@ -502,6 +589,9 @@ export type RoutedPacketMeta = {
502
589
  mmtpTimestamp: bigint;
503
590
  /**
504
591
  * The MMTP header RAP flag: the packet starts a random access point. On a
592
+ * track under a keyframe overlay it is also set on the FT=0 and FT=1 data
593
+ * units that govern a covered keyframe (draft-ramadan-moq-mmt-02 7.4.2), so
594
+ * it can precede the keyframe's first MFU fragment. On a
505
595
  * `recovered` packet it is read from the FEC-reconstructed header, which is
506
596
  * byte-exact outside packet_sequence_number, so it is the flag the
507
597
  * publisher sent. The router's own gap decisions deliberately ignore it for
@@ -565,6 +655,7 @@ interface StreamLedgers {
565
655
  * the pair.
566
656
  */
567
657
  readonly duplicates: StreamDuplicateLedgers;
658
+ readonly overlayToBase: OverlayToBase;
568
659
  }
569
660
 
570
661
  const NO_FEC_MANAGER_HORIZON: DuplicateHorizon = Object.freeze({
@@ -652,6 +743,17 @@ export interface FECManagerLike {
652
743
  catalogTrackName?: string,
653
744
  ): Uint8Array | null;
654
745
 
746
+ /** Feed a source another FEC instance over the same packets rebuilt (a
747
+ * keyframe overlay) to its base block, which a repair must already have
748
+ * stated; not a network arrival (FECManager.feedRebuiltSourceSymbol).
749
+ * Optional: without it the overlay and the base decode apart. */
750
+ feedRebuiltSourceSymbol?(
751
+ packetId: number,
752
+ ssId: number,
753
+ symbolData: Uint8Array,
754
+ catalogTrackName?: string,
755
+ ): RebuiltSourceFeed;
756
+
655
757
  /** How long ago the stream's oldest open block with an FEC deadline took its first symbol (optional). */
656
758
  oldestOpenBlockAgeMs?(packetId: number, catalogTrackName: string): number | undefined;
657
759
 
@@ -946,6 +1048,15 @@ function sourceSymbolEvidence(
946
1048
  };
947
1049
  }
948
1050
 
1051
+ /** One protected flow: its 7.2.7 receiver, and the ingress its datagrams arrive with. */
1052
+ interface ProvenanceEntry {
1053
+ receiver: BcProvenanceV2Receiver;
1054
+ ingress: MmtpIngressContext;
1055
+ protected?: boolean;
1056
+ /** A datagram the user agent verified admitted the flow (`admittedBy`). */
1057
+ userAgent?: boolean;
1058
+ }
1059
+
949
1060
  export class MmtpRouter {
950
1061
  readonly #parser: MmtpContainerParser;
951
1062
  // Source AND FEC-recovered MFU fragments share ONE reassembler — the parser's
@@ -955,10 +1066,7 @@ export class MmtpRouter {
955
1066
  #defaultFecMapper: FecBlockMapper | null;
956
1067
  #fecMappersByPacketId = new Map<number, FecBlockMapper>();
957
1068
  /** 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
- >();
1069
+ readonly #provenance = new Map<string, ProvenanceEntry>();
962
1070
  #onFlowProtected: ((flow: string) => void) | null = null;
963
1071
  #unadmittedListedDrops = 0;
964
1072
  #unlistedRepairSetAside = 0;
@@ -1029,6 +1137,14 @@ export class MmtpRouter {
1029
1137
  * sequence is its SS_ID minus this; see #routeProcessedFecSymbols.
1030
1138
  */
1031
1139
  #ssIdSeqOffsetByStream = new Map<string, number>();
1140
+ /**
1141
+ * Per stream, the SS_ID offset at which a base recovery last showed the
1142
+ * sender's own packet_sequence_number: a recovered packet's header psn
1143
+ * equal to its SS_ID minus that offset. While it equals the stream's
1144
+ * current offset, no hop re-stamps the stream and a packet's protected psn
1145
+ * names its base SS_ID (joint decoding of an MPU no member of which arrived).
1146
+ */
1147
+ #unstampedSsIdSeqOffsetByStream = new Map<string, number>();
1032
1148
  /**
1033
1149
  * Streams whose multicast leg delivered a RAP-flagged source packet that was
1034
1150
  * not parsed, typically because its overlap copy was parsed first
@@ -1106,7 +1222,7 @@ export class MmtpRouter {
1106
1222
  #restampedStreams = new Set<string>();
1107
1223
  #overlayStats: KeyframeOverlayStats = {
1108
1224
  repairDropped: 0, ownerRefused: 0, recovered: 0, restarts: 0,
1109
- unsequencedDropped: 0, protectedDropped: 0,
1225
+ unsequencedDropped: 0, protectedDropped: 0, baseFed: 0, baseUndetermined: 0,
1110
1226
  };
1111
1227
  /** Callback fired when a delta-track FEC block completes or fails. */
1112
1228
  #onDeltaBlockCompleteCb:
@@ -1385,7 +1501,7 @@ export class MmtpRouter {
1385
1501
  const cb = this.#getFrameCallback(packetId);
1386
1502
  if (isVideo && !assembler) {
1387
1503
  if (frameMeta.recovered) {
1388
- this.#routeDirectRecoveredVideo(packetId, data, frameMeta, cb);
1504
+ this.#routeDirectRecoveredVideo(packetId, data, frameMeta, cb, meta.fecSbn);
1389
1505
  } else {
1390
1506
  this.#routeDirectSourceVideo(packetId, data, frameMeta, cb);
1391
1507
  }
@@ -1900,6 +2016,11 @@ export class MmtpRouter {
1900
2016
  return { ...this.#overlayStats };
1901
2017
  }
1902
2018
 
2019
+ /** Recovered FT=2 heads held for their FT=1, and how each left the wait (BLO-53369). */
2020
+ get recoveredHeadsAwaitingTiming(): Readonly<MmtpRecoveredHeadTimingStats> {
2021
+ return this.#parser.recoveredHeadsAwaitingTiming;
2022
+ }
2023
+
1903
2024
  #isOverlayRepair(repairPacketId: number, repairTrackName?: string): boolean {
1904
2025
  return this.#overlayLegs.has(repairPacketId) ||
1905
2026
  (repairTrackName !== undefined && this.#overlayRepairNames.has(repairTrackName));
@@ -1951,6 +2072,13 @@ export class MmtpRouter {
1951
2072
  */
1952
2073
  resetKeyframeOverlays(sourcePacketId: number, catalogTrackName?: string): void {
1953
2074
  for (const inst of this.#overlaysForSource(sourcePacketId, catalogTrackName)) this.#resetOverlay(inst);
2075
+ // The base restarted with it: base SS_IDs kept for joint decoding name
2076
+ // the old epoch's packets.
2077
+ for (const [name, stream] of this.#streams.get(sourcePacketId) ?? []) {
2078
+ if (catalogTrackName !== undefined && name !== catalogTrackName) continue;
2079
+ stream.overlayToBase.routed.clear();
2080
+ stream.overlayToBase.pending.clear();
2081
+ }
1954
2082
  }
1955
2083
 
1956
2084
  #resetOverlay(inst: OverlayInstance): void {
@@ -2028,7 +2156,8 @@ export class MmtpRouter {
2028
2156
  * A source packet that carries a 0x8B01 overlay SS_ID is also a symbol of
2029
2157
  * every overlay installed for its source, at that SS_ID, with the bytes the
2030
2158
  * base protects: the packet minus its 4-byte SS_ID trailer, extension
2031
- * included (7.1, 7.4.3).
2159
+ * included (7.1, 7.4.3). `baseSsId` is that trailer's SS_ID, when the base
2160
+ * FEC reads it.
2032
2161
  */
2033
2162
  #feedOverlaySource(
2034
2163
  packetId: number,
@@ -2038,6 +2167,7 @@ export class MmtpRouter {
2038
2167
  sequenceNumber: number,
2039
2168
  rapFlag: boolean,
2040
2169
  source: MmtpSourcePath,
2170
+ baseSsId: number | undefined,
2041
2171
  ): void {
2042
2172
  if (this.#overlays.size === 0) return;
2043
2173
  const ssId = keyframeOverlaySsId(header);
@@ -2046,8 +2176,22 @@ export class MmtpRouter {
2046
2176
  this.#noteOverlaySsId(inst, ssId, sequenceNumber);
2047
2177
  const identity = MmtpContainerParser.sourcePacketIdentity(packet, header);
2048
2178
  if (identity !== null) {
2179
+ // The newest member anchors its MPU; one with no base SS_ID leaves the
2180
+ // MPU no base anchor rather than keep an older member's. A member that
2181
+ // is no FEC source packet of the base takes an overlay SS_ID and a psn
2182
+ // but no base SS_ID, so past it an older anchor would put each rebuilt
2183
+ // packet one base slot high, and overlayBaseSsId's psn test would pass
2184
+ // it. That costs a conforming stream nothing: every member carries the
2185
+ // base trailer (7.4.3), unread only while the base track is
2186
+ // unconfigured (fecUnreadable), which joint decoding skips, and a base
2187
+ // release (MulticastManager) resets or removes the overlay, so no
2188
+ // readable member precedes an unread one in its MPU.
2049
2189
  inst.offsets.delete(identity.mpuSequenceNumber);
2050
- inst.offsets.set(identity.mpuSequenceNumber, (ssId - sequenceNumber) >>> 0);
2190
+ inst.offsets.set(identity.mpuSequenceNumber, {
2191
+ psnOffset: (ssId - sequenceNumber) >>> 0,
2192
+ overlaySsId: ssId,
2193
+ ...(baseSsId === undefined ? {} : { baseSsId }),
2194
+ });
2051
2195
  if (inst.offsets.size > OVERLAY_OFFSET_MPUS) inst.offsets.delete(inst.offsets.keys().next().value!);
2052
2196
  }
2053
2197
  const { fec, repairPacketId: pid, streamName: name } = inst;
@@ -2134,18 +2278,35 @@ export class MmtpRouter {
2134
2278
  const streamKey = this.#mediaStreamKey(sourcePacketId, sourceName);
2135
2279
  const restamped = this.#restampedStreams.has(streamKey);
2136
2280
  const restored = new Set<number>();
2281
+ const baseSsIds = new Map<ProcessFecBlockResult["symbols"][number], number>();
2137
2282
  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;
2283
+ const anchor = inst.offsets.get(sym.fragment.mpuSequenceNumber);
2284
+ if (anchor !== undefined) {
2285
+ const overlaySsId = serial32Add(blockStart, sym.index);
2286
+ const packetSequenceNumber = (overlaySsId - anchor.psnOffset) >>> 0;
2141
2287
  restored.add(packetSequenceNumber);
2142
- return [{ ...sym, packetSequenceNumber }];
2288
+ const out = { ...sym, packetSequenceNumber };
2289
+ const baseSsId = overlayBaseSsId(anchor, overlaySsId, packetSequenceNumber, sym.packetSequenceNumber);
2290
+ if (baseSsId !== undefined) baseSsIds.set(out, baseSsId);
2291
+ return [out];
2143
2292
  }
2144
2293
  // A stream re-stamped by a bridge that no base recovery has shown yet
2145
2294
  // keeps the header's sequence too: from received packets alone the
2146
2295
  // router cannot tell (their psn is the wire one). Base recovery or a
2147
2296
  // FETCH still covers such a keyframe.
2148
- if (!restamped) return [sym];
2297
+ if (!restamped) {
2298
+ // No member of this MPU arrived to anchor a base SS_ID. A base
2299
+ // recovery that showed the stream unstamped at its current offset
2300
+ // determines one from the packet's protected psn instead
2301
+ // (unstampedBaseSsId).
2302
+ const baseSsId = unstampedBaseSsId(
2303
+ this.#ssIdSeqOffsetByStream.get(streamKey),
2304
+ this.#unstampedSsIdSeqOffsetByStream.get(streamKey),
2305
+ sym.packetSequenceNumber,
2306
+ );
2307
+ if (baseSsId !== undefined) baseSsIds.set(sym, baseSsId);
2308
+ return [sym];
2309
+ }
2149
2310
  this.#overlayStats.unsequencedDropped++;
2150
2311
  return [];
2151
2312
  });
@@ -2155,6 +2316,98 @@ export class MmtpRouter {
2155
2316
  });
2156
2317
  this.#noteOverlayRecoveredRaps(sourcePacketId, sourceName, streamKey, symbols.filter((sym) =>
2157
2318
  restored.has(sym.packetSequenceNumber)));
2319
+ this.#feedOverlayRecoveredToBase(inst, blockStart, symbols, baseSsIds);
2320
+ }
2321
+
2322
+ /**
2323
+ * Joint decoding (BLO-52085). A packet the overlay rebuilt is a source of
2324
+ * its base block too, once its base SS_ID is determined (overlayBaseSsId,
2325
+ * from a received member of its MPU, else unstampedBaseSsId),
2326
+ * so it is fed to the base decoder as a rebuilt source
2327
+ * (FECManager.feedRebuiltSourceSymbol): the base block has one erasure
2328
+ * fewer to solve, and its repair can recover the block's other packets.
2329
+ * A base block no repair has stated yet takes it when one does
2330
+ * (#feedPendingRebuilt).
2331
+ *
2332
+ * The overlay route already handled each packet fed (delivered it, or
2333
+ * held an untimed head for its FT=1), so a base recovery routes none of
2334
+ * them again (#routeVerifiedFecSymbols): a second recovered copy of a
2335
+ * held head would wait in the parser for timing its first copy consumed.
2336
+ * Only an overlay block decoded from multicast symbols alone is fed: with
2337
+ * a MoQ symbol in it, the base block's recovery would depend on bytes
2338
+ * that crossed no multicast socket, which the provenance counted from the
2339
+ * block's own arrivals cannot show.
2340
+ */
2341
+ #feedOverlayRecoveredToBase(
2342
+ inst: OverlayInstance,
2343
+ blockStart: number,
2344
+ symbols: ProcessFecBlockResult["symbols"],
2345
+ baseSsIds: ReadonlyMap<ProcessFecBlockResult["symbols"][number], number>,
2346
+ ): void {
2347
+ const fec = this.#fecManager;
2348
+ const owner = inst.leg.sourcePacketId;
2349
+ const name = inst.leg.sourceCatalogTrackName;
2350
+ if (!fec?.feedRebuiltSourceSymbol || inst.moqBlocks.has(blockStart)) return;
2351
+ if (this.#classifyPacketId(owner) === null || fec.isTrackConfigured?.(owner) === false) return;
2352
+ const { duplicates, overlayToBase: { routed } } = this.#stream(owner, name);
2353
+ const feeds: Array<readonly [number, Uint8Array]> = [];
2354
+ for (const sym of symbols) {
2355
+ // A packet the base received already sits in its block.
2356
+ if (duplicates.sourceSeqs.has(sym.packetSequenceNumber)) continue;
2357
+ const ssId = baseSsIds.get(sym);
2358
+ if (ssId === undefined) {
2359
+ this.#overlayStats.baseUndetermined++;
2360
+ continue;
2361
+ }
2362
+ routed.delete(ssId);
2363
+ routed.add(ssId);
2364
+ if (routed.size > OVERLAY_ROUTED_LIMIT) routed.delete(routed.values().next().value!);
2365
+ feeds.push([ssId, sym.rawSymbol]);
2366
+ }
2367
+ // Any feed can recover the base block, whose routing must already skip
2368
+ // every packet this recovery routed: all are registered before the first.
2369
+ for (const [ssId, symbolData] of feeds) {
2370
+ // A recovery routed above can retire the owner (an in-place switch).
2371
+ if (this.#classifyPacketId(owner) === null) return;
2372
+ this.#feedRebuiltToBase(owner, name, ssId, symbolData);
2373
+ }
2374
+ }
2375
+
2376
+ /** Feed one rebuilt source to its base block, or keep it until a repair states that block. */
2377
+ #feedRebuiltToBase(owner: number, name: string | undefined, ssId: number, symbolData: Uint8Array): void {
2378
+ const fec = this.#fecManager;
2379
+ if (!fec?.feedRebuiltSourceSymbol) return;
2380
+ const coords = fec.locateIsoSource?.(owner, name, ssId);
2381
+ if (coords == null) {
2382
+ const pending = this.#stream(owner, name).overlayToBase.pending;
2383
+ pending.delete(ssId);
2384
+ // Its own copy: the symbol is a view of the whole recovered block.
2385
+ pending.set(ssId, symbolData.slice());
2386
+ if (pending.size > OVERLAY_PENDING_LIMIT) pending.delete(pending.keys().next().value!);
2387
+ return;
2388
+ }
2389
+ const fed = fec.feedRebuiltSourceSymbol(owner, ssId, symbolData, name);
2390
+ if (!fed.taken) return;
2391
+ this.#overlayStats.baseFed++;
2392
+ if (fed.recovered === null) return;
2393
+ const sbn = coords.sbn;
2394
+ this.#routeRecoveredBlock(sbn, fed.recovered, owner, name, () => {
2395
+ fec.cancelBlockTimeout(sbn, owner, name);
2396
+ fec.countRecovery(owner, sbn, name);
2397
+ fec.markBlockCompleted(sbn, owner, name);
2398
+ });
2399
+ }
2400
+
2401
+ /** A repair stated `block`: the rebuilt sources that waited for it join it now. */
2402
+ #feedPendingRebuilt(owner: number, name: string | undefined, block: { start: number; length: number }): void {
2403
+ const pending = this.#streams.get(owner)?.get(name)?.overlayToBase.pending;
2404
+ if (pending === undefined || pending.size === 0) return;
2405
+ for (const [ssId, symbolData] of [...pending]) {
2406
+ if (this.#classifyPacketId(owner) === null) return;
2407
+ if (serial32Distance(block.start, ssId) >= block.length) continue;
2408
+ pending.delete(ssId);
2409
+ this.#feedRebuiltToBase(owner, name, ssId, symbolData);
2410
+ }
2158
2411
  }
2159
2412
 
2160
2413
  /** fec-multicast-00 §6.6: report each base block whose RAP MFU fragments
@@ -2190,6 +2443,9 @@ export class MmtpRouter {
2190
2443
  const mpu = sym.fragment.mpuSequenceNumber;
2191
2444
  if (sym.fragmentType !== 2 || !sym.fragment.rapFlag || !ft0.has(mpu) || !ft1.has(mpu)) continue;
2192
2445
  if (!single.has(mpu) && !(first.has(mpu) && last.has(mpu))) continue;
2446
+ // Not overlayBaseSsId: a wrong sbn only keeps or wrongly stops a keyframe
2447
+ // FETCH (§6.6, a SHOULD; no symbol moves), and wire psn plus offset holds
2448
+ // under a constant re-stamp shift, which overlayBaseSsId's psn test refuses.
2193
2449
  const ssId = (sym.packetSequenceNumber + ssIdSeqOffset) >>> 0;
2194
2450
  // ponytail: a base block no repair has stated yet (an undelimited hole) is not reported.
2195
2451
  const sbn = this.#fecManager?.locateIsoSource?.(packetId, catalogTrackName, ssId)?.sbn;
@@ -2329,12 +2585,13 @@ export class MmtpRouter {
2329
2585
  if (!entry) return;
2330
2586
  entry.receiver.release();
2331
2587
  entry.protected = false;
2588
+ entry.userAgent = false;
2332
2589
  // ponytail: drops every governed block's record, not only this flow's; per-flow if joins multiply.
2333
2590
  this.#assistBlocks.clear();
2334
2591
  this.#assistFailures.clear();
2335
2592
  }
2336
2593
 
2337
- /** MoQ packets of a track a protected flow lists, dropped on its unicast path: duplicate, overflow, or listed by more than one flow. */
2594
+ /** 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
2595
  get unadmittedListedDropCount(): number {
2339
2596
  return this.#unadmittedListedDrops;
2340
2597
  }
@@ -2384,6 +2641,14 @@ export class MmtpRouter {
2384
2641
  return false;
2385
2642
  }
2386
2643
 
2644
+ /** Whether a protected flow the user agent admitted lists `packetId`. */
2645
+ #userAgentGoverns(packetId: number): boolean {
2646
+ for (const entry of this.#provenance.values()) {
2647
+ if (entry.protected && entry.userAgent && ingressHasPacketId(entry.ingress, packetId)) return true;
2648
+ }
2649
+ return false;
2650
+ }
2651
+
2387
2652
  /**
2388
2653
  * A MoQ repair symbol no protected flow lists, owned by a source track one
2389
2654
  * does: its FEC instance is governed (7.2.7 step 2) and takes only admitted
@@ -2503,7 +2768,7 @@ export class MmtpRouter {
2503
2768
  fec.cancelBlockTimeout(blockStart, ownerPacketId, catalogTrackName);
2504
2769
  fec.countRecovery(ownerPacketId, blockStart, catalogTrackName);
2505
2770
  fec.markBlockCompleted(blockStart, ownerPacketId, catalogTrackName);
2506
- })) {
2771
+ }, true)) {
2507
2772
  this.#unlistedAssistedRecoveries++;
2508
2773
  this.#assistBlocks.delete(key);
2509
2774
  return;
@@ -2965,6 +3230,11 @@ export class MmtpRouter {
2965
3230
  )) return;
2966
3231
  fec.noteBlockActivity?.(blockStart, ownerPacketId, catalogTrackName);
2967
3232
  fec.noteRepairGroupLength?.(blockStart, ownerPacketId, repair.rsbLength, catalogTrackName);
3233
+ // Rebuilt sources that waited for this block join it once the block took
3234
+ // a repair, so it has a deadline; still ahead of the repair, as the held
3235
+ // sources above are.
3236
+ this.#feedPendingRebuilt(ownerPacketId, catalogTrackName, placement.block);
3237
+ if (this.#classifyPacketId(ownerPacketId) === null) return;
2968
3238
  const alreadyCompleted = fec.isBlockCompleted(blockStart, ownerPacketId, catalogTrackName);
2969
3239
  const recovered = fec.feedRepairSymbol(
2970
3240
  ownerPacketId,
@@ -3145,6 +3415,7 @@ export class MmtpRouter {
3145
3415
  this.#droppedPreSyncVideo.delete(streamKey);
3146
3416
  this.#latestVideoSeqByStream.delete(streamKey);
3147
3417
  this.#ssIdSeqOffsetByStream.delete(streamKey);
3418
+ this.#unstampedSsIdSeqOffsetByStream.delete(streamKey);
3148
3419
  this.#multicastRapTwinByStream.delete(streamKey);
3149
3420
  this.#lastDirectSourceVideoTsByStream.delete(streamKey);
3150
3421
  this.#lastDirectEmittedVideoTsByStream.delete(streamKey);
@@ -3959,6 +4230,15 @@ export class MmtpRouter {
3959
4230
  debugRouter(`[MmtpRouter] Dropping multicast packet pid=${info.packetId}: flow ${ingress.flow} has no receiver`);
3960
4231
  return;
3961
4232
  }
4233
+ // The user agent verified this datagram against the flow's records
4234
+ // itself, and the records never reach the page: admitted as is. The
4235
+ // flow stays registered, so its MoQ copies are still checked.
4236
+ if (ingress.admittedBy === USER_AGENT_VERIFIED) {
4237
+ this.#noteFlowAdmitted(ingress.flow);
4238
+ protectedFlow.userAgent = true;
4239
+ this.#processMMTP(packet, source, true, ingress, overlapCopy);
4240
+ return;
4241
+ }
3962
4242
  // Held: released by a record (feedProvenanceRecord) or expired. Duplicate / overflow: dropped.
3963
4243
  const { verdict } = protectedFlow.receiver.onDatagram(packet, "multicast");
3964
4244
  if (verdict === "admitted") {
@@ -3983,6 +4263,18 @@ export class MmtpRouter {
3983
4263
  }
3984
4264
  }
3985
4265
 
4266
+ // A multicast datagram that names no flow is unverified. Of a track a
4267
+ // user-agent-admitted flow lists it would feed that flow's live decoder,
4268
+ // whose recoveries are kept without a record (#routeVerifiedFecSymbols):
4269
+ // its source is dropped here and its repair set aside below, as for
4270
+ // unlisted MoQ. Under a record-only flow it is read as before: those
4271
+ // recoveries still need a record.
4272
+ const flowless = source === "multicast" && !manifestAdmitted && ingress?.flow === undefined;
4273
+ if (flowless && !info.isRepair && this.#userAgentGoverns(info.packetId)) {
4274
+ this.#unadmittedListedDrops++;
4275
+ return;
4276
+ }
4277
+
3986
4278
  if (info.isRepair) {
3987
4279
  // Repair packet — per ISO 23008-1 §C.5.3, carries (SS_Start, SSB_length, RS_ID).
3988
4280
  // Read from the header parsed above rather than parsing it again
@@ -4003,9 +4295,10 @@ export class MmtpRouter {
4003
4295
  );
4004
4296
  if (!repair) return;
4005
4297
  if (ownerPacketId != null && this.#fecManager) {
4006
- // An unadmitted MoQ symbol reaching here is unlisted (#admitUnicast).
4298
+ // An unadmitted MoQ symbol reaching here is unlisted (#admitUnicast);
4299
+ // a flow-less multicast one is unverified.
4007
4300
  if (
4008
- source === "moq" && !manifestAdmitted &&
4301
+ ((flowless && this.#userAgentGoverns(ownerPacketId)) || (source === "moq" && !manifestAdmitted)) &&
4009
4302
  this.#setAsideUnlistedRepair(ownerPacketId, catalogTrackNameForPacket(ingress, ownerPacketId), repair)
4010
4303
  ) return;
4011
4304
  this.#repairPidCounts[info.packetId] = (this.#repairPidCounts[info.packetId] || 0) + 1;
@@ -4345,6 +4638,7 @@ export class MmtpRouter {
4345
4638
  if (!overlapCopy && this.#classifyPacketId(info.packetId) !== null) {
4346
4639
  this.#feedOverlaySource(
4347
4640
  info.packetId, catalogTrackName, packet, parsed.header, info.sequenceNumber, info.rapFlag, source,
4641
+ fecPayloadId?.ssId,
4348
4642
  );
4349
4643
  }
4350
4644
  }
@@ -4573,6 +4867,7 @@ export class MmtpRouter {
4573
4867
  stream = {
4574
4868
  key: JSON.stringify([packetId, catalogTrackName ?? null]),
4575
4869
  duplicates: new StreamDuplicateLedgers(() => this.duplicateHorizonFor(packetId)),
4870
+ overlayToBase: { routed: new Set(), pending: new Map() },
4576
4871
  };
4577
4872
  byName.set(catalogTrackName, stream);
4578
4873
  }
@@ -4771,8 +5066,28 @@ export class MmtpRouter {
4771
5066
  data: Uint8Array,
4772
5067
  meta: RoutedFrameMeta,
4773
5068
  cb: FrameCallback | null,
5069
+ /** The base SS_ID the FEC release gate ordered the frame by, if any. */
5070
+ fecSbn: number | undefined,
4774
5071
  ): void {
4775
5072
  const streamKey = this.#mediaStreamKey(packetId, meta.catalogTrackName);
5073
+ // The FEC release gate let this frame out in order: only once every
5074
+ // block between it and the frame before it resolved, or because it
5075
+ // starts where that frame ended. Nothing still in flight can precede it
5076
+ // then, so it plays now, as a source frame does; holding it for the next
5077
+ // intact frame cost every repaired burst its length in decoder input
5078
+ // (BLO-52085). An overlay recovery names no base block for the gate to
5079
+ // wait on, so past a gap it may be ahead of frames still in flight (an
5080
+ // overlay that outran the wire, BLO-53342): it keeps waiting for the
5081
+ // source cursor below, unless it starts where the last emitted frame
5082
+ // ended (on this direct path, the delivered high water).
5083
+ if (this.#isFecTrack(packetId)) {
5084
+ const high = this.#deliveredVideoHighWaterByStream.get(streamKey);
5085
+ if (fecSbn !== undefined || (high !== undefined && authoredAdjacent(high, meta))) {
5086
+ this.#drainDirectRecoveredVideo(streamKey, packetId, frame => frame.timestamp <= meta.timestamp, cb);
5087
+ this.#emitDirectVideo(packetId, data, meta, cb);
5088
+ return;
5089
+ }
5090
+ }
4776
5091
  this.#enqueueDirectRecoveredVideo(packetId, data, meta);
4777
5092
  const lastSourceTs = this.#lastDirectSourceVideoTsByStream.get(streamKey);
4778
5093
  this.#drainDirectRecoveredVideo(
@@ -4950,6 +5265,7 @@ export class MmtpRouter {
4950
5265
  ownerPacketId?: number,
4951
5266
  catalogTrackName?: string,
4952
5267
  complete?: () => void,
5268
+ assisted = false,
4953
5269
  ): boolean {
4954
5270
  // MMTP path: use processFecBlock for MMTP-wrapped recovery.
4955
5271
  // Recovered symbols are individual MMTP packets that may be fragments
@@ -4974,7 +5290,7 @@ export class MmtpRouter {
4974
5290
  ownerPacketId,
4975
5291
  blockProfile,
4976
5292
  });
4977
- return this.#routeVerifiedFecSymbols(blockId, result, ownerPacketId, catalogTrackName, complete);
5293
+ return this.#routeVerifiedFecSymbols(blockId, result, ownerPacketId, catalogTrackName, complete, assisted);
4978
5294
  }
4979
5295
 
4980
5296
  /**
@@ -4987,6 +5303,19 @@ export class MmtpRouter {
4987
5303
  * recovered packets is refused is unrecovered (7.2.7): its caller leaves it
4988
5304
  * open, so it reaches its FEC deadline and fails by class. Always true
4989
5305
  * outside a protected flow.
5306
+ *
5307
+ * On a flow the user agent admitted, the live decoder's recoveries are kept
5308
+ * without a record: while the flow is protected that decoder takes only
5309
+ * symbols the user agent or a record verified (a flow-less multicast
5310
+ * datagram of a listed track is dropped or set aside in #processMMTP), so
5311
+ * what it recovers is as verified as they are. An `assisted` (scratch-decoder) recovery mixes in
5312
+ * unlisted MoQ repair and still needs a record.
5313
+ * ponytail: per-flow flag; per-block provenance only if a protected flow
5314
+ * ever mixes verified and unverified live inputs.
5315
+ * ponytail: a worker frame computed before the protection reset can still
5316
+ * arrive after it (frames carry no epoch); its inputs are MoQ copies read
5317
+ * and played before protection, so it admits nothing new. Drop pre-reset
5318
+ * frames by epoch only if pre-protection input ever matters.
4990
5319
  */
4991
5320
  #routeVerifiedFecSymbols(
4992
5321
  blockId: number,
@@ -4994,8 +5323,22 @@ export class MmtpRouter {
4994
5323
  ownerPacketId?: number,
4995
5324
  catalogTrackName?: string,
4996
5325
  complete?: () => void,
5326
+ assisted = false,
4997
5327
  ): boolean {
4998
- const receiver = ownerPacketId == null ? undefined : this.#provenanceFor(ownerPacketId, catalogTrackName);
5328
+ // A packet a keyframe overlay recovery routed before it fed the packet to
5329
+ // this block (#feedOverlayRecoveredToBase) is not routed again.
5330
+ const overlayRouted = ownerPacketId == null
5331
+ ? undefined
5332
+ : this.#streams.get(ownerPacketId)?.get(catalogTrackName)?.overlayToBase.routed;
5333
+ if (overlayRouted !== undefined && overlayRouted.size > 0) {
5334
+ result = {
5335
+ ...result,
5336
+ symbols: result.symbols.filter((sym) => !overlayRouted.has(this.#fecAnchorForRecovered(blockId, sym.index))),
5337
+ };
5338
+ }
5339
+ const entry = ownerPacketId == null ? undefined : this.#provenanceFor(ownerPacketId, catalogTrackName);
5340
+ const receiver = entry === undefined || entry === "ambiguous" ? entry : entry.receiver;
5341
+ const userAgent = !assisted && entry !== undefined && entry !== "ambiguous" && entry.protected === true && entry.userAgent === true;
4999
5342
  let recovered = true;
5000
5343
  if (receiver) {
5001
5344
  const parsed = this.#stream(ownerPacketId!, catalogTrackName).duplicates.parsedSymbols;
@@ -5008,6 +5351,8 @@ export class MmtpRouter {
5008
5351
  let admitted = false;
5009
5352
  if (receiver === "ambiguous") {
5010
5353
  this.#ambiguousRecoveredDrops++;
5354
+ } else if (userAgent) {
5355
+ admitted = true;
5011
5356
  } else {
5012
5357
  const wire = new Uint8Array(sym.rawSymbol.byteLength + 4);
5013
5358
  wire.set(sym.rawSymbol);
@@ -5033,7 +5378,7 @@ export class MmtpRouter {
5033
5378
  #provenanceFor(
5034
5379
  packetId: number,
5035
5380
  catalogTrackName: string | undefined,
5036
- ): BcProvenanceV2Receiver | "ambiguous" | undefined {
5381
+ ): ProvenanceEntry | "ambiguous" | undefined {
5037
5382
  // A caller that names no track matches every name the packet id was admitted under.
5038
5383
  const byName = this.#provenanceStreamFlow.get(packetId);
5039
5384
  const flows = new Set(
@@ -5042,12 +5387,12 @@ export class MmtpRouter {
5042
5387
  : byName.has(catalogTrackName) ? [byName.get(catalogTrackName)!] : [],
5043
5388
  );
5044
5389
  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))) {
5390
+ for (const bound of flows) return this.#provenance.get(bound) ?? "ambiguous";
5391
+ let match: ProvenanceEntry | undefined;
5392
+ for (const entry of this.#provenance.values()) {
5393
+ if (entry.ingress.tracks.some((t) => t.packetId === packetId && (catalogTrackName === undefined || t.name === catalogTrackName))) {
5049
5394
  if (match) return "ambiguous";
5050
- match = receiver;
5395
+ match = entry;
5051
5396
  }
5052
5397
  }
5053
5398
  return match;
@@ -5084,7 +5429,10 @@ export class MmtpRouter {
5084
5429
  ...result,
5085
5430
  symbols: result.symbols.map((sym) => {
5086
5431
  const wire = (this.#fecAnchorForRecovered(blockId, sym.index) - ssIdSeqOffset) >>> 0;
5087
- if (wire === sym.packetSequenceNumber) return sym;
5432
+ if (wire === sym.packetSequenceNumber) {
5433
+ this.#unstampedSsIdSeqOffsetByStream.set(streamKey, ssIdSeqOffset);
5434
+ return sym;
5435
+ }
5088
5436
  this.#restampedRecoveredSeqs++;
5089
5437
  this.#restampedStreams.add(streamKey);
5090
5438
  return { ...sym, packetSequenceNumber: wire };
@@ -5141,6 +5489,43 @@ export class MmtpRouter {
5141
5489
  );
5142
5490
  }
5143
5491
 
5492
+ // A recovered head whose sample FT=1 has not named yet (an overlay that
5493
+ // outran the source path) would complete its data unit untimed: the unit
5494
+ // is dropped, marked completed, and the timed source copy is then refused
5495
+ // (BLO-53342). Hand it to the parser instead, which holds it in its
5496
+ // awaiting-timing queue and feeds it again when FT=1 lands; its recovered
5497
+ // continuations park in the reassembler meanwhile. If the source path
5498
+ // lost the head, this held copy is the keyframe (BLO-53369).
5499
+ const untimedHead = (sym: (typeof result.symbols)[number]): boolean =>
5500
+ sym.fragmentType === 2 &&
5501
+ sym.fragment.fragmentationIndicator === 1 &&
5502
+ sym.fragment.movieFragmentSequenceNumber !== undefined &&
5503
+ sym.fragment.sampleNumber !== undefined &&
5504
+ !this.#parser.hasAuthoredTimingFor(
5505
+ ownerPacketId,
5506
+ sym.fragment.mpuSequenceNumber,
5507
+ sym.fragment.movieFragmentSequenceNumber,
5508
+ sym.fragment.sampleNumber,
5509
+ catalogTrackName,
5510
+ );
5511
+ for (const sym of result.symbols) {
5512
+ if (!untimedHead(sym) ||
5513
+ this.#sourceSeqReceived(ownerPacketId, sym.packetSequenceNumber, catalogTrackName)) continue;
5514
+ this.#feedViaParser(
5515
+ sym.rawSymbol,
5516
+ { packetId: ownerPacketId, sequenceNumber: sym.packetSequenceNumber },
5517
+ undefined,
5518
+ {
5519
+ skipFecPayloadId: true,
5520
+ recovered: true,
5521
+ deliveryProvenance,
5522
+ ...fecSbnOf(sym.index),
5523
+ ...(catalogTrackName === undefined ? {} : { catalogTrackName }),
5524
+ },
5525
+ );
5526
+ }
5527
+ result = { ...result, symbols: result.symbols.filter((sym) => !untimedHead(sym)) };
5528
+
5144
5529
  // MSE assembler path: route recovered block as pre-assembled frames (skip FT=0)
5145
5530
  // BLO-3035 layer 3: recovered symbols are RAW MFU fragments, not complete
5146
5531
  // frames. A keyframe can span multiple packets (FI=1/2/3); pushing each