@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
@@ -22,6 +22,7 @@ import {
22
22
  isoBlockContains,
23
23
  isoBlocksBehindLowerBound,
24
24
  parseFecBlockProfile,
25
+ serial32Add,
25
26
  serial32Distance,
26
27
  serial32Precedes,
27
28
  ssbg1FitsSymbol,
@@ -169,6 +170,17 @@ export interface FecPacketStats {
169
170
 
170
171
  export type FecSymbolSource = 'multicast' | 'moq'
171
172
 
173
+ /**
174
+ * What feedRebuiltSourceSymbol did with a rebuilt source: whether its block
175
+ * took it, and the block the synchronous decoder recovered with it, if any.
176
+ */
177
+ export interface RebuiltSourceFeed {
178
+ readonly taken: boolean
179
+ readonly recovered: Uint8Array | null
180
+ }
181
+
182
+ const NOT_TAKEN: RebuiltSourceFeed = Object.freeze({ taken: false, recovered: null })
183
+
172
184
  /**
173
185
  * One iso-ssbg1-v1 first-arrival source symbol MmtpRouter holds until a repair
174
186
  * symbol states its block: the exact protected packet (no SS_ID, unpadded),
@@ -514,6 +526,10 @@ export class FECManager {
514
526
  // Source symbol count per track-scoped block — marks complete when K symbols arrive.
515
527
  #blockSourceCounts = new Map<string, number>()
516
528
  #blockSourceEsis = new Map<string, Set<number>>()
529
+ // Source ESIs per block that another FEC instance rebuilt and fed in
530
+ // (feedRebuiltSourceSymbol). Kept apart from #blockSourceEsis: they are
531
+ // not network arrivals and never make a block clean.
532
+ #blockRebuiltEsis = new Map<string, Set<number>>()
517
533
  // Authored SSB_length per block (ISO/IEC 23008-1 §C.5.3), learned from the
518
534
  // first repair symbol. The FFmpeg moq_mmt publisher closes a block every D
519
535
  // media units, usually before it holds K symbols; under iso-ssbg1-v1
@@ -644,6 +660,10 @@ export class FECManager {
644
660
  // ── Keyframe-protected repair policy (block-policy.ts) ──
645
661
  // One classifier per physical stream, built from that stream's geometry.
646
662
  #classifiers = new Map<string, BlockClassifier>()
663
+ // A stream without catalog timing has no classifier, so the last RAP it
664
+ // released stands in for one: "an MPU boundary bounds its RAPs" holds until
665
+ // a second RAP is released inside one MPU (BLO-52085).
666
+ #noSpanRaps = new Map<string, { mpu: number; presentationTime: bigint; inside: boolean }>()
647
667
  // Geometry a stream-scoped configuration gave one stream (streamKey). ABR
648
668
  // rungs can share a packetId with a different framerate or GOP, so the
649
669
  // packet-level geometry is only the geometry of a packet whose streams
@@ -1706,6 +1726,7 @@ export class FECManager {
1706
1726
  // The classifier compares authored order with SBN order only within
1707
1727
  // one continuous SBN sequence.
1708
1728
  this.#classifiers.get(streamKey)?.noteSourceDiscontinuity()
1729
+ this.#noSpanRaps.delete(streamKey)
1709
1730
  this.#highestSbn.set(streamKey, { sbn, atMs: now })
1710
1731
  this.#sourceFrontiers.set(streamKey, { sbn, atMs: now })
1711
1732
  if (psn === undefined) this.#sourceAnchors.delete(streamKey)
@@ -2289,6 +2310,9 @@ export class FECManager {
2289
2310
  // sibling's change, but the first stream-scoped geometry on a packet
2290
2311
  // does change the siblings that have none (stream-geometry-absent).
2291
2312
  this.#bumpEpoch(packetId)
2313
+ for (const key of [...this.#noSpanRaps.keys()]) {
2314
+ if (this.#belongsToPacket(key, packetId)) this.#noSpanRaps.delete(key)
2315
+ }
2292
2316
  for (const [key, classifier] of [...this.#classifiers]) {
2293
2317
  if (!this.#belongsToPacket(key, packetId)) continue
2294
2318
  const stream = this.#parseStreamKey(key)
@@ -2532,6 +2556,7 @@ export class FECManager {
2532
2556
  }
2533
2557
  resetMap(this.#blockSourceCounts)
2534
2558
  resetMap(this.#blockSourceEsis)
2559
+ resetMap(this.#blockRebuiltEsis)
2535
2560
  resetMap(this.#blockAuthoredLengths)
2536
2561
  resetMap(this.#blockRepairRsIds)
2537
2562
  resetMap(this.#blockRsbLengths)
@@ -2565,6 +2590,7 @@ export class FECManager {
2565
2590
  resetMap(this.#isoLocators)
2566
2591
  resetMap(this.#sourceAnchors)
2567
2592
  this.#classifiers.delete(this.#streamKey(packetId, catalogTrackName))
2593
+ this.#noSpanRaps.delete(this.#streamKey(packetId, catalogTrackName))
2568
2594
  this.#bumpEpoch(packetId)
2569
2595
  for (const [sbn, keys] of this.#blockKeysBySbn) {
2570
2596
  for (const key of keys.keys()) {
@@ -2663,6 +2689,7 @@ export class FECManager {
2663
2689
  dropMap(this.#pendingWorkerCleanupClients)
2664
2690
  dropMap(this.#blockSourceCounts)
2665
2691
  dropMap(this.#blockSourceEsis)
2692
+ dropMap(this.#blockRebuiltEsis)
2666
2693
  dropMap(this.#blockAuthoredLengths)
2667
2694
  dropMap(this.#blockRepairRsIds)
2668
2695
  dropMap(this.#blockRsbLengths)
@@ -2679,6 +2706,7 @@ export class FECManager {
2679
2706
  dropMap(this.#isoLocators)
2680
2707
  dropMap(this.#sourceAnchors)
2681
2708
  dropMap(this.#classifiers)
2709
+ dropMap(this.#noSpanRaps)
2682
2710
  dropMap(this.#streamGeometries)
2683
2711
  dropSet(this.#pendingWorkerCleanups)
2684
2712
  dropSet(this.#completedBlocks)
@@ -2881,6 +2909,10 @@ export class FECManager {
2881
2909
  }
2882
2910
  }
2883
2911
 
2912
+ if (isSourceSymbol && source === 'multicast') {
2913
+ this.#noteMulticastSource(this.#isoLocatorFor(packetId, catalogTrackName), (sbn + esi) >>> 0, packetId, catalogTrackName)
2914
+ }
2915
+
2884
2916
  return true
2885
2917
  }
2886
2918
 
@@ -3253,6 +3285,7 @@ export class FECManager {
3253
3285
  const locator = this.#growingIsoLocatorFor(packetId, catalogTrackName)
3254
3286
  const evictedBefore = locator.heldEvicted
3255
3287
  const held = locator.hold(ssId, source)
3288
+ if (held && source.source === 'multicast') this.#noteMulticastSource(locator, ssId, packetId, catalogTrackName)
3256
3289
  const evicted = locator.heldEvicted - evictedBefore
3257
3290
  if (evicted > 0) {
3258
3291
  const before = this.#isoHeldEvictedCount
@@ -3470,11 +3503,14 @@ export class FECManager {
3470
3503
  /**
3471
3504
  * Whether an MPU boundary still bounds this stream's RAPs (see
3472
3505
  * `BlockClassifier.mpuBoundsRaps`). Gap decisions drop MPU evidence once
3473
- * it does not. A stream with no classifier yet has shown nothing against it.
3506
+ * it does not. A stream with no classifier yet has shown nothing against it,
3507
+ * and neither has a stream without catalog timing until it releases two
3508
+ * RAPs inside one MPU.
3474
3509
  */
3475
3510
  mpuBoundsRapsFor(packetId: number, catalogTrackName?: string): boolean {
3476
- if (!this.#hasCatalogTimingForPacket(packetId)) return false
3477
- return this.#classifiers.get(this.#streamKey(packetId, catalogTrackName))?.mpuBoundsRaps ?? true
3511
+ const streamKey = this.#streamKey(packetId, catalogTrackName)
3512
+ if (!this.#hasCatalogTimingForPacket(packetId)) return this.#noSpanRaps.get(streamKey)?.inside !== true
3513
+ return this.#classifiers.get(streamKey)?.mpuBoundsRaps ?? true
3478
3514
  }
3479
3515
 
3480
3516
  /** A block's class now, or frozen at its resolution. */
@@ -3510,7 +3546,15 @@ export class FECManager {
3510
3546
  presentationTime: bigint,
3511
3547
  mediaTimescale: number,
3512
3548
  ): void {
3513
- if (!this.#trackConfigs.has(packetId) || !this.#hasCatalogTimingForPacket(packetId)) return
3549
+ if (!this.#trackConfigs.has(packetId)) return
3550
+ if (!this.#hasCatalogTimingForPacket(packetId)) {
3551
+ const streamKey = this.#streamKey(packetId, catalogTrackName)
3552
+ const last = this.#noSpanRaps.get(streamKey)
3553
+ const inside = last?.inside === true ||
3554
+ (last?.mpu === mpu && last.presentationTime !== presentationTime)
3555
+ this.#noSpanRaps.set(streamKey, { mpu, presentationTime, inside })
3556
+ return
3557
+ }
3514
3558
  this.#applyClassifierChanges(
3515
3559
  packetId,
3516
3560
  catalogTrackName,
@@ -4041,7 +4085,22 @@ export class FECManager {
4041
4085
  this.#isoSourceOversizeCount++
4042
4086
  return null
4043
4087
  }
4044
- const sbn = located.block.start
4088
+ return this.#feedDecoderSource(packetId, ssId, symbolData, catalogTrackName, located.block.start)
4089
+ }
4090
+
4091
+ /**
4092
+ * Hand one placed source symbol to the stream's decoder: its Worker, which
4093
+ * answers through onFrame (null here), or its synchronous decoder, whose
4094
+ * recovered block is returned. `sbn` is the SS_Start of the block a repair
4095
+ * stated for `ssId`.
4096
+ */
4097
+ #feedDecoderSource(
4098
+ packetId: number,
4099
+ ssId: number,
4100
+ symbolData: Uint8Array,
4101
+ catalogTrackName: string | undefined,
4102
+ sbn: number,
4103
+ ): Uint8Array | null {
4045
4104
  const blockKey = this.#blockKey(sbn, packetId, catalogTrackName)
4046
4105
  const syncBlockOwner = this.#syncDecoderBlockOwners.get(blockKey)
4047
4106
 
@@ -4086,6 +4145,81 @@ export class FECManager {
4086
4145
  return recovered
4087
4146
  }
4088
4147
 
4148
+ /**
4149
+ * Feed a source symbol of this stream that another FEC instance over the
4150
+ * same packets rebuilt: a keyframe overlay recovery whose base SS_ID the
4151
+ * caller determined (draft-ramadan-moq-mmt-02 7.1, 7.4.4; joint decoding,
4152
+ * BLO-52085). It joins its block's decoder as a received source would, so
4153
+ * it reduces the erasures the block still has to solve, but it is not a
4154
+ * network arrival: no transport accounting, no classification evidence,
4155
+ * no deadline refresh, and it never makes a block clean.
4156
+ *
4157
+ * Only a block a repair stated takes one (a repair-less span has no
4158
+ * decoder block). A block that is terminal, or already holds that source,
4159
+ * does not. The symbol is the exact protected packet, unpadded, as
4160
+ * feedSourceSymbol takes it.
4161
+ *
4162
+ * `recovered` is the block the synchronous decoder completed with it; the
4163
+ * caller routes it and completes the block, as for any recovery. When
4164
+ * every source of the block is then present, received or rebuilt, and no
4165
+ * decoder returned it (a Worker answers asynchronously, and reports such a
4166
+ * block as clean on its own side), the block is completed here as
4167
+ * recovered, with nothing left to route.
4168
+ */
4169
+ feedRebuiltSourceSymbol(
4170
+ packetId: number,
4171
+ ssId: number,
4172
+ symbolData: Uint8Array,
4173
+ catalogTrackName?: string,
4174
+ ): RebuiltSourceFeed {
4175
+ if (!this.#hasBlockProfile(packetId)) return NOT_TAKEN
4176
+ const located = this.#isoLocatorFor(packetId, catalogTrackName).locate(ssId)
4177
+ if (located === null || located.block.span) return NOT_TAKEN
4178
+ if (!ssbg1FitsSymbol(symbolData.byteLength, this.#trackConfig(packetId).symbolSize)) return NOT_TAKEN
4179
+ const sbn = located.block.start
4180
+ const key = this.#blockKey(sbn, packetId, catalogTrackName)
4181
+ if (this.#completedBlocks.has(key) || this.#blockSourceEsis.get(key)?.has(located.esi)) return NOT_TAKEN
4182
+ let rebuilt = this.#blockRebuiltEsis.get(key)
4183
+ if (rebuilt?.has(located.esi)) return NOT_TAKEN
4184
+ if (rebuilt === undefined) {
4185
+ rebuilt = new Set()
4186
+ this.#blockRebuiltEsis.set(key, rebuilt)
4187
+ }
4188
+ rebuilt.add(located.esi)
4189
+ const recovered = this.#feedDecoderSource(packetId, ssId, symbolData, catalogTrackName, sbn)
4190
+ if (recovered === null) this.#completeWhenSourcesPresent(key, sbn, located.block.length, packetId, catalogTrackName)
4191
+ return { taken: true, recovered }
4192
+ }
4193
+
4194
+ /**
4195
+ * A block that holds every one of its `length` sources, received or
4196
+ * rebuilt by another instance, with at least one rebuilt, is recovered:
4197
+ * every packet of it was delivered, by its own arrival or by the instance
4198
+ * that rebuilt it, so nothing is routed. Returns whether it completed it.
4199
+ */
4200
+ #completeWhenSourcesPresent(
4201
+ key: string,
4202
+ sbn: number,
4203
+ length: number,
4204
+ packetId: number,
4205
+ catalogTrackName: string | undefined,
4206
+ ): boolean {
4207
+ const rebuilt = this.#blockRebuiltEsis.get(key)
4208
+ if (rebuilt === undefined || this.#completedBlocks.has(key)) return false
4209
+ let present = rebuilt.size
4210
+ for (const esi of this.#blockSourceEsis.get(key) ?? []) if (!rebuilt.has(esi)) present++
4211
+ if (present < length) return false
4212
+ this.#traceLifecycle('recovered', sbn, packetId, catalogTrackName)
4213
+ this.countRecovery(packetId, sbn, catalogTrackName)
4214
+ this.cancelBlockTimeout(sbn, packetId, catalogTrackName)
4215
+ if (catalogTrackName === undefined) {
4216
+ this.onBlockComplete?.(sbn, packetId)
4217
+ } else {
4218
+ this.onBlockComplete?.(sbn, packetId, catalogTrackName)
4219
+ }
4220
+ return true
4221
+ }
4222
+
4089
4223
  /**
4090
4224
  * fec-01 4.5: a reed-solomon repair packet whose SSB_length + RSB_length
4091
4225
  * exceeds 255, or whose RS_ID is not below RSB_length, is discarded with the
@@ -4343,9 +4477,95 @@ export class FECManager {
4343
4477
  this.#completeCleanBlock(blockKey, sbn, packetId, catalogTrackName)
4344
4478
  return null
4345
4479
  }
4480
+ // Its K' is known now: a block this repair shows short of it waits less.
4481
+ if (recovered === null) this.#shortenUnrecoverableWait(blockKey, sbn, packetId, catalogTrackName)
4346
4482
  return recovered
4347
4483
  }
4348
4484
 
4485
+ /**
4486
+ * A first-arrival multicast source, held or placed. A datagram flow keeps
4487
+ * its sources in send order, so one that moves the stream's newest source
4488
+ * position past a block leaves the sources that block still misses
4489
+ * reordered behind it at best: it shortens the wait of every block it
4490
+ * passed that only a late source can complete. MoQ is left out: its streams
4491
+ * can overtake one another, so a MoQ source shows nothing about the sources
4492
+ * sent before it.
4493
+ */
4494
+ #noteMulticastSource(
4495
+ locator: IsoBlockLocator<IsoHeldSource>,
4496
+ ssId: number,
4497
+ packetId: number,
4498
+ catalogTrackName: string | undefined,
4499
+ ): void {
4500
+ const previous = locator.newestSource
4501
+ if (!locator.noteSource(ssId) || previous === undefined) return
4502
+ // The blocks whose last SS_ID lies in [previous, ssId): this source
4503
+ // passed them and the previous newest one had not.
4504
+ for (const block of locator.blocksBetween((previous - 1) >>> 0, ssId)) {
4505
+ if (block.span || !serial32Precedes(serial32Add(block.start, block.length - 1), ssId)) continue
4506
+ this.#shortenUnrecoverableWait(
4507
+ this.#blockKey(block.start, packetId, catalogTrackName),
4508
+ block.start,
4509
+ packetId,
4510
+ catalogTrackName,
4511
+ )
4512
+ }
4513
+ }
4514
+
4515
+ /**
4516
+ * The wait of a block that only a late source can complete (BLO-52085): a
4517
+ * repair stated it, its received sources plus every symbol its repair group
4518
+ * carries (RSB_length) are short of K' (the repair scheduler's
4519
+ * group-cannot-complete), and a later source of the stream has arrived, so
4520
+ * a source it misses is lost or reordered behind that one. The span, which
4521
+ * covers repair symbols paced after its sources, buys it nothing: it waits
4522
+ * the late-symbol allowance alone, the multicast wait less the span (the
4523
+ * catalog tolerance or the measured delivery gap). Undefined for any other
4524
+ * block.
4525
+ */
4526
+ #lateSourceWaitMs(
4527
+ key: string,
4528
+ sbn: number,
4529
+ packetId: number,
4530
+ catalogTrackName: string | undefined,
4531
+ config: ResolvedFECTrackConfig,
4532
+ ): number | undefined {
4533
+ const authoredLength = this.#blockAuthoredLengths.get(key)
4534
+ const rsbLength = this.#blockRsbLengths.get(key)
4535
+ if (authoredLength === undefined || rsbLength === undefined) return undefined
4536
+ if ((this.#blockSourceCounts.get(key) ?? 0) + rsbLength >= authoredLength) return undefined
4537
+ const newestSource = this.#isoLocators.get(this.#streamKey(packetId, catalogTrackName))?.newestSource
4538
+ if (newestSource === undefined || !serial32Precedes(serial32Add(sbn, authoredLength - 1), newestSource)) {
4539
+ return undefined
4540
+ }
4541
+ return this.#multicastWaitMs(config) - config.interleaveDepthMs
4542
+ }
4543
+
4544
+ /**
4545
+ * Bring an open block's wait in to its late-source wait, when it has one.
4546
+ * Never later than it is armed: only a source can complete such a block,
4547
+ * so a repair of its group (paced across the span) must not keep it open.
4548
+ */
4549
+ #shortenUnrecoverableWait(
4550
+ key: string,
4551
+ sbn: number,
4552
+ packetId: number,
4553
+ catalogTrackName: string | undefined,
4554
+ ): void {
4555
+ if (!this.#activeBlocks.has(key)) return
4556
+ const config = this.#trackConfigs.get(packetId)
4557
+ if (config === undefined) return
4558
+ const waitMs = this.#lateSourceWaitMs(key, sbn, packetId, catalogTrackName, config)
4559
+ if (waitMs === undefined) return
4560
+ const armedAtMs = this.#blockWaitDeadlinesMs.get(key)
4561
+ this.#armBlockTimeout(
4562
+ sbn,
4563
+ packetId,
4564
+ catalogTrackName,
4565
+ armedAtMs === undefined ? waitMs : Math.min(waitMs, armedAtMs - this.#receiptNow()),
4566
+ )
4567
+ }
4568
+
4349
4569
  /**
4350
4570
  * Record a block's authored SSB_length from a repair symbol. Returns false,
4351
4571
  * leaving the block unchanged, when the symbol contradicts the block: a
@@ -4561,6 +4781,20 @@ export class FECManager {
4561
4781
  this.#completeCleanBlock(key, sbn, packetId, catalogTrackName)
4562
4782
  return
4563
4783
  }
4784
+ // A late arrival can be the last source a block lacked besides those
4785
+ // another instance rebuilt (feedRebuiltSourceSymbol).
4786
+ if (
4787
+ this.#blockRebuiltEsis.has(key) &&
4788
+ this.#completeWhenSourcesPresent(
4789
+ key,
4790
+ sbn,
4791
+ this.#isoLocatorFor(packetId, catalogTrackName).blockAt(sbn)?.length ?? authoredLength,
4792
+ packetId,
4793
+ catalogTrackName,
4794
+ )
4795
+ ) {
4796
+ return
4797
+ }
4564
4798
 
4565
4799
  this.noteBlockActivity(sbn, packetId, catalogTrackName, true)
4566
4800
 
@@ -4580,6 +4814,7 @@ export class FECManager {
4580
4814
  this.cancelBlockTimeout(sbn, packetId, catalogTrackName)
4581
4815
  this.#blockSourceCounts.delete(key)
4582
4816
  this.#blockSourceEsis.delete(key)
4817
+ this.#blockRebuiltEsis.delete(key)
4583
4818
  this.#blockAuthoredLengths.delete(key)
4584
4819
  this.#blockRepairRsIds.delete(key)
4585
4820
  this.#blockRsbLengths.delete(key)
@@ -4693,7 +4928,18 @@ export class FECManager {
4693
4928
  )
4694
4929
  }
4695
4930
 
4696
- this.#armBlockTimeout(sbn, packetId, catalogTrackName, timeoutMs)
4931
+ // A block only a late source can complete waits the late-symbol
4932
+ // allowance from its own sources' activity; nothing else moves it later.
4933
+ if (!sourceProgress && this.#lateSourceWaitMs(key, sbn, packetId, catalogTrackName, trackConfig) !== undefined) {
4934
+ this.#shortenUnrecoverableWait(key, sbn, packetId, catalogTrackName)
4935
+ return
4936
+ }
4937
+ this.#armBlockTimeout(
4938
+ sbn,
4939
+ packetId,
4940
+ catalogTrackName,
4941
+ this.#lateSourceWaitMs(key, sbn, packetId, catalogTrackName, trackConfig) ?? timeoutMs,
4942
+ )
4697
4943
  }
4698
4944
 
4699
4945
  #failBlock(
@@ -4711,6 +4957,7 @@ export class FECManager {
4711
4957
  }
4712
4958
  this.#blockSourceCounts.delete(key)
4713
4959
  this.#blockSourceEsis.delete(key)
4960
+ this.#blockRebuiltEsis.delete(key)
4714
4961
  this.#blockAuthoredLengths.delete(key)
4715
4962
  this.#blockRepairRsIds.delete(key)
4716
4963
  this.#blockRsbLengths.delete(key)
@@ -4750,6 +4997,7 @@ export class FECManager {
4750
4997
  this.#retireBlockClass(key, block.sbn, block.packetId, block.catalogTrackName)
4751
4998
  this.#blockSourceCounts.delete(key)
4752
4999
  this.#blockSourceEsis.delete(key)
5000
+ this.#blockRebuiltEsis.delete(key)
4753
5001
  this.#blockAuthoredLengths.delete(key)
4754
5002
  this.#blockRepairRsIds.delete(key)
4755
5003
  this.#blockRsbLengths.delete(key)
@@ -4858,6 +5106,7 @@ export class FECManager {
4858
5106
  this.#blockKeysBySbn.clear()
4859
5107
  this.#blockSourceCounts.clear()
4860
5108
  this.#blockSourceEsis.clear()
5109
+ this.#blockRebuiltEsis.clear()
4861
5110
  this.#blockAuthoredLengths.clear()
4862
5111
  this.#blockRepairRsIds.clear()
4863
5112
  this.#blockRsbLengths.clear()
@@ -4883,6 +5132,7 @@ export class FECManager {
4883
5132
  this.#blockAnchorsMs.clear()
4884
5133
  this.#blockCatalogTrackNames.clear()
4885
5134
  this.#classifiers.clear()
5135
+ this.#noSpanRaps.clear()
4886
5136
  this.#streamGeometries.clear()
4887
5137
  this.#failedBlocks.clear()
4888
5138
  this.#resolvedBlockClasses.clear()
@@ -64,7 +64,7 @@ export interface FecWorkerSetupOptions {
64
64
  /**
65
65
  * Repair symbols per block this receiver actually joined: the sum of
66
66
  * `repairSymbols` over the joined BASE layers (draft-ramadan-moq-fec 6.3).
67
- * Reported as P by the worker snapshot and CMCD `x-bc-fec-p`, because the
67
+ * Reported as P by the worker snapshot and CMCD `com.blockcast-fp`, because the
68
68
  * catalog total is not what this receiver joined. Falls back to
69
69
  * `fecRepairCount`, which is the same value on a single-layer track.
70
70
  */
@@ -252,7 +252,7 @@ export async function setupFecWorkerForTrack(
252
252
  blockProfile,
253
253
  k: opts.fecK,
254
254
  // P as this receiver joined it, not the catalog total: the worker snapshot
255
- // and CMCD `x-bc-fec-p` are read from this. Identity on a single layer.
255
+ // and CMCD `com.blockcast-fp` are read from this. Identity on a single layer.
256
256
  repairCount: opts.joinedRepairCount ?? opts.fecRepairCount,
257
257
  symbolSize: opts.fecSymbolSize,
258
258
  interleaveDepth: interleaveDepthBlocks,
package/src/index.ts CHANGED
@@ -299,6 +299,7 @@ export {
299
299
  type MmtpFeedOutcomeKey,
300
300
  type TrackInfo,
301
301
  type MmtpReorderStats,
302
+ type MmtpRecoveredHeadTimingStats,
302
303
  type MmtpReorderFrameDiagnostics,
303
304
  type MmtpReorderStreamDiagnostics,
304
305
  type FrameMeta as ParserFrameMeta,
@@ -526,10 +527,13 @@ export {
526
527
  type FecStarvationDetectorConfig,
527
528
  } from './fec-starvation-detector.js'
528
529
 
529
- // CMCD Producer (x-bc-* vendor key telemetry, BLO-1044)
530
+ // CMCD v2 Event-mode producer (CTA-5004-B, com.blockcast-* custom keys)
530
531
  export {
531
532
  CmcdProducer,
532
- type CmcdParams,
533
+ encodeCmcdRecord,
534
+ type CmcdRecord,
535
+ type CmcdToken,
536
+ type CmcdValue,
533
537
  type CmcdProducerOptions,
534
538
  type SnapshotData,
535
539
  type TransportSnapshot,
@@ -546,6 +550,7 @@ export {
546
550
  type TrackFecParams,
547
551
  type TrackFecUnavailable,
548
552
  catalogTrackFecUnavailable,
553
+ catalogTrackDeliveryMode,
549
554
  catalogGroupDurationMs,
550
555
  catalogKeyframeCadenceMs,
551
556
  catalogFecMediaUnitsPerSecond,
@@ -602,11 +607,22 @@ export {
602
607
  type MmtpFecSessionConfig,
603
608
  type MmtpFecSessionDecoder,
604
609
  type MmtpFecSessionDecoderConstructor,
610
+ type MmtpFecSessionMessageConfig,
611
+ type MmtpFecSessionMessageUpdate,
605
612
  type MmtpFecSessionMixedPathStats,
606
613
  type MmtpFecSessionPath,
607
614
  type MmtpFecSessionPathCounts,
608
615
  type MmtpFecSessionStats,
609
616
  } from "./mmtp-fec-session.js";
617
+ // MoQ repair-layer subscriptions by measured loss (BLO-35456)
618
+ export {
619
+ RepairLayerSubscriptions,
620
+ type MoqRepairDelivery,
621
+ type RepairLayerSource,
622
+ type RepairLayerSubscriptionsOptions,
623
+ type RepairLayerSubscriptionsSnapshot,
624
+ type RepairTrackSubscription,
625
+ } from "./repair-layer-subscriptions.js";
610
626
 
611
627
  export {
612
628
  catalogAmtNetworkSource,
@@ -85,6 +85,7 @@ export class IsoBlockLocator<T> {
85
85
  #retentionBlocks: number
86
86
  #heldEvicted = 0
87
87
  #newest: number | undefined
88
+ #newestSource: number | undefined
88
89
  /** Pending holes (see `deferIncompleteGaps`): unstated, sources still held. */
89
90
  readonly #holes: IsoBlockRange[] = []
90
91
  /**
@@ -152,6 +153,23 @@ export class IsoBlockLocator<T> {
152
153
  if (this.#newest === undefined || serial32Precedes(this.#newest, position)) this.#newest = position
153
154
  }
154
155
 
156
+ /**
157
+ * The newest source SS_ID noted with `noteSource` (RFC 1982 order). Unlike
158
+ * `newest`, a repair never moves it: a repair can run ahead of its sources,
159
+ * a source cannot run ahead of the sources sent before it except by
160
+ * reordering.
161
+ */
162
+ get newestSource(): number | undefined {
163
+ return this.#newestSource
164
+ }
165
+
166
+ /** Note a received source's SS_ID; true when it is the newest one yet. */
167
+ noteSource(ssId: number): boolean {
168
+ if (this.#newestSource !== undefined && !serial32Precedes(this.#newestSource, ssId)) return false
169
+ this.#newestSource = ssId
170
+ return true
171
+ }
172
+
155
173
  /** Held sources evicted by `heldCapacity` (never by the horizon itself). */
156
174
  get heldEvicted(): number {
157
175
  return this.#heldEvicted
@@ -412,6 +430,7 @@ export class IsoBlockLocator<T> {
412
430
  this.#held.clear()
413
431
  this.#coverageEnd = undefined
414
432
  this.#newest = undefined
433
+ this.#newestSource = undefined
415
434
  }
416
435
 
417
436
  #claim(block: IsoBlockRange): IsoPlacedSource<T>[] {
@@ -581,6 +581,14 @@ type PendingMovieFragment = Array<IsoBmffSampleTiming | null>
581
581
  export class IsoBmffTimingResolver {
582
582
  #track: IsoBmffTrackTiming | null = null
583
583
  #fragments = new Map<string, PendingMovieFragment>()
584
+ /** First sample of each pending fragment, kept in `#delivered` with its sample count. */
585
+ #firstSamples = new Map<string, IsoBmffSampleTiming>()
586
+ /**
587
+ * First sample and sample count of the newest fragments whose every slot was
588
+ * cleared. Abandonment counts: `releaseSample` clears a slot too, which
589
+ * matches the combiner, since it retires an abandoned sample as well.
590
+ */
591
+ #delivered = new Map<string, { first: IsoBmffSampleTiming, sampleCount: number }>()
584
592
  #pendingSamples = 0
585
593
  #evictedSamples = 0
586
594
  #lastMovieFragmentRejection: IsoBmffMovieFragmentRejection | null = null
@@ -591,6 +599,11 @@ export class IsoBmffTimingResolver {
591
599
  }
592
600
  }
593
601
 
602
+ /** True once an FT=0 has bound the track that FT=1 timing is read against. */
603
+ get initialized(): boolean {
604
+ return this.#track !== null
605
+ }
606
+
594
607
  get pendingSampleCount(): number {
595
608
  return this.#pendingSamples
596
609
  }
@@ -618,10 +631,18 @@ export class IsoBmffTimingResolver {
618
631
  return true
619
632
  }
620
633
 
634
+ /**
635
+ * `recovered` marks an FEC-recovered copy. A recovered block hands back
636
+ * every FT=1 it holds, the received ones included, so a recovered copy of a
637
+ * fragment whose samples are all done is a duplicate: admitting it again
638
+ * would charge stale timing and could displace the live MPU's. A source
639
+ * copy is still admitted again (a rendition switch re-arms timing that way).
640
+ */
621
641
  addMovieFragment(
622
642
  mpuSequenceNumber: number,
623
643
  data: Uint8Array,
624
644
  additionalSampleLimit = this.maxPendingSamples,
645
+ recovered = false,
625
646
  ): boolean {
626
647
  this.#lastMovieFragmentRejection = null
627
648
  if (!Number.isInteger(additionalSampleLimit) || additionalSampleLimit < 0) {
@@ -648,6 +669,9 @@ export class IsoBmffTimingResolver {
648
669
  (sample, index) => sample !== null && sameSample(sample, parsed.samples[index]!),
649
670
  )
650
671
  }
672
+ const delivered = recovered ? this.#delivered.get(key) : undefined
673
+ if (delivered && delivered.sampleCount === parsed.samples.length &&
674
+ sameSample(delivered.first, parsed.samples[0]!)) return true
651
675
  // Sliding window, not a one-way ratchet. `consumeSample` and
652
676
  // `releaseSample` between them cover every sample some component observed
653
677
  // -- but a sample whose data units never arrive at all is observed by
@@ -683,6 +707,7 @@ export class IsoBmffTimingResolver {
683
707
  return false
684
708
  }
685
709
  this.#fragments.set(key, [...parsed.samples])
710
+ this.#firstSamples.set(key, parsed.samples[0]!)
686
711
  this.#pendingSamples += parsed.samples.length
687
712
  return true
688
713
  }
@@ -758,6 +783,7 @@ export class IsoBmffTimingResolver {
758
783
  let live = 0
759
784
  for (const sample of samples) if (sample !== null) live++
760
785
  this.#fragments.delete(oldest)
786
+ this.#firstSamples.delete(oldest)
761
787
  this.#pendingSamples -= live
762
788
  this.#evictedSamples += live
763
789
  return live
@@ -779,13 +805,26 @@ export class IsoBmffTimingResolver {
779
805
  if (!samples || !samples[index]) return false
780
806
  samples[index] = null
781
807
  this.#pendingSamples--
782
- if (samples.every(value => value === null)) this.#fragments.delete(key)
808
+ if (samples.every(value => value === null)) {
809
+ this.#fragments.delete(key)
810
+ const first = this.#firstSamples.get(key)!
811
+ this.#firstSamples.delete(key)
812
+ this.#delivered.delete(key)
813
+ this.#delivered.set(key, { first, sampleCount: samples.length })
814
+ // ponytail: the dedupe memory is as wide as the timing budget, in fragments;
815
+ // a recovered copy older than that is admitted again, as before.
816
+ if (this.#delivered.size > this.maxPendingSamples) {
817
+ this.#delivered.delete(this.#delivered.keys().next().value!)
818
+ }
819
+ }
783
820
  return true
784
821
  }
785
822
 
786
823
  reset(): void {
787
824
  this.#track = null
788
825
  this.#fragments.clear()
826
+ this.#firstSamples.clear()
827
+ this.#delivered.clear()
789
828
  this.#pendingSamples = 0
790
829
  }
791
830
  }