@blockcast/mmt-container 2.2.0-main.f480a7ede8b1 → 2.2.0-main.fb5c7dfbe2de

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 (54) hide show
  1. package/dist/browser-runtime.js +891 -141
  2. package/dist/browser-runtime.js.map +3 -3
  3. package/dist/delta-fallback-config.js.map +1 -1
  4. package/dist/fec-manager.d.ts +67 -11
  5. package/dist/fec-manager.d.ts.map +1 -1
  6. package/dist/fec-manager.js +283 -54
  7. package/dist/fec-manager.js.map +2 -2
  8. package/dist/fec-worker-setup.js.map +1 -1
  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 +39 -13
  13. package/dist/iso-block-locator.d.ts.map +1 -1
  14. package/dist/iso-block-locator.js +80 -25
  15. package/dist/iso-block-locator.js.map +2 -2
  16. package/dist/media-router.js.map +1 -1
  17. package/dist/mmtp-container-parser.d.ts +42 -8
  18. package/dist/mmtp-container-parser.d.ts.map +1 -1
  19. package/dist/mmtp-container-parser.js +121 -17
  20. package/dist/mmtp-container-parser.js.map +2 -2
  21. package/dist/mmtp-container-parser.no-modules.js +120 -18
  22. package/dist/mmtp-container-parser.no-modules.js.map +2 -2
  23. package/dist/mmtp-decode-pipeline.js +121 -17
  24. package/dist/mmtp-decode-pipeline.js.map +2 -2
  25. package/dist/mmtp-router.d.ts +28 -8
  26. package/dist/mmtp-router.d.ts.map +1 -1
  27. package/dist/mmtp-router.js +407 -52
  28. package/dist/mmtp-router.js.map +2 -2
  29. package/dist/multicast-manager.d.ts +13 -3
  30. package/dist/multicast-manager.d.ts.map +1 -1
  31. package/dist/multicast-manager.js +712 -115
  32. package/dist/multicast-manager.js.map +2 -2
  33. package/dist/multicast-transport.js +2 -2
  34. package/dist/multicast-transport.js.map +2 -2
  35. package/dist/no-modules.js +121 -17
  36. package/dist/no-modules.js.map +2 -2
  37. package/dist/repair-fetch.d.ts +36 -8
  38. package/dist/repair-fetch.d.ts.map +1 -1
  39. package/dist/repair-fetch.js +285 -26
  40. package/dist/repair-fetch.js.map +3 -3
  41. package/dist/track-session.d.ts.map +1 -1
  42. package/dist/track-session.js +1 -0
  43. package/dist/track-session.js.map +2 -2
  44. package/dist/wasm-mmtp-container-parser.js +121 -17
  45. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  46. package/package.json +5 -5
  47. package/src/fec-manager.ts +304 -28
  48. package/src/index.ts +2 -0
  49. package/src/iso-block-locator.ts +114 -31
  50. package/src/mmtp-container-parser.ts +210 -21
  51. package/src/mmtp-router.ts +488 -45
  52. package/src/multicast-manager.ts +44 -6
  53. package/src/repair-fetch.ts +363 -49
  54. package/src/track-session.ts +3 -0
@@ -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
@@ -88,6 +104,15 @@ export class IsoBlockLocator<T> {
88
104
  #newestSource: number | undefined
89
105
  /** Pending holes (see `deferIncompleteGaps`): unstated, sources still held. */
90
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[] = []
91
116
  /**
92
117
  * Keep a gap that is missing a source as a pending hole rather than a span,
93
118
  * so a repair FETCHed for it (draft-ramadan-moq-fec §7.5) can still state
@@ -192,18 +217,31 @@ export class IsoBlockLocator<T> {
192
217
 
193
218
  /** The known block or span containing `ssId`, with its ESI. */
194
219
  locate(ssId: number): { block: IsoBlockRange; esi: number } | null {
195
- for (let i = this.#blocks.length - 1; i >= 0; i--) {
196
- const block = this.#blocks[i]!
197
- if (isoBlockContains(block.start, block.length, ssId)) {
198
- 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
+ }
199
226
  }
200
227
  }
201
228
  return null
202
229
  }
203
230
 
204
- /** Number of pending holes. */
231
+ /** Number of holes not closed yet, pending or past the horizon. */
205
232
  get holeCount(): number {
206
- 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)
207
245
  }
208
246
 
209
247
  /** The stated block (never a span) ending exactly at `end`. */
@@ -268,9 +306,11 @@ export class IsoBlockLocator<T> {
268
306
  /**
269
307
  * Apply one repair symbol's `(SS_Start, SSB_length)`. The first repair of a
270
308
  * block states its range and places every held source in it; later repair
271
- * symbols of the same block change nothing. A range that contradicts a
272
- * known block (a different length, or an overlap with another block or a
273
- * 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.
274
314
  * A newly stated block retires behind its SS_Start (see the class note).
275
315
  * A block so far past `newest` that stating it would retire the position
276
316
  * `newest` is refused as `ahead`. When `newest` is a placed block's SS_Start
@@ -289,7 +329,25 @@ export class IsoBlockLocator<T> {
289
329
  const known = this.blockAt(start)
290
330
  if (known !== undefined) {
291
331
  if (known.span) {
292
- 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}` }
293
351
  }
294
352
  if (known.length !== length) {
295
353
  return {
@@ -367,18 +425,28 @@ export class IsoBlockLocator<T> {
367
425
  }
368
426
 
369
427
  /**
370
- * Turn every pending hole inside `[start, start + length)` into a
371
- * repair-less span, claiming its held sources: its range FETCH ended, so
372
- * 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.
373
434
  */
374
435
  closeHolesWithin(start: number, length: number): { block: IsoBlockRange; placed: IsoPlacedSource<T>[] }[] {
375
436
  const closed: { block: IsoBlockRange; placed: IsoPlacedSource<T>[] }[] = []
376
- for (const hole of [...this.#holes]) {
377
- if (!isoBlockContains(start, length, hole.start)) continue
378
- this.#holes.splice(this.#holes.indexOf(hole), 1)
379
- const spanBlock: IsoBlockRange = { start: hole.start, length: hole.length, span: true }
380
- closed.push({ block: spanBlock, placed: this.#claim(spanBlock) })
381
- 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
+ }
382
450
  }
383
451
  return closed.sort((x, y) => serial32Distance(start, x.block.start) - serial32Distance(start, y.block.start))
384
452
  }
@@ -395,15 +463,17 @@ export class IsoBlockLocator<T> {
395
463
  }
396
464
 
397
465
  /**
398
- * Retire every block and span whose last SS_ID, and every held source,
399
- * lies more than `retentionBlocks` blocks behind `position`, with the shared
400
- * lower-bound metric (`isoBlocksBehindLowerBound`) every ISO interleave
401
- * 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.
402
471
  */
403
472
  retireBehind(position: number): number[] {
404
473
  this.observe(position)
405
474
  const horizon = this.#retentionBlocks
406
475
  const retired: number[] = []
476
+ this.#closedBehind.length = 0
407
477
  for (let i = this.#blocks.length - 1; i >= 0; i--) {
408
478
  const block = this.#blocks[i]!
409
479
  const last = serial32Add(block.start, block.length - 1)
@@ -415,10 +485,20 @@ export class IsoBlockLocator<T> {
415
485
  for (let i = this.#holes.length - 1; i >= 0; i--) {
416
486
  const hole = this.#holes[i]!
417
487
  const last = serial32Add(hole.start, hole.length - 1)
418
- 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)
419
492
  }
493
+ // A held source stays while a hole holding it does (class note).
420
494
  for (const ssId of [...this.#held.keys()]) {
421
- 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
+ }
422
502
  }
423
503
  return retired
424
504
  }
@@ -427,6 +507,9 @@ export class IsoBlockLocator<T> {
427
507
  reset(): void {
428
508
  this.#blocks.length = 0
429
509
  this.#holes.length = 0
510
+ this.#expired.length = 0
511
+ this.#newlyExpired.length = 0
512
+ this.#closedBehind.length = 0
430
513
  this.#held.clear()
431
514
  this.#coverageEnd = undefined
432
515
  this.#newest = undefined
@@ -173,6 +173,15 @@ export interface FrameMeta {
173
173
  * on FEC-gated streams.
174
174
  */
175
175
  fecSbn?: number
176
+ /**
177
+ * Set on a frame the keyframe overlay rebuilt (draft-ramadan-moq-mmt-02
178
+ * 7.4) whose base SS_IDs are determined: the lowest base SS_ID of its
179
+ * MPU's packets in that decode, its FT=0 when the decode holds it. The
180
+ * release gate waits only for the blocks before it, and stops waiting once
181
+ * a base frame after this one arrived. `fecSbn` stays the SS_ID the frame
182
+ * completed in.
183
+ */
184
+ fecSbnFirst?: number
176
185
  /**
177
186
  * Set on the first frame released after a gap: the blocks between it and
178
187
  * the previous release resolved with lost media, or authored decode time
@@ -242,13 +251,22 @@ export interface FecReleaseGate {
242
251
  isRap?(data: Uint8Array, meta: FrameMeta): boolean
243
252
  /** A complete RAP entered the gate at SS_ID `ssId` (see `isRap`). */
244
253
  onRap?(packetId: number, catalogTrackName: string | undefined, ssId: number): void
254
+ /**
255
+ * Whether what reads this stream decides on when its frames arrive (a
256
+ * staged stream switch): the gate then releases a held overlay-rebuilt
257
+ * frame when it did unanchored (#cursorPassed).
258
+ */
259
+ releaseUnanchored?(packetId: number, catalogTrackName: string | undefined): boolean
245
260
  }
246
261
 
247
262
  /** Typed ISO-BMFF timing-sample budget counters. See `timingBudgetStats`. */
248
263
  export interface MmtpTimingBudgetStats {
249
264
  /** Slots currently charged to declared-but-undelivered samples. */
250
265
  pending: number
251
- /** Total slots available; `pending === capacity` rejects every new moof. */
266
+ /**
267
+ * Slots each stream's window holds (`maxBufferedMfus`). A stream at
268
+ * capacity displaces its own oldest timing; `pending` sums every stream.
269
+ */
252
270
  capacity: number
253
271
  /** Slots returned by samples the combiner abandoned (evicted or timed out). */
254
272
  reclaimed: number
@@ -259,7 +277,13 @@ export interface MmtpTimingBudgetStats {
259
277
  * up, `evicted` is loss that was never observable at all.
260
278
  */
261
279
  evicted: number
262
- /** Movie fragments refused because the budget had no room for their samples. */
280
+ /**
281
+ * Movie fragments refused because the budget had no room for their samples.
282
+ * Stays 0 with a window per stream (BLO-52085): a stream at capacity
283
+ * displaces its own oldest timing to make room, and a moof declaring more
284
+ * samples than a whole window holds is refused as invalid before any budget
285
+ * is consulted. Kept as a guard, and because it is public.
286
+ */
263
287
  rejectedMovieFragments: number
264
288
  }
265
289
 
@@ -367,7 +391,10 @@ export type MmtpFeedDropReason =
367
391
  | 'fragment-unexpected-fragmentation'
368
392
  /** FT=1 before an FT=0 has bound the stream's timing track. */
369
393
  | 'fragment-no-resolver'
370
- /** addMovieFragment refused — includes the pending-timing-sample budget. */
394
+ /** addMovieFragment refused: the moof does not parse against the stream's
395
+ * track, conflicts with a pending copy of itself, or repeats a pending
396
+ * copy whose samples are partly consumed. Never the timing budget: a full
397
+ * window displaces the stream's own oldest timing. */
371
398
  | 'fragment-timing-refused'
372
399
  | 'unknown-fragment-type'
373
400
  /** Stable code, no longer produced: an FT=2 with no timing (before the
@@ -508,6 +535,8 @@ export interface ParseHeaderOptions {
508
535
  deliveryProvenance?: MmtpDeliveryProvenance
509
536
  /** FEC source block this packet belongs to (FEC-protected packets only). */
510
537
  fecSbn?: number
538
+ /** See FrameMeta.fecSbnFirst. */
539
+ fecSbnFirst?: number
511
540
  }
512
541
 
513
542
  // ── FrameSequencer ──────────────────────────────────────────────────
@@ -721,9 +750,19 @@ function ssIdAfter(a: number, b: number): boolean {
721
750
  return ((a - b) | 0) > 0
722
751
  }
723
752
 
753
+ /** The stream holds a frame the keyframe overlay rebuilt (FrameMeta.fecSbnFirst). */
754
+ function holdsRebuilt(stream: GatedStream): boolean {
755
+ return stream.buf.some(({ meta }) => meta.fecSbnFirst !== undefined)
756
+ }
757
+
724
758
  interface GatedFrame extends PendingFrame {
725
759
  /** performance.now() at push; measures the hold against the gate's limit. */
726
760
  arrivedAtMs: number
761
+ /**
762
+ * The unanchored path's gate is done with this frame (see #cursorPassed):
763
+ * it released it, or dropped it as stale.
764
+ */
765
+ passed?: boolean
727
766
  }
728
767
 
729
768
  interface GatedStream {
@@ -735,6 +774,10 @@ interface GatedStream {
735
774
  discontinuity: boolean
736
775
  /** Latest (serial order) SS_ID of a complete RAP that entered the gate since the last reset. */
737
776
  rapSsId: number | undefined
777
+ /** A frame that was not FEC-recovered was released since the last reset. */
778
+ sourced: boolean
779
+ /** Drains of this stream in progress: a release calls out, and back in (a stream switch's onCommit). */
780
+ draining: number
738
781
  }
739
782
 
740
783
  /**
@@ -743,9 +786,11 @@ interface GatedStream {
743
786
  * to the last released frame is released immediately. A frame after a gap
744
787
  * waits until every block between the last released frame's block and its
745
788
  * own has resolved; a D-28 repair or a late in-band recovery can therefore
746
- * still fill the gap. When the resolved blocks lost media, or authored decode
747
- * time shows a missing sample, the release carries the gate's stall/conceal
748
- * decision as `gapBefore`.
789
+ * still fill the gap. A frame the keyframe overlay rebuilt waits only for the
790
+ * blocks before its MPU (FrameMeta.fecSbnFirst), and no longer than the
791
+ * source cursor would hold it unanchored (#cursorPassed). When the resolved
792
+ * blocks lost media, or authored decode time shows a missing sample, the
793
+ * release carries the gate's stall/conceal decision as `gapBefore`.
749
794
  *
750
795
  * The wait is bounded here, not by the blocks: a frame held longer than the
751
796
  * gate's `holdLimitMs` since it arrived is released with the gate's
@@ -829,6 +874,7 @@ class FecReleaseSequencer {
829
874
  stream.lastSbn = undefined
830
875
  stream.discontinuity = false
831
876
  stream.rapSsId = undefined
877
+ stream.sourced = false
832
878
  }
833
879
  if (stream.last !== null && compareFrameOrder(frame, stream.last) <= 0) {
834
880
  this.#recordStat('droppedStale')
@@ -857,6 +903,8 @@ class FecReleaseSequencer {
857
903
  if (compareFrameOrder(stream.buf[mid]!, frame) < 0) lo = mid + 1
858
904
  else hi = mid
859
905
  }
906
+ // In front of a frame the unanchored path released, it was stale there.
907
+ if (stream.buf[lo]?.passed === true) frame.passed = true
860
908
  stream.buf.splice(lo, 0, frame)
861
909
  this.#drainUnlessDeferred(stream)
862
910
  }
@@ -895,6 +943,54 @@ class FecReleaseSequencer {
895
943
  return false
896
944
  }
897
945
 
946
+ /**
947
+ * These streams' sink is being replaced by one that has seen no source
948
+ * frame yet. First, from a stream that holds a frame the overlay rebuilt,
949
+ * release to the sink being replaced the frames the unanchored path had
950
+ * already released to it (#cursorPassed): there they waited in its queue,
951
+ * which its replacement flushes. A stream that holds none is not drained:
952
+ * it holds nothing the anchor kept back, and a drain would still force out
953
+ * a frame held past the hold limit, which before the anchor waited for the
954
+ * next push. Then treat the streams as unsourced until a source frame is
955
+ * released.
956
+ */
957
+ unsource(packetId: number, catalogTrackName: string | undefined): void {
958
+ for (const stream of this.#streams.values()) {
959
+ if (stream.packetId === packetId && (catalogTrackName === undefined || stream.catalogTrackName === catalogTrackName)) {
960
+ // From inside this stream's own drain (a sink replaced from a frame
961
+ // callback, as a stream switch's onCommit can), the drain under way
962
+ // releases the rest, in order, once the frame it is delivering is
963
+ // done (a commit delivers its held GOP and RAP after onCommit):
964
+ // released here, they would go ahead of them. Inside a deferral
965
+ // scope (a protected flow's record admits frames of several streams
966
+ // in one), the scope's drain does, for the same reason, into the
967
+ // replacement. With the stream unsourced, that drain lets a rebuilt
968
+ // frame go as unanchored did until it releases a source frame; a
969
+ // rebuilt frame behind that one waits for the blocks before it, as an
970
+ // anchored frame does (the record may hold the base frames it needs).
971
+ if (stream.draining === 0 && holdsRebuilt(stream)) {
972
+ if (this.#deferDepth > 0) this.#deferredStreams.add(stream)
973
+ else this.#drain(stream, true)
974
+ }
975
+ stream.sourced = false
976
+ }
977
+ }
978
+ }
979
+
980
+ /**
981
+ * Whether a stream of `packetId` holds a frame the keyframe overlay rebuilt
982
+ * (FrameMeta.fecSbnFirst). Without `catalogTrackName`, every stream of the
983
+ * packetId.
984
+ */
985
+ holdsRebuilt(packetId: number, catalogTrackName: string | undefined): boolean {
986
+ for (const stream of this.#streams.values()) {
987
+ if (stream.packetId !== packetId) continue
988
+ if (catalogTrackName !== undefined && stream.catalogTrackName !== catalogTrackName) continue
989
+ if (holdsRebuilt(stream)) return true
990
+ }
991
+ return false
992
+ }
993
+
898
994
  /** Re-evaluate held gaps after FEC state changed. */
899
995
  poke(streamKey?: string): void {
900
996
  if (streamKey !== undefined) {
@@ -1001,16 +1097,34 @@ class FecReleaseSequencer {
1001
1097
  lastSbn: undefined,
1002
1098
  discontinuity: false,
1003
1099
  rapSsId: undefined,
1100
+ sourced: false,
1101
+ draining: 0,
1004
1102
  }
1005
1103
  this.#streams.set(streamKey, stream)
1006
1104
  }
1007
1105
  return stream
1008
1106
  }
1009
1107
 
1010
- #drain(stream: GatedStream): void {
1108
+ /** `asUnanchored`: release overlay-rebuilt frames when unanchored did (#cursorPassed). */
1109
+ #drain(stream: GatedStream, asUnanchored = false): void {
1110
+ stream.draining++
1111
+ try {
1112
+ this.#drainLoop(stream, asUnanchored)
1113
+ } finally {
1114
+ stream.draining--
1115
+ }
1116
+ }
1117
+
1118
+ #drainLoop(stream: GatedStream, asUnanchored: boolean): void {
1011
1119
  while (stream.buf.length > 0 && stream.last !== null) {
1012
1120
  const head = stream.buf[0]!
1013
1121
  const last = stream.last
1122
+ // A second held copy of the frame just released waits on nothing.
1123
+ if (compareFrameOrder(head, last) <= 0) {
1124
+ stream.buf.shift()
1125
+ this.#release(stream, head, undefined)
1126
+ continue
1127
+ }
1014
1128
  if (stream.discontinuity) {
1015
1129
  stream.discontinuity = false
1016
1130
  stream.buf.shift()
@@ -1024,13 +1138,19 @@ class FecReleaseSequencer {
1024
1138
  }
1025
1139
  const headSbn = head.meta.fecSbn
1026
1140
  const from = stream.lastSbn ?? headSbn
1027
- const to = headSbn ?? stream.lastSbn
1141
+ // An overlay-rebuilt frame waits only for the blocks before its MPU's
1142
+ // first packet in that decode (fecSbnFirst): the overlay delivered the
1143
+ // packets from there on.
1144
+ const to = head.meta.fecSbnFirst ?? headSbn ?? stream.lastSbn
1028
1145
  let lost: readonly number[] = []
1029
1146
  // Neither side carries a Source FEC Payload ID: on a catalog-declared
1030
1147
  // FEC track that is the trailer-stripped profile violation, and there
1031
1148
  // are no blocks to wait on. The gap is still a gap: never release across
1032
1149
  // it undecided (fail closed), let the gate decide with no known blocks.
1033
1150
  const unanchored = from === undefined && to === undefined
1151
+ // The source cursor released it (#cursorPassed) across blocks that have
1152
+ // not resolved: nothing sized that gap either, so it is decided too.
1153
+ let unresolved = false
1034
1154
  if (from !== undefined && to !== undefined) {
1035
1155
  const resolved = this.#gate.resolvedLoss(
1036
1156
  stream.packetId,
@@ -1039,7 +1159,8 @@ class FecReleaseSequencer {
1039
1159
  Math.max(from, to),
1040
1160
  from,
1041
1161
  )
1042
- if (resolved === null) {
1162
+ unresolved = resolved === null
1163
+ if (unresolved && !this.#cursorPassed(stream, asUnanchored)) {
1043
1164
  const heldMs = performance.now() - head.arrivedAtMs
1044
1165
  if (heldMs <= this.#gate.holdLimitMs(stream.packetId, stream.catalogTrackName)) return
1045
1166
  // Past the point where these blocks must have resolved: nothing
@@ -1049,7 +1170,7 @@ class FecReleaseSequencer {
1049
1170
  this.#release(stream, head, this.#gate.decide(last, head, [], true))
1050
1171
  continue
1051
1172
  }
1052
- lost = resolved
1173
+ lost = resolved ?? []
1053
1174
  }
1054
1175
  stream.buf.shift()
1055
1176
  // Every gap in the frame sequence gets a decision, not only one whose
@@ -1060,19 +1181,67 @@ class FecReleaseSequencer {
1060
1181
  this.#release(
1061
1182
  stream,
1062
1183
  head,
1063
- lost.length > 0 || unanchored || authoredGap(last.meta, head.meta)
1184
+ lost.length > 0 || unanchored || unresolved || authoredGap(last.meta, head.meta)
1064
1185
  ? this.#gate.decide(last, head, lost, false)
1065
1186
  : undefined,
1066
1187
  )
1067
1188
  }
1068
1189
  }
1069
1190
 
1191
+ /**
1192
+ * An overlay-rebuilt frame (fecSbnFirst) waits no longer than the BLO-53342
1193
+ * source cursor holds it unanchored, and neither does a later frame behind
1194
+ * it. Unanchored, a frame that waits on no block (fecSbnFirst set, or no
1195
+ * fecSbn) left the gate once every frame ahead of it had: mark it `passed`,
1196
+ * like each frame that arrived in front of it since (stale there). The head
1197
+ * then goes, decided as it would be unanchored, once a frame the base path
1198
+ * delivered (a source frame, or one base FEC recovered) is buffered after a
1199
+ * marked one: the base path has passed that one, so a frame still missing
1200
+ * before it can only come from a repair. It also goes while no source frame
1201
+ * has been released, when the direct path shows a recovered frame at once.
1202
+ * And it goes at once while a staged switch decides on when frames arrive
1203
+ * (FecReleaseGate.releaseUnanchored), and when unsource releases into a
1204
+ * sink being replaced: unanchored, it had already reached the sink.
1205
+ * The scan also writes: the `passed` marks it sets stay on the frames,
1206
+ * and push marks a frame it inserts in front of a marked one from them.
1207
+ */
1208
+ #cursorPassed(stream: GatedStream, asUnanchored: boolean): boolean {
1209
+ let at = -1
1210
+ for (let i = 0; i < stream.buf.length; i++) {
1211
+ const frame = stream.buf[i]!
1212
+ if (frame.meta.fecSbnFirst !== undefined || frame.meta.fecSbn === undefined) {
1213
+ frame.passed = true
1214
+ if (at < 0) at = i
1215
+ } else if (frame.passed !== true) break
1216
+ }
1217
+ if (at < 0) return false
1218
+ return asUnanchored || !stream.sourced ||
1219
+ this.#gate.releaseUnanchored?.(stream.packetId, stream.catalogTrackName) === true ||
1220
+ stream.buf.some(({ meta }, i) =>
1221
+ i > at && meta.fecSbnFirst === undefined && (meta.recovered !== true || meta.fecSbn !== undefined))
1222
+ }
1223
+
1070
1224
  #release(stream: GatedStream, frame: PendingFrame, decision: FrameGapDecision | undefined): void {
1225
+ // Two copies of one frame were held (its base copy and the overlay's
1226
+ // rebuild) and the other went out: this one is stale, as a copy pushed
1227
+ // after its frame was released is. It still moves the state as a release
1228
+ // does: only the second emission is dropped.
1229
+ const stale = stream.last !== null && compareFrameOrder(frame, stream.last) <= 0
1230
+ if (stale) this.#recordStat('droppedStale')
1071
1231
  if (decision) frame.meta.gapBefore = decision
1072
1232
  stream.last = frame
1073
- if (frame.meta.fecSbn !== undefined) stream.lastSbn = frame.meta.fecSbn
1233
+ // An overlay-rebuilt frame (fecSbnFirst) released before any base frame
1234
+ // since the last reset leaves lastSbn unset, as it did unanchored: the
1235
+ // base frame after it then waits for nothing, not for the frames between.
1236
+ if (frame.meta.fecSbn !== undefined && (frame.meta.fecSbnFirst === undefined || stream.lastSbn !== undefined)) {
1237
+ stream.lastSbn = frame.meta.fecSbn
1238
+ }
1239
+ if (frame.meta.recovered !== true) stream.sourced = true
1240
+ // A stale copy of the frame just released (equal order) repeats an
1241
+ // onReleased call already made: noteReleasedRap ignores a second RAP of
1242
+ // an MPU it has anchored (the same MPU and presentation time).
1074
1243
  this.#gate.onReleased?.(frame.data, frame.meta)
1075
- this.#emit(frame.data, frame.meta)
1244
+ if (!stale) this.#emit(frame.data, frame.meta)
1076
1245
  }
1077
1246
  }
1078
1247
 
@@ -1366,11 +1535,12 @@ export class MmtpContainerParser {
1366
1535
  /**
1367
1536
  * Typed view of the ISO-BMFF timing-sample budget.
1368
1537
  *
1369
- * This is the acceptance signal for the budget ratchet: `pending` walking
1370
- * to `capacity` while `rejectedMovieFragments` climbs is the stream wedging,
1371
- * and it is observable here rather than only as media silently stopping.
1372
- * Per repo policy this is a producer-owned counter -- never infer budget
1373
- * state by parsing logs.
1538
+ * Each stream admits timing against its own window of `capacity` slots
1539
+ * (BLO-52085). A stream at capacity displaces its own oldest timing to
1540
+ * admit the moof in hand, and `evicted` counts the slots so displaced:
1541
+ * timing a stream declared and never delivered shows up there. `pending`
1542
+ * sums every stream, so it can exceed `capacity`. Per repo policy this is
1543
+ * a producer-owned counter -- never infer budget state by parsing logs.
1374
1544
  */
1375
1545
  get timingBudgetStats(): Readonly<MmtpTimingBudgetStats> {
1376
1546
  return {
@@ -1439,6 +1609,16 @@ export class MmtpContainerParser {
1439
1609
  return this.#fecSequencer?.rapAbove(packetId, catalogTrackName, ssId) ?? false
1440
1610
  }
1441
1611
 
1612
+ /** See FecReleaseSequencer.unsource. */
1613
+ unsourceFecRelease(packetId: number, catalogTrackName?: string): void {
1614
+ this.#fecSequencer?.unsource(packetId, catalogTrackName)
1615
+ }
1616
+
1617
+ /** See FecReleaseSequencer.holdsRebuilt. */
1618
+ fecReleaseHoldsRebuilt(packetId: number, catalogTrackName?: string): boolean {
1619
+ return this.#fecSequencer?.holdsRebuilt(packetId, catalogTrackName) ?? false
1620
+ }
1621
+
1442
1622
  /** Re-evaluate held gaps after FEC block state changed. */
1443
1623
  pokeFecRelease(packetId?: number, catalogTrackName?: string): void {
1444
1624
  if (!this.#fecSequencer) return
@@ -1759,7 +1939,11 @@ export class MmtpContainerParser {
1759
1939
  if (!resolver?.initialized) return this.#drop('fragment-no-resolver')
1760
1940
  const pendingBefore = resolver.pendingSampleCount
1761
1941
  const evictedBefore = resolver.evictedSampleCount
1762
- const remaining = this.#maxPendingTimingSamples - this.#pendingTimingSamples
1942
+ // Each stream's window is the whole budget. A resolver can displace only
1943
+ // its own timing, so a pool shared across streams let one stream's
1944
+ // unconsumed slots refuse another stream's moofs with nothing to
1945
+ // displace: BLO-52085 G7b, overlay-rebuilt video keyframes refusing audio.
1946
+ const remaining = this.#maxPendingTimingSamples - resolver.pendingSampleCount
1763
1947
  const admitted = resolver.addMovieFragment(
1764
1948
  mpu.mpuSequenceNumber,
1765
1949
  data,
@@ -1767,11 +1951,12 @@ export class MmtpContainerParser {
1767
1951
  options?.recovered === true,
1768
1952
  )
1769
1953
  // Sync on BOTH paths: the resolver can displace stale timing and still
1770
- // refuse the fragment in hand, and a global count that missed those
1771
- // freed slots would keep refusing on a budget that is no longer full.
1954
+ // refuse the fragment in hand, and the total must count those freed slots.
1772
1955
  this.#pendingTimingSamples += resolver.pendingSampleCount - pendingBefore
1773
1956
  this.#evictedTimingSamples += resolver.evictedSampleCount - evictedBefore
1774
1957
  if (!admitted) {
1958
+ // Never 'budget' with a window per stream (see
1959
+ // `rejectedMovieFragments`); kept as a guard.
1775
1960
  if (resolver.lastMovieFragmentRejection === 'budget') {
1776
1961
  this.#rejectedMovieFragments++
1777
1962
  }
@@ -1858,6 +2043,7 @@ export class MmtpContainerParser {
1858
2043
  resolver,
1859
2044
  options?.recovered === true,
1860
2045
  options?.fecSbn,
2046
+ options?.fecSbnFirst,
1861
2047
  )
1862
2048
  if (deferred.record === undefined) this.#countDeferredOutcome(deferred.reason)
1863
2049
  }
@@ -1969,6 +2155,7 @@ export class MmtpContainerParser {
1969
2155
  resolver,
1970
2156
  options?.recovered === true,
1971
2157
  options?.fecSbn,
2158
+ options?.fecSbnFirst,
1972
2159
  )
1973
2160
  if (outcome.record !== undefined) return outcome.record
1974
2161
  return this.#drop(outcome.reason)
@@ -1995,6 +2182,7 @@ export class MmtpContainerParser {
1995
2182
  resolver: IsoBmffTimingResolver,
1996
2183
  recovered: boolean,
1997
2184
  fecSbn: number | undefined,
2185
+ fecSbnFirst: number | undefined,
1998
2186
  ): CombinedSampleOutcome {
1999
2187
  const timing = resolver.sampleTiming(
2000
2188
  combined.mpuSequenceNumber,
@@ -2016,6 +2204,7 @@ export class MmtpContainerParser {
2016
2204
  )
2017
2205
  if (!meta) return { reason: 'combined-meta-build-failed' }
2018
2206
  if (fecSbn !== undefined) meta.fecSbn = fecSbn
2207
+ if (fecSbnFirst !== undefined) meta.fecSbnFirst = fecSbnFirst
2019
2208
  if (!resolver.consumeSample(
2020
2209
  combined.mpuSequenceNumber,
2021
2210
  combined.movieFragmentSequenceNumber,