@blockcast/mmt-container 2.2.0-main.da2e7cd1dd5d → 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 (46) hide show
  1. package/dist/browser-runtime.js +472 -36
  2. package/dist/browser-runtime.js.map +2 -2
  3. package/dist/delivery-catalog.js +2 -1
  4. package/dist/delivery-catalog.js.map +2 -2
  5. package/dist/fec-manager.d.ts +30 -0
  6. package/dist/fec-manager.d.ts.map +1 -1
  7. package/dist/fec-manager.js +196 -2
  8. package/dist/fec-manager.js.map +2 -2
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js.map +2 -2
  12. package/dist/iso-block-locator.d.ts +9 -0
  13. package/dist/iso-block-locator.d.ts.map +1 -1
  14. package/dist/iso-block-locator.js +17 -0
  15. package/dist/iso-block-locator.js.map +2 -2
  16. package/dist/iso-bmff-timing.d.ts +10 -1
  17. package/dist/iso-bmff-timing.d.ts.map +1 -1
  18. package/dist/iso-bmff-timing.js +36 -2
  19. package/dist/iso-bmff-timing.js.map +2 -2
  20. package/dist/mmtp-container-parser.d.ts +35 -2
  21. package/dist/mmtp-container-parser.d.ts.map +1 -1
  22. package/dist/mmtp-container-parser.js +82 -9
  23. package/dist/mmtp-container-parser.js.map +2 -2
  24. package/dist/mmtp-container-parser.no-modules.js +85 -11
  25. package/dist/mmtp-container-parser.no-modules.js.map +2 -2
  26. package/dist/mmtp-decode-pipeline.js +81 -9
  27. package/dist/mmtp-decode-pipeline.js.map +2 -2
  28. package/dist/mmtp-router.d.ts +18 -1
  29. package/dist/mmtp-router.d.ts.map +1 -1
  30. package/dist/mmtp-router.js +276 -34
  31. package/dist/mmtp-router.js.map +2 -2
  32. package/dist/multicast-manager.js +472 -36
  33. package/dist/multicast-manager.js.map +2 -2
  34. package/dist/multicast-transport.js +2 -1
  35. package/dist/multicast-transport.js.map +2 -2
  36. package/dist/no-modules.js +81 -9
  37. package/dist/no-modules.js.map +2 -2
  38. package/dist/wasm-mmtp-container-parser.js +81 -9
  39. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  40. package/package.json +5 -5
  41. package/src/fec-manager.ts +251 -2
  42. package/src/index.ts +1 -0
  43. package/src/iso-block-locator.ts +19 -0
  44. package/src/iso-bmff-timing.ts +40 -1
  45. package/src/mmtp-container-parser.ts +84 -8
  46. package/src/mmtp-router.ts +346 -30
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockcast/mmt-container",
3
- "version": "2.2.0-main.da2e7cd1dd5d",
3
+ "version": "2.2.0-main.e75bbd7576a6",
4
4
  "description": "MMTP/LOC container parsing for MMT over MoQ and SSM",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -169,10 +169,10 @@
169
169
  "test:watch": "vitest"
170
170
  },
171
171
  "dependencies": {
172
- "@blockcast/fec-worker": "2.2.0-main.da2e7cd1dd5d",
173
- "@blockcast/mmt-base": "2.2.0-main.da2e7cd1dd5d",
174
- "@blockcast/mmt-fec": "2.2.0-main.da2e7cd1dd5d",
175
- "@blockcast/mmt-manifest-verify": "2.2.0-main.da2e7cd1dd5d",
172
+ "@blockcast/fec-worker": "2.2.0-main.e75bbd7576a6",
173
+ "@blockcast/mmt-base": "2.2.0-main.e75bbd7576a6",
174
+ "@blockcast/mmt-fec": "2.2.0-main.e75bbd7576a6",
175
+ "@blockcast/mmt-manifest-verify": "2.2.0-main.e75bbd7576a6",
176
176
  "@noble/curves": "^1.9.7",
177
177
  "@noble/hashes": "^1.8.0",
178
178
  "ajv": "^8.17.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
@@ -2540,6 +2556,7 @@ export class FECManager {
2540
2556
  }
2541
2557
  resetMap(this.#blockSourceCounts)
2542
2558
  resetMap(this.#blockSourceEsis)
2559
+ resetMap(this.#blockRebuiltEsis)
2543
2560
  resetMap(this.#blockAuthoredLengths)
2544
2561
  resetMap(this.#blockRepairRsIds)
2545
2562
  resetMap(this.#blockRsbLengths)
@@ -2672,6 +2689,7 @@ export class FECManager {
2672
2689
  dropMap(this.#pendingWorkerCleanupClients)
2673
2690
  dropMap(this.#blockSourceCounts)
2674
2691
  dropMap(this.#blockSourceEsis)
2692
+ dropMap(this.#blockRebuiltEsis)
2675
2693
  dropMap(this.#blockAuthoredLengths)
2676
2694
  dropMap(this.#blockRepairRsIds)
2677
2695
  dropMap(this.#blockRsbLengths)
@@ -2891,6 +2909,10 @@ export class FECManager {
2891
2909
  }
2892
2910
  }
2893
2911
 
2912
+ if (isSourceSymbol && source === 'multicast') {
2913
+ this.#noteMulticastSource(this.#isoLocatorFor(packetId, catalogTrackName), (sbn + esi) >>> 0, packetId, catalogTrackName)
2914
+ }
2915
+
2894
2916
  return true
2895
2917
  }
2896
2918
 
@@ -3263,6 +3285,7 @@ export class FECManager {
3263
3285
  const locator = this.#growingIsoLocatorFor(packetId, catalogTrackName)
3264
3286
  const evictedBefore = locator.heldEvicted
3265
3287
  const held = locator.hold(ssId, source)
3288
+ if (held && source.source === 'multicast') this.#noteMulticastSource(locator, ssId, packetId, catalogTrackName)
3266
3289
  const evicted = locator.heldEvicted - evictedBefore
3267
3290
  if (evicted > 0) {
3268
3291
  const before = this.#isoHeldEvictedCount
@@ -4062,7 +4085,22 @@ export class FECManager {
4062
4085
  this.#isoSourceOversizeCount++
4063
4086
  return null
4064
4087
  }
4065
- 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 {
4066
4104
  const blockKey = this.#blockKey(sbn, packetId, catalogTrackName)
4067
4105
  const syncBlockOwner = this.#syncDecoderBlockOwners.get(blockKey)
4068
4106
 
@@ -4107,6 +4145,97 @@ export class FECManager {
4107
4145
  return recovered
4108
4146
  }
4109
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
+ const config = this.#trackConfigs.get(packetId)
4189
+ const wasShort = config !== undefined &&
4190
+ this.#lateSourceWaitMs(key, sbn, packetId, catalogTrackName, config) !== undefined
4191
+ rebuilt.add(located.esi)
4192
+ const recovered = this.#feedDecoderSource(packetId, ssId, symbolData, catalogTrackName, sbn)
4193
+ if (recovered === null && !this.#completeWhenSourcesPresent(key, sbn, located.block.length, packetId, catalogTrackName)) {
4194
+ // The late-source cut assumed only a late source could complete this
4195
+ // block; with this source its group can, so it gets a full multicast
4196
+ // wait again, counted from now.
4197
+ // ponytail: from now, not from its sources, so it can close up to one
4198
+ // multicast wait after its uncut deadline; at most once per block, since
4199
+ // a rebuilt source only adds to what the block holds and the cut cannot
4200
+ // apply to it again. Keep the uncut deadline and re-arm to it if a lab
4201
+ // run shows that wait.
4202
+ if (wasShort && this.#activeBlocks.has(key) &&
4203
+ this.#lateSourceWaitMs(key, sbn, packetId, catalogTrackName, config!) === undefined) {
4204
+ this.#armBlockTimeout(sbn, packetId, catalogTrackName, this.#multicastWaitMs(config!))
4205
+ }
4206
+ }
4207
+ return { taken: true, recovered }
4208
+ }
4209
+
4210
+ /**
4211
+ * A block that holds every one of its `length` sources, received or
4212
+ * rebuilt by another instance, with at least one rebuilt, is recovered:
4213
+ * every packet of it was delivered, by its own arrival or by the instance
4214
+ * that rebuilt it, so nothing is routed. Returns whether it completed it.
4215
+ */
4216
+ #completeWhenSourcesPresent(
4217
+ key: string,
4218
+ sbn: number,
4219
+ length: number,
4220
+ packetId: number,
4221
+ catalogTrackName: string | undefined,
4222
+ ): boolean {
4223
+ const rebuilt = this.#blockRebuiltEsis.get(key)
4224
+ if (rebuilt === undefined || this.#completedBlocks.has(key)) return false
4225
+ let present = rebuilt.size
4226
+ for (const esi of this.#blockSourceEsis.get(key) ?? []) if (!rebuilt.has(esi)) present++
4227
+ if (present < length) return false
4228
+ this.#traceLifecycle('recovered', sbn, packetId, catalogTrackName)
4229
+ this.countRecovery(packetId, sbn, catalogTrackName)
4230
+ this.cancelBlockTimeout(sbn, packetId, catalogTrackName)
4231
+ if (catalogTrackName === undefined) {
4232
+ this.onBlockComplete?.(sbn, packetId)
4233
+ } else {
4234
+ this.onBlockComplete?.(sbn, packetId, catalogTrackName)
4235
+ }
4236
+ return true
4237
+ }
4238
+
4110
4239
  /**
4111
4240
  * fec-01 4.5: a reed-solomon repair packet whose SSB_length + RSB_length
4112
4241
  * exceeds 255, or whose RS_ID is not below RSB_length, is discarded with the
@@ -4364,9 +4493,100 @@ export class FECManager {
4364
4493
  this.#completeCleanBlock(blockKey, sbn, packetId, catalogTrackName)
4365
4494
  return null
4366
4495
  }
4496
+ // Its K' is known now: a block this repair shows short of it waits less.
4497
+ if (recovered === null) this.#shortenUnrecoverableWait(blockKey, sbn, packetId, catalogTrackName)
4367
4498
  return recovered
4368
4499
  }
4369
4500
 
4501
+ /**
4502
+ * A first-arrival multicast source, held or placed. A datagram flow keeps
4503
+ * its sources in send order, so one that moves the stream's newest source
4504
+ * position past a block leaves the sources that block still misses
4505
+ * reordered behind it at best: it shortens the wait of every block it
4506
+ * passed that only a late source can complete. MoQ is left out: its streams
4507
+ * can overtake one another, so a MoQ source shows nothing about the sources
4508
+ * sent before it.
4509
+ */
4510
+ #noteMulticastSource(
4511
+ locator: IsoBlockLocator<IsoHeldSource>,
4512
+ ssId: number,
4513
+ packetId: number,
4514
+ catalogTrackName: string | undefined,
4515
+ ): void {
4516
+ const previous = locator.newestSource
4517
+ if (!locator.noteSource(ssId) || previous === undefined) return
4518
+ // The blocks whose last SS_ID lies in [previous, ssId): this source
4519
+ // passed them and the previous newest one had not.
4520
+ for (const block of locator.blocksBetween((previous - 1) >>> 0, ssId)) {
4521
+ if (block.span || !serial32Precedes(serial32Add(block.start, block.length - 1), ssId)) continue
4522
+ this.#shortenUnrecoverableWait(
4523
+ this.#blockKey(block.start, packetId, catalogTrackName),
4524
+ block.start,
4525
+ packetId,
4526
+ catalogTrackName,
4527
+ )
4528
+ }
4529
+ }
4530
+
4531
+ /**
4532
+ * The wait of a block that only a late source can complete (BLO-52085): a
4533
+ * repair stated it, its received sources plus every symbol its repair group
4534
+ * carries (RSB_length) are short of K' (the repair scheduler's
4535
+ * group-cannot-complete), and a later source of the stream has arrived, so
4536
+ * a source it misses is lost or reordered behind that one. The span, which
4537
+ * covers repair symbols paced after its sources, buys it nothing: it waits
4538
+ * the late-symbol allowance alone, the multicast wait less the span (the
4539
+ * catalog tolerance or the measured delivery gap). Undefined for any other
4540
+ * block.
4541
+ */
4542
+ #lateSourceWaitMs(
4543
+ key: string,
4544
+ sbn: number,
4545
+ packetId: number,
4546
+ catalogTrackName: string | undefined,
4547
+ config: ResolvedFECTrackConfig,
4548
+ ): number | undefined {
4549
+ const authoredLength = this.#blockAuthoredLengths.get(key)
4550
+ const rsbLength = this.#blockRsbLengths.get(key)
4551
+ if (authoredLength === undefined || rsbLength === undefined) return undefined
4552
+ // A source another instance rebuilt (feedRebuiltSourceSymbol) is one the
4553
+ // decoder holds: the group need only cover what neither delivered.
4554
+ let present = this.#blockSourceCounts.get(key) ?? 0
4555
+ const received = this.#blockSourceEsis.get(key)
4556
+ for (const esi of this.#blockRebuiltEsis.get(key) ?? []) if (!received?.has(esi)) present++
4557
+ if (present + rsbLength >= authoredLength) return undefined
4558
+ const newestSource = this.#isoLocators.get(this.#streamKey(packetId, catalogTrackName))?.newestSource
4559
+ if (newestSource === undefined || !serial32Precedes(serial32Add(sbn, authoredLength - 1), newestSource)) {
4560
+ return undefined
4561
+ }
4562
+ return this.#multicastWaitMs(config) - config.interleaveDepthMs
4563
+ }
4564
+
4565
+ /**
4566
+ * Bring an open block's wait in to its late-source wait, when it has one.
4567
+ * Never later than it is armed: only a source can complete such a block,
4568
+ * so a repair of its group (paced across the span) must not keep it open.
4569
+ */
4570
+ #shortenUnrecoverableWait(
4571
+ key: string,
4572
+ sbn: number,
4573
+ packetId: number,
4574
+ catalogTrackName: string | undefined,
4575
+ ): void {
4576
+ if (!this.#activeBlocks.has(key)) return
4577
+ const config = this.#trackConfigs.get(packetId)
4578
+ if (config === undefined) return
4579
+ const waitMs = this.#lateSourceWaitMs(key, sbn, packetId, catalogTrackName, config)
4580
+ if (waitMs === undefined) return
4581
+ const armedAtMs = this.#blockWaitDeadlinesMs.get(key)
4582
+ this.#armBlockTimeout(
4583
+ sbn,
4584
+ packetId,
4585
+ catalogTrackName,
4586
+ armedAtMs === undefined ? waitMs : Math.min(waitMs, armedAtMs - this.#receiptNow()),
4587
+ )
4588
+ }
4589
+
4370
4590
  /**
4371
4591
  * Record a block's authored SSB_length from a repair symbol. Returns false,
4372
4592
  * leaving the block unchanged, when the symbol contradicts the block: a
@@ -4582,6 +4802,20 @@ export class FECManager {
4582
4802
  this.#completeCleanBlock(key, sbn, packetId, catalogTrackName)
4583
4803
  return
4584
4804
  }
4805
+ // A late arrival can be the last source a block lacked besides those
4806
+ // another instance rebuilt (feedRebuiltSourceSymbol).
4807
+ if (
4808
+ this.#blockRebuiltEsis.has(key) &&
4809
+ this.#completeWhenSourcesPresent(
4810
+ key,
4811
+ sbn,
4812
+ this.#isoLocatorFor(packetId, catalogTrackName).blockAt(sbn)?.length ?? authoredLength,
4813
+ packetId,
4814
+ catalogTrackName,
4815
+ )
4816
+ ) {
4817
+ return
4818
+ }
4585
4819
 
4586
4820
  this.noteBlockActivity(sbn, packetId, catalogTrackName, true)
4587
4821
 
@@ -4601,6 +4835,7 @@ export class FECManager {
4601
4835
  this.cancelBlockTimeout(sbn, packetId, catalogTrackName)
4602
4836
  this.#blockSourceCounts.delete(key)
4603
4837
  this.#blockSourceEsis.delete(key)
4838
+ this.#blockRebuiltEsis.delete(key)
4604
4839
  this.#blockAuthoredLengths.delete(key)
4605
4840
  this.#blockRepairRsIds.delete(key)
4606
4841
  this.#blockRsbLengths.delete(key)
@@ -4714,7 +4949,18 @@ export class FECManager {
4714
4949
  )
4715
4950
  }
4716
4951
 
4717
- this.#armBlockTimeout(sbn, packetId, catalogTrackName, timeoutMs)
4952
+ // A block only a late source can complete waits the late-symbol
4953
+ // allowance from its own sources' activity; nothing else moves it later.
4954
+ if (!sourceProgress && this.#lateSourceWaitMs(key, sbn, packetId, catalogTrackName, trackConfig) !== undefined) {
4955
+ this.#shortenUnrecoverableWait(key, sbn, packetId, catalogTrackName)
4956
+ return
4957
+ }
4958
+ this.#armBlockTimeout(
4959
+ sbn,
4960
+ packetId,
4961
+ catalogTrackName,
4962
+ this.#lateSourceWaitMs(key, sbn, packetId, catalogTrackName, trackConfig) ?? timeoutMs,
4963
+ )
4718
4964
  }
4719
4965
 
4720
4966
  #failBlock(
@@ -4732,6 +4978,7 @@ export class FECManager {
4732
4978
  }
4733
4979
  this.#blockSourceCounts.delete(key)
4734
4980
  this.#blockSourceEsis.delete(key)
4981
+ this.#blockRebuiltEsis.delete(key)
4735
4982
  this.#blockAuthoredLengths.delete(key)
4736
4983
  this.#blockRepairRsIds.delete(key)
4737
4984
  this.#blockRsbLengths.delete(key)
@@ -4771,6 +5018,7 @@ export class FECManager {
4771
5018
  this.#retireBlockClass(key, block.sbn, block.packetId, block.catalogTrackName)
4772
5019
  this.#blockSourceCounts.delete(key)
4773
5020
  this.#blockSourceEsis.delete(key)
5021
+ this.#blockRebuiltEsis.delete(key)
4774
5022
  this.#blockAuthoredLengths.delete(key)
4775
5023
  this.#blockRepairRsIds.delete(key)
4776
5024
  this.#blockRsbLengths.delete(key)
@@ -4879,6 +5127,7 @@ export class FECManager {
4879
5127
  this.#blockKeysBySbn.clear()
4880
5128
  this.#blockSourceCounts.clear()
4881
5129
  this.#blockSourceEsis.clear()
5130
+ this.#blockRebuiltEsis.clear()
4882
5131
  this.#blockAuthoredLengths.clear()
4883
5132
  this.#blockRepairRsIds.clear()
4884
5133
  this.#blockRsbLengths.clear()
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,
@@ -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
  }