@blockcast/mmt-container 2.1.0 → 2.2.0-main.050b52fb9b00

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 (105) hide show
  1. package/README.md +29 -17
  2. package/dist/browser-runtime.js +1949 -374
  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 +70 -12
  18. package/dist/fec-manager.d.ts.map +1 -1
  19. package/dist/fec-manager.js +403 -58
  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 +6 -5
  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 +48 -13
  31. package/dist/iso-block-locator.d.ts.map +1 -1
  32. package/dist/iso-block-locator.js +97 -25
  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 +79 -10
  42. package/dist/mmtp-container-parser.d.ts.map +1 -1
  43. package/dist/mmtp-container-parser.js +219 -45
  44. package/dist/mmtp-container-parser.js.map +2 -2
  45. package/dist/mmtp-container-parser.no-modules.js +342 -63
  46. package/dist/mmtp-container-parser.no-modules.js.map +3 -3
  47. package/dist/mmtp-decode-pipeline.js +218 -45
  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 +43 -9
  54. package/dist/mmtp-router.d.ts.map +1 -1
  55. package/dist/mmtp-router.js +2006 -95
  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 +28 -5
  60. package/dist/multicast-manager.d.ts.map +1 -1
  61. package/dist/multicast-manager.js +16116 -15005
  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 +336 -59
  70. package/dist/no-modules.js.map +3 -3
  71. package/dist/repair-fetch.d.ts +36 -8
  72. package/dist/repair-fetch.d.ts.map +1 -1
  73. package/dist/repair-fetch.js +285 -26
  74. package/dist/repair-fetch.js.map +3 -3
  75. package/dist/repair-layer-subscriptions.d.ts +82 -0
  76. package/dist/repair-layer-subscriptions.d.ts.map +1 -0
  77. package/dist/repair-layer-subscriptions.js +192 -0
  78. package/dist/repair-layer-subscriptions.js.map +7 -0
  79. package/dist/signaling.js.map +2 -2
  80. package/dist/timestamp-codec.js +12 -19
  81. package/dist/timestamp-codec.js.map +2 -2
  82. package/dist/track-session.d.ts +1 -1
  83. package/dist/track-session.d.ts.map +1 -1
  84. package/dist/track-session.js +1 -0
  85. package/dist/track-session.js.map +2 -2
  86. package/dist/wasm-mfu-reassembler.js.map +1 -1
  87. package/dist/wasm-mmtp-container-parser.js +218 -45
  88. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  89. package/package.json +8 -5
  90. package/schemas/catalog.schema.json +4 -0
  91. package/src/catalog-fec-params.ts +36 -1
  92. package/src/cmcd-producer.ts +194 -179
  93. package/src/fec-manager.ts +433 -32
  94. package/src/fec-worker-setup.ts +2 -2
  95. package/src/index.ts +20 -2
  96. package/src/iso-block-locator.ts +133 -31
  97. package/src/iso-bmff-timing.ts +40 -1
  98. package/src/mmtp-container-parser.ts +306 -29
  99. package/src/mmtp-fec-session.ts +115 -15
  100. package/src/mmtp-router.ts +629 -54
  101. package/src/multicast-manager.ts +70 -11
  102. package/src/multicast-transport.ts +44 -20
  103. package/src/repair-fetch.ts +363 -49
  104. package/src/repair-layer-subscriptions.ts +261 -0
  105. package/src/track-session.ts +4 -1
@@ -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
@@ -260,6 +260,8 @@ export {
260
260
  type FecStats,
261
261
  type MulticastSymbolDelivery,
262
262
  type UndelimitedIsoLoss,
263
+ type ExpiredIsoHole,
264
+ type IsoSpanFailure,
263
265
  type FecBlockResolution,
264
266
  type RepairVia,
265
267
  type FecRepairLayer,
@@ -299,6 +301,7 @@ export {
299
301
  type MmtpFeedOutcomeKey,
300
302
  type TrackInfo,
301
303
  type MmtpReorderStats,
304
+ type MmtpRecoveredHeadTimingStats,
302
305
  type MmtpReorderFrameDiagnostics,
303
306
  type MmtpReorderStreamDiagnostics,
304
307
  type FrameMeta as ParserFrameMeta,
@@ -526,10 +529,13 @@ export {
526
529
  type FecStarvationDetectorConfig,
527
530
  } from './fec-starvation-detector.js'
528
531
 
529
- // CMCD Producer (x-bc-* vendor key telemetry, BLO-1044)
532
+ // CMCD v2 Event-mode producer (CTA-5004-B, com.blockcast-* custom keys)
530
533
  export {
531
534
  CmcdProducer,
532
- type CmcdParams,
535
+ encodeCmcdRecord,
536
+ type CmcdRecord,
537
+ type CmcdToken,
538
+ type CmcdValue,
533
539
  type CmcdProducerOptions,
534
540
  type SnapshotData,
535
541
  type TransportSnapshot,
@@ -546,6 +552,7 @@ export {
546
552
  type TrackFecParams,
547
553
  type TrackFecUnavailable,
548
554
  catalogTrackFecUnavailable,
555
+ catalogTrackDeliveryMode,
549
556
  catalogGroupDurationMs,
550
557
  catalogKeyframeCadenceMs,
551
558
  catalogFecMediaUnitsPerSecond,
@@ -602,11 +609,22 @@ export {
602
609
  type MmtpFecSessionConfig,
603
610
  type MmtpFecSessionDecoder,
604
611
  type MmtpFecSessionDecoderConstructor,
612
+ type MmtpFecSessionMessageConfig,
613
+ type MmtpFecSessionMessageUpdate,
605
614
  type MmtpFecSessionMixedPathStats,
606
615
  type MmtpFecSessionPath,
607
616
  type MmtpFecSessionPathCounts,
608
617
  type MmtpFecSessionStats,
609
618
  } from "./mmtp-fec-session.js";
619
+ // MoQ repair-layer subscriptions by measured loss (BLO-35456)
620
+ export {
621
+ RepairLayerSubscriptions,
622
+ type MoqRepairDelivery,
623
+ type RepairLayerSource,
624
+ type RepairLayerSubscriptionsOptions,
625
+ type RepairLayerSubscriptionsSnapshot,
626
+ type RepairTrackSubscription,
627
+ } from "./repair-layer-subscriptions.js";
610
628
 
611
629
  export {
612
630
  catalogAmtNetworkSource,
@@ -14,9 +14,10 @@
14
14
  * between are one or more blocks whose every repair symbol was lost: they are
15
15
  * kept as one repair-less span, which completes clean once all of its SS_IDs
16
16
  * arrived (C.5.2 makes a gap in consecutive SS_IDs directly visible) and
17
- * otherwise resolves through its catalog-derived deadline. That is the only
18
- * way a block without repair completes: on the next block's SS_Start, never on
19
- * a count of K source symbols.
17
+ * otherwise resolves through its catalog-derived deadline. A block without
18
+ * repair completes that way, on the next block's SS_Start, never on a count of
19
+ * K source symbols, unless a repair that arrives later (a FETCH of the span's
20
+ * first group) states exactly the span's range: the span was that one block.
20
21
  */
21
22
  import {
22
23
  isoBlockContains,
@@ -58,14 +59,29 @@ export type IsoRepairPlacement<T> =
58
59
  */
59
60
  readonly hole?: { readonly block: IsoBlockRange; readonly lostSsId: number }
60
61
  }
61
- /** Not a block this stream can use; the repair symbol is dropped. */
62
- | { readonly status: 'conflict' | 'overlap' | 'stale' | 'ahead'; readonly reason: string }
62
+ /**
63
+ * Not a block this stream can use; the repair symbol is dropped. `ofSpan` is
64
+ * set when the repair stated only the first block of that repair-less span:
65
+ * the span holds more than one block, so no single repair group completes it.
66
+ */
67
+ | {
68
+ readonly status: 'conflict' | 'overlap' | 'stale' | 'ahead'
69
+ readonly reason: string
70
+ readonly ofSpan?: IsoBlockRange
71
+ }
63
72
 
64
73
  /**
65
74
  * Retention: the locator is never without one. Every call that can grow it
66
75
  * (`hold`, `placeRepair`) retires, behind the position it just saw, every
67
76
  * block, span and held source more than `retentionBlocks` blocks back, by the
68
- * one ISO horizon metric (`isoBlocksBehindLowerBound`). A block is measured
77
+ * one ISO horizon metric (`isoBlocksBehindLowerBound`). A held source stays
78
+ * while a pending hole holding it does: the hole's repair FETCH may still
79
+ * state its block, and closing the hole places it, so the span it leaves
80
+ * counts every source it received (BLO-52085). A pending hole the horizon
81
+ * passes stops being pending (its blocks can no longer be stated, and the
82
+ * release gate no longer waits for it), but keeps its sources until its
83
+ * owner closes it (`takeExpiredHoles`), so it still fails as the span it
84
+ * is. A block is measured
69
85
  * from its last SS_ID, so it stays while any SS_ID of it is inside the
70
86
  * horizon: a repair-less span has no length bound, and one measured from its
71
87
  * start could be retired the moment it is created, before the sources it
@@ -85,8 +101,18 @@ export class IsoBlockLocator<T> {
85
101
  #retentionBlocks: number
86
102
  #heldEvicted = 0
87
103
  #newest: number | undefined
104
+ #newestSource: number | undefined
88
105
  /** Pending holes (see `deferIncompleteGaps`): unstated, sources still held. */
89
106
  readonly #holes: IsoBlockRange[] = []
107
+ /** Holes the horizon passed while pending: sources held until closed. */
108
+ readonly #expired: IsoBlockRange[] = []
109
+ /** Of `#expired`, those `takeExpiredHoles` has not handed out yet. */
110
+ readonly #newlyExpired: IsoBlockRange[] = []
111
+ /**
112
+ * Spans closed from expired holes: behind the horizon, so kept only for
113
+ * `locate` while their sources are fed, until the next retirement.
114
+ */
115
+ readonly #closedBehind: IsoBlockRange[] = []
90
116
  /**
91
117
  * Keep a gap that is missing a source as a pending hole rather than a span,
92
118
  * so a repair FETCHed for it (draft-ramadan-moq-fec §7.5) can still state
@@ -152,6 +178,23 @@ export class IsoBlockLocator<T> {
152
178
  if (this.#newest === undefined || serial32Precedes(this.#newest, position)) this.#newest = position
153
179
  }
154
180
 
181
+ /**
182
+ * The newest source SS_ID noted with `noteSource` (RFC 1982 order). Unlike
183
+ * `newest`, a repair never moves it: a repair can run ahead of its sources,
184
+ * a source cannot run ahead of the sources sent before it except by
185
+ * reordering.
186
+ */
187
+ get newestSource(): number | undefined {
188
+ return this.#newestSource
189
+ }
190
+
191
+ /** Note a received source's SS_ID; true when it is the newest one yet. */
192
+ noteSource(ssId: number): boolean {
193
+ if (this.#newestSource !== undefined && !serial32Precedes(this.#newestSource, ssId)) return false
194
+ this.#newestSource = ssId
195
+ return true
196
+ }
197
+
155
198
  /** Held sources evicted by `heldCapacity` (never by the horizon itself). */
156
199
  get heldEvicted(): number {
157
200
  return this.#heldEvicted
@@ -174,18 +217,31 @@ export class IsoBlockLocator<T> {
174
217
 
175
218
  /** The known block or span containing `ssId`, with its ESI. */
176
219
  locate(ssId: number): { block: IsoBlockRange; esi: number } | null {
177
- for (let i = this.#blocks.length - 1; i >= 0; i--) {
178
- const block = this.#blocks[i]!
179
- if (isoBlockContains(block.start, block.length, ssId)) {
180
- return { block, esi: serial32Distance(block.start, ssId) }
220
+ for (const blocks of [this.#blocks, this.#closedBehind]) {
221
+ for (let i = blocks.length - 1; i >= 0; i--) {
222
+ const block = blocks[i]!
223
+ if (isoBlockContains(block.start, block.length, ssId)) {
224
+ return { block, esi: serial32Distance(block.start, ssId) }
225
+ }
181
226
  }
182
227
  }
183
228
  return null
184
229
  }
185
230
 
186
- /** Number of pending holes. */
231
+ /** Number of holes not closed yet, pending or past the horizon. */
187
232
  get holeCount(): number {
188
- return this.#holes.length
233
+ return this.#holes.length + this.#expired.length
234
+ }
235
+
236
+ /**
237
+ * The pending holes the horizon has passed since the last call, in the
238
+ * order it passed them. Each is no longer pending: no repair states a block
239
+ * in it (that repair is `stale`, as for a span the horizon retired) and the
240
+ * release gate does not wait for it. Its owner must close it
241
+ * (`closeHolesWithin`), which turns it into a span that is not kept here.
242
+ */
243
+ takeExpiredHoles(): IsoBlockRange[] {
244
+ return this.#newlyExpired.splice(0)
189
245
  }
190
246
 
191
247
  /** The stated block (never a span) ending exactly at `end`. */
@@ -250,9 +306,11 @@ export class IsoBlockLocator<T> {
250
306
  /**
251
307
  * Apply one repair symbol's `(SS_Start, SSB_length)`. The first repair of a
252
308
  * block states its range and places every held source in it; later repair
253
- * symbols of the same block change nothing. A range that contradicts a
254
- * known block (a different length, or an overlap with another block or a
255
- * span) fails closed: the repair symbol is dropped and no block changes.
309
+ * symbols of the same block change nothing. A repair whose range is exactly
310
+ * a repair-less span states that span's one block. A range that contradicts
311
+ * a known block (a different length, or an overlap with another block or
312
+ * with part of a span) fails closed: the repair symbol is dropped and no
313
+ * block changes.
256
314
  * A newly stated block retires behind its SS_Start (see the class note).
257
315
  * A block so far past `newest` that stating it would retire the position
258
316
  * `newest` is refused as `ahead`. When `newest` is a placed block's SS_Start
@@ -271,7 +329,25 @@ export class IsoBlockLocator<T> {
271
329
  const known = this.blockAt(start)
272
330
  if (known !== undefined) {
273
331
  if (known.span) {
274
- return { status: 'overlap', reason: `repair block ${start}+${length} falls inside a repair-less span` }
332
+ // A span is no accepted block: it is the whole blocks between two
333
+ // stated ones whose repair never came (blocks are consecutive), so a
334
+ // repair whose range is exactly the span states its one block and
335
+ // contradicts nothing (draft-ramadan-moq-fec-core-00 5.4). Its sources
336
+ // were placed at these same ESIs when the span closed. A FETCH of the
337
+ // span's Group ext(SS_Start) returns just this when one block lost
338
+ // every multicast repair (a join).
339
+ if (known.length === length) {
340
+ const block: IsoBlockRange = { start, length, span: false }
341
+ this.#blocks[this.#blocks.indexOf(known)] = block
342
+ return { status: 'placed', block, placed: [] }
343
+ }
344
+ return length < known.length
345
+ ? {
346
+ status: 'overlap',
347
+ reason: `repair block ${start}+${length} is only the first block of repair-less span ${known.start}+${known.length}`,
348
+ ofSpan: known,
349
+ }
350
+ : { status: 'overlap', reason: `repair block ${start}+${length} runs past repair-less span ${known.start}+${known.length}` }
275
351
  }
276
352
  if (known.length !== length) {
277
353
  return {
@@ -349,18 +425,28 @@ export class IsoBlockLocator<T> {
349
425
  }
350
426
 
351
427
  /**
352
- * Turn every pending hole inside `[start, start + length)` into a
353
- * repair-less span, claiming its held sources: its range FETCH ended, so
354
- * nothing more will state its blocks. Returns the spans, in SS_ID order.
428
+ * Turn every hole inside `[start, start + length)` into a repair-less span,
429
+ * claiming its held sources: its range FETCH ended, so nothing more will
430
+ * state its blocks. A hole the horizon passed (`takeExpiredHoles`) leaves a
431
+ * span that is not kept among the blocks, as the horizon would retire it at
432
+ * once; only `locate` finds it, until the next retirement, so its sources
433
+ * can be fed. Returns the spans, in SS_ID order.
355
434
  */
356
435
  closeHolesWithin(start: number, length: number): { block: IsoBlockRange; placed: IsoPlacedSource<T>[] }[] {
357
436
  const closed: { block: IsoBlockRange; placed: IsoPlacedSource<T>[] }[] = []
358
- for (const hole of [...this.#holes]) {
359
- if (!isoBlockContains(start, length, hole.start)) continue
360
- this.#holes.splice(this.#holes.indexOf(hole), 1)
361
- const spanBlock: IsoBlockRange = { start: hole.start, length: hole.length, span: true }
362
- closed.push({ block: spanBlock, placed: this.#claim(spanBlock) })
363
- this.#blocks.push(spanBlock)
437
+ for (const holes of [this.#holes, this.#expired]) {
438
+ for (const hole of [...holes]) {
439
+ if (!isoBlockContains(start, length, hole.start)) continue
440
+ holes.splice(holes.indexOf(hole), 1)
441
+ const spanBlock: IsoBlockRange = { start: hole.start, length: hole.length, span: true }
442
+ closed.push({ block: spanBlock, placed: this.#claim(spanBlock) })
443
+ if (holes === this.#holes) {
444
+ this.#blocks.push(spanBlock)
445
+ } else {
446
+ this.#closedBehind.push(spanBlock)
447
+ if (this.#newlyExpired.includes(hole)) this.#newlyExpired.splice(this.#newlyExpired.indexOf(hole), 1)
448
+ }
449
+ }
364
450
  }
365
451
  return closed.sort((x, y) => serial32Distance(start, x.block.start) - serial32Distance(start, y.block.start))
366
452
  }
@@ -377,15 +463,17 @@ export class IsoBlockLocator<T> {
377
463
  }
378
464
 
379
465
  /**
380
- * Retire every block and span whose last SS_ID, and every held source,
381
- * lies more than `retentionBlocks` blocks behind `position`, with the shared
382
- * lower-bound metric (`isoBlocksBehindLowerBound`) every ISO interleave
383
- * horizon uses. Returns the retired block starts.
466
+ * Retire every block and span whose last SS_ID, and every held source
467
+ * outside a hole, lies more than `retentionBlocks` blocks behind
468
+ * `position`, with the shared lower-bound metric (`isoBlocksBehindLowerBound`)
469
+ * every ISO interleave horizon uses; a pending hole so far behind expires
470
+ * (`takeExpiredHoles`). Returns the retired block starts.
384
471
  */
385
472
  retireBehind(position: number): number[] {
386
473
  this.observe(position)
387
474
  const horizon = this.#retentionBlocks
388
475
  const retired: number[] = []
476
+ this.#closedBehind.length = 0
389
477
  for (let i = this.#blocks.length - 1; i >= 0; i--) {
390
478
  const block = this.#blocks[i]!
391
479
  const last = serial32Add(block.start, block.length - 1)
@@ -397,10 +485,20 @@ export class IsoBlockLocator<T> {
397
485
  for (let i = this.#holes.length - 1; i >= 0; i--) {
398
486
  const hole = this.#holes[i]!
399
487
  const last = serial32Add(hole.start, hole.length - 1)
400
- if (isoBlocksBehindLowerBound(last, position, this.#kMax) > horizon) this.#holes.splice(i, 1)
488
+ if (isoBlocksBehindLowerBound(last, position, this.#kMax) <= horizon) continue
489
+ this.#holes.splice(i, 1)
490
+ this.#expired.push(hole)
491
+ this.#newlyExpired.push(hole)
401
492
  }
493
+ // A held source stays while a hole holding it does (class note).
402
494
  for (const ssId of [...this.#held.keys()]) {
403
- if (isoBlocksBehindLowerBound(ssId, position, this.#kMax) > horizon) this.#held.delete(ssId)
495
+ if (
496
+ isoBlocksBehindLowerBound(ssId, position, this.#kMax) > horizon &&
497
+ !this.#holes.some((hole) => isoBlockContains(hole.start, hole.length, ssId)) &&
498
+ !this.#expired.some((hole) => isoBlockContains(hole.start, hole.length, ssId))
499
+ ) {
500
+ this.#held.delete(ssId)
501
+ }
404
502
  }
405
503
  return retired
406
504
  }
@@ -409,9 +507,13 @@ export class IsoBlockLocator<T> {
409
507
  reset(): void {
410
508
  this.#blocks.length = 0
411
509
  this.#holes.length = 0
510
+ this.#expired.length = 0
511
+ this.#newlyExpired.length = 0
512
+ this.#closedBehind.length = 0
412
513
  this.#held.clear()
413
514
  this.#coverageEnd = undefined
414
515
  this.#newest = undefined
516
+ this.#newestSource = undefined
415
517
  }
416
518
 
417
519
  #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
  }