@blockcast/mmt-container 2.2.0-main.75b39a2cdddf → 2.2.0-main.7e8a9d13c01c

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 (52) hide show
  1. package/dist/browser-runtime.js +634 -129
  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 +37 -11
  5. package/dist/fec-manager.d.ts.map +1 -1
  6. package/dist/fec-manager.js +181 -52
  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 +21 -0
  18. package/dist/mmtp-container-parser.d.ts.map +1 -1
  19. package/dist/mmtp-container-parser.js +114 -11
  20. package/dist/mmtp-container-parser.js.map +2 -2
  21. package/dist/mmtp-container-parser.no-modules.js +113 -12
  22. package/dist/mmtp-container-parser.no-modules.js.map +2 -2
  23. package/dist/mmtp-decode-pipeline.js +114 -11
  24. package/dist/mmtp-decode-pipeline.js.map +2 -2
  25. package/dist/mmtp-router.d.ts +18 -9
  26. package/dist/mmtp-router.d.ts.map +1 -1
  27. package/dist/mmtp-router.js +254 -44
  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 +455 -103
  32. package/dist/multicast-manager.js.map +2 -2
  33. package/dist/no-modules.js +114 -11
  34. package/dist/no-modules.js.map +2 -2
  35. package/dist/repair-fetch.d.ts +36 -8
  36. package/dist/repair-fetch.d.ts.map +1 -1
  37. package/dist/repair-fetch.js +285 -26
  38. package/dist/repair-fetch.js.map +3 -3
  39. package/dist/track-session.d.ts.map +1 -1
  40. package/dist/track-session.js +1 -0
  41. package/dist/track-session.js.map +2 -2
  42. package/dist/wasm-mmtp-container-parser.js +114 -11
  43. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  44. package/package.json +5 -5
  45. package/src/fec-manager.ts +156 -26
  46. package/src/index.ts +2 -0
  47. package/src/iso-block-locator.ts +114 -31
  48. package/src/mmtp-container-parser.ts +181 -10
  49. package/src/mmtp-router.ts +231 -58
  50. package/src/multicast-manager.ts +44 -6
  51. package/src/repair-fetch.ts +363 -49
  52. 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,6 +251,12 @@ 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`. */
@@ -520,6 +535,8 @@ export interface ParseHeaderOptions {
520
535
  deliveryProvenance?: MmtpDeliveryProvenance
521
536
  /** FEC source block this packet belongs to (FEC-protected packets only). */
522
537
  fecSbn?: number
538
+ /** See FrameMeta.fecSbnFirst. */
539
+ fecSbnFirst?: number
523
540
  }
524
541
 
525
542
  // ── FrameSequencer ──────────────────────────────────────────────────
@@ -733,9 +750,19 @@ function ssIdAfter(a: number, b: number): boolean {
733
750
  return ((a - b) | 0) > 0
734
751
  }
735
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
+
736
758
  interface GatedFrame extends PendingFrame {
737
759
  /** performance.now() at push; measures the hold against the gate's limit. */
738
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
739
766
  }
740
767
 
741
768
  interface GatedStream {
@@ -747,6 +774,10 @@ interface GatedStream {
747
774
  discontinuity: boolean
748
775
  /** Latest (serial order) SS_ID of a complete RAP that entered the gate since the last reset. */
749
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
750
781
  }
751
782
 
752
783
  /**
@@ -755,9 +786,11 @@ interface GatedStream {
755
786
  * to the last released frame is released immediately. A frame after a gap
756
787
  * waits until every block between the last released frame's block and its
757
788
  * own has resolved; a D-28 repair or a late in-band recovery can therefore
758
- * still fill the gap. When the resolved blocks lost media, or authored decode
759
- * time shows a missing sample, the release carries the gate's stall/conceal
760
- * 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`.
761
794
  *
762
795
  * The wait is bounded here, not by the blocks: a frame held longer than the
763
796
  * gate's `holdLimitMs` since it arrived is released with the gate's
@@ -841,6 +874,7 @@ class FecReleaseSequencer {
841
874
  stream.lastSbn = undefined
842
875
  stream.discontinuity = false
843
876
  stream.rapSsId = undefined
877
+ stream.sourced = false
844
878
  }
845
879
  if (stream.last !== null && compareFrameOrder(frame, stream.last) <= 0) {
846
880
  this.#recordStat('droppedStale')
@@ -869,6 +903,8 @@ class FecReleaseSequencer {
869
903
  if (compareFrameOrder(stream.buf[mid]!, frame) < 0) lo = mid + 1
870
904
  else hi = mid
871
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
872
908
  stream.buf.splice(lo, 0, frame)
873
909
  this.#drainUnlessDeferred(stream)
874
910
  }
@@ -907,6 +943,54 @@ class FecReleaseSequencer {
907
943
  return false
908
944
  }
909
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
+
910
994
  /** Re-evaluate held gaps after FEC state changed. */
911
995
  poke(streamKey?: string): void {
912
996
  if (streamKey !== undefined) {
@@ -1013,16 +1097,34 @@ class FecReleaseSequencer {
1013
1097
  lastSbn: undefined,
1014
1098
  discontinuity: false,
1015
1099
  rapSsId: undefined,
1100
+ sourced: false,
1101
+ draining: 0,
1016
1102
  }
1017
1103
  this.#streams.set(streamKey, stream)
1018
1104
  }
1019
1105
  return stream
1020
1106
  }
1021
1107
 
1022
- #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 {
1023
1119
  while (stream.buf.length > 0 && stream.last !== null) {
1024
1120
  const head = stream.buf[0]!
1025
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
+ }
1026
1128
  if (stream.discontinuity) {
1027
1129
  stream.discontinuity = false
1028
1130
  stream.buf.shift()
@@ -1036,13 +1138,19 @@ class FecReleaseSequencer {
1036
1138
  }
1037
1139
  const headSbn = head.meta.fecSbn
1038
1140
  const from = stream.lastSbn ?? headSbn
1039
- 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
1040
1145
  let lost: readonly number[] = []
1041
1146
  // Neither side carries a Source FEC Payload ID: on a catalog-declared
1042
1147
  // FEC track that is the trailer-stripped profile violation, and there
1043
1148
  // are no blocks to wait on. The gap is still a gap: never release across
1044
1149
  // it undecided (fail closed), let the gate decide with no known blocks.
1045
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
1046
1154
  if (from !== undefined && to !== undefined) {
1047
1155
  const resolved = this.#gate.resolvedLoss(
1048
1156
  stream.packetId,
@@ -1051,7 +1159,8 @@ class FecReleaseSequencer {
1051
1159
  Math.max(from, to),
1052
1160
  from,
1053
1161
  )
1054
- if (resolved === null) {
1162
+ unresolved = resolved === null
1163
+ if (unresolved && !this.#cursorPassed(stream, asUnanchored)) {
1055
1164
  const heldMs = performance.now() - head.arrivedAtMs
1056
1165
  if (heldMs <= this.#gate.holdLimitMs(stream.packetId, stream.catalogTrackName)) return
1057
1166
  // Past the point where these blocks must have resolved: nothing
@@ -1061,7 +1170,7 @@ class FecReleaseSequencer {
1061
1170
  this.#release(stream, head, this.#gate.decide(last, head, [], true))
1062
1171
  continue
1063
1172
  }
1064
- lost = resolved
1173
+ lost = resolved ?? []
1065
1174
  }
1066
1175
  stream.buf.shift()
1067
1176
  // Every gap in the frame sequence gets a decision, not only one whose
@@ -1072,19 +1181,67 @@ class FecReleaseSequencer {
1072
1181
  this.#release(
1073
1182
  stream,
1074
1183
  head,
1075
- lost.length > 0 || unanchored || authoredGap(last.meta, head.meta)
1184
+ lost.length > 0 || unanchored || unresolved || authoredGap(last.meta, head.meta)
1076
1185
  ? this.#gate.decide(last, head, lost, false)
1077
1186
  : undefined,
1078
1187
  )
1079
1188
  }
1080
1189
  }
1081
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
+
1082
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')
1083
1231
  if (decision) frame.meta.gapBefore = decision
1084
1232
  stream.last = frame
1085
- 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).
1086
1243
  this.#gate.onReleased?.(frame.data, frame.meta)
1087
- this.#emit(frame.data, frame.meta)
1244
+ if (!stale) this.#emit(frame.data, frame.meta)
1088
1245
  }
1089
1246
  }
1090
1247
 
@@ -1452,6 +1609,16 @@ export class MmtpContainerParser {
1452
1609
  return this.#fecSequencer?.rapAbove(packetId, catalogTrackName, ssId) ?? false
1453
1610
  }
1454
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
+
1455
1622
  /** Re-evaluate held gaps after FEC block state changed. */
1456
1623
  pokeFecRelease(packetId?: number, catalogTrackName?: string): void {
1457
1624
  if (!this.#fecSequencer) return
@@ -1876,6 +2043,7 @@ export class MmtpContainerParser {
1876
2043
  resolver,
1877
2044
  options?.recovered === true,
1878
2045
  options?.fecSbn,
2046
+ options?.fecSbnFirst,
1879
2047
  )
1880
2048
  if (deferred.record === undefined) this.#countDeferredOutcome(deferred.reason)
1881
2049
  }
@@ -1987,6 +2155,7 @@ export class MmtpContainerParser {
1987
2155
  resolver,
1988
2156
  options?.recovered === true,
1989
2157
  options?.fecSbn,
2158
+ options?.fecSbnFirst,
1990
2159
  )
1991
2160
  if (outcome.record !== undefined) return outcome.record
1992
2161
  return this.#drop(outcome.reason)
@@ -2013,6 +2182,7 @@ export class MmtpContainerParser {
2013
2182
  resolver: IsoBmffTimingResolver,
2014
2183
  recovered: boolean,
2015
2184
  fecSbn: number | undefined,
2185
+ fecSbnFirst: number | undefined,
2016
2186
  ): CombinedSampleOutcome {
2017
2187
  const timing = resolver.sampleTiming(
2018
2188
  combined.mpuSequenceNumber,
@@ -2034,6 +2204,7 @@ export class MmtpContainerParser {
2034
2204
  )
2035
2205
  if (!meta) return { reason: 'combined-meta-build-failed' }
2036
2206
  if (fecSbn !== undefined) meta.fecSbn = fecSbn
2207
+ if (fecSbnFirst !== undefined) meta.fecSbnFirst = fecSbnFirst
2037
2208
  if (!resolver.consumeSample(
2038
2209
  combined.mpuSequenceNumber,
2039
2210
  combined.movieFragmentSequenceNumber,