@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
@@ -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
 
@@ -365,10 +389,22 @@ export type MmtpFeedDropReason =
365
389
  /** No timing resolver, or it refused the initialization segment. */
366
390
  | 'init-timing-refused'
367
391
  | 'fragment-unexpected-fragmentation'
392
+ /** FT=1 before an FT=0 has bound the stream's timing track. */
368
393
  | 'fragment-no-resolver'
369
- /** 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. */
370
398
  | 'fragment-timing-refused'
371
399
  | 'unknown-fragment-type'
400
+ /** Stable code, no longer produced: an FT=2 with no timing (before the
401
+ * stream's first FT=0, or after setTrack dropped it) now waits for its
402
+ * timing like one whose FT=1 has not arrived. A head counts as
403
+ * `drop:mfu-sample-timing-missing` (parked) and a FI=2/FI=3 continuation
404
+ * as the `pending` record. A continuation that completes a head the
405
+ * reassembler buffered before the timing was dropped counts as
406
+ * `drop:assembled-timing-missing`. A panel keyed on this code reads zero:
407
+ * a re-attribution, not lost coverage. Re-key on both drop codes. */
372
408
  | 'mfu-no-resolver'
373
409
  | 'mfu-empty-payload'
374
410
  | 'mfu-du-header-missing'
@@ -499,6 +535,8 @@ export interface ParseHeaderOptions {
499
535
  deliveryProvenance?: MmtpDeliveryProvenance
500
536
  /** FEC source block this packet belongs to (FEC-protected packets only). */
501
537
  fecSbn?: number
538
+ /** See FrameMeta.fecSbnFirst. */
539
+ fecSbnFirst?: number
502
540
  }
503
541
 
504
542
  // ── FrameSequencer ──────────────────────────────────────────────────
@@ -679,7 +717,10 @@ class FrameSequencer {
679
717
  // ── FecReleaseSequencer ─────────────────────────────────────────────
680
718
 
681
719
  /** Authored decode-time adjacency: the next frame starts where this one ends. */
682
- function authoredAdjacent(before: FrameMeta, after: FrameMeta): boolean {
720
+ export function authoredAdjacent(
721
+ before: Pick<FrameMeta, 'authoredTiming'>,
722
+ after: Pick<FrameMeta, 'authoredTiming'>,
723
+ ): boolean {
683
724
  const a = before.authoredTiming
684
725
  const b = after.authoredTiming
685
726
  if (!a || !b || a.mediaTimescale !== b.mediaTimescale) return false
@@ -709,9 +750,19 @@ function ssIdAfter(a: number, b: number): boolean {
709
750
  return ((a - b) | 0) > 0
710
751
  }
711
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
+
712
758
  interface GatedFrame extends PendingFrame {
713
759
  /** performance.now() at push; measures the hold against the gate's limit. */
714
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
715
766
  }
716
767
 
717
768
  interface GatedStream {
@@ -723,6 +774,10 @@ interface GatedStream {
723
774
  discontinuity: boolean
724
775
  /** Latest (serial order) SS_ID of a complete RAP that entered the gate since the last reset. */
725
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
726
781
  }
727
782
 
728
783
  /**
@@ -731,9 +786,11 @@ interface GatedStream {
731
786
  * to the last released frame is released immediately. A frame after a gap
732
787
  * waits until every block between the last released frame's block and its
733
788
  * own has resolved; a D-28 repair or a late in-band recovery can therefore
734
- * still fill the gap. When the resolved blocks lost media, or authored decode
735
- * time shows a missing sample, the release carries the gate's stall/conceal
736
- * 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`.
737
794
  *
738
795
  * The wait is bounded here, not by the blocks: a frame held longer than the
739
796
  * gate's `holdLimitMs` since it arrived is released with the gate's
@@ -817,6 +874,7 @@ class FecReleaseSequencer {
817
874
  stream.lastSbn = undefined
818
875
  stream.discontinuity = false
819
876
  stream.rapSsId = undefined
877
+ stream.sourced = false
820
878
  }
821
879
  if (stream.last !== null && compareFrameOrder(frame, stream.last) <= 0) {
822
880
  this.#recordStat('droppedStale')
@@ -845,6 +903,8 @@ class FecReleaseSequencer {
845
903
  if (compareFrameOrder(stream.buf[mid]!, frame) < 0) lo = mid + 1
846
904
  else hi = mid
847
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
848
908
  stream.buf.splice(lo, 0, frame)
849
909
  this.#drainUnlessDeferred(stream)
850
910
  }
@@ -883,6 +943,54 @@ class FecReleaseSequencer {
883
943
  return false
884
944
  }
885
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
+
886
994
  /** Re-evaluate held gaps after FEC state changed. */
887
995
  poke(streamKey?: string): void {
888
996
  if (streamKey !== undefined) {
@@ -989,16 +1097,34 @@ class FecReleaseSequencer {
989
1097
  lastSbn: undefined,
990
1098
  discontinuity: false,
991
1099
  rapSsId: undefined,
1100
+ sourced: false,
1101
+ draining: 0,
992
1102
  }
993
1103
  this.#streams.set(streamKey, stream)
994
1104
  }
995
1105
  return stream
996
1106
  }
997
1107
 
998
- #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 {
999
1119
  while (stream.buf.length > 0 && stream.last !== null) {
1000
1120
  const head = stream.buf[0]!
1001
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
+ }
1002
1128
  if (stream.discontinuity) {
1003
1129
  stream.discontinuity = false
1004
1130
  stream.buf.shift()
@@ -1012,13 +1138,19 @@ class FecReleaseSequencer {
1012
1138
  }
1013
1139
  const headSbn = head.meta.fecSbn
1014
1140
  const from = stream.lastSbn ?? headSbn
1015
- 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
1016
1145
  let lost: readonly number[] = []
1017
1146
  // Neither side carries a Source FEC Payload ID: on a catalog-declared
1018
1147
  // FEC track that is the trailer-stripped profile violation, and there
1019
1148
  // are no blocks to wait on. The gap is still a gap: never release across
1020
1149
  // it undecided (fail closed), let the gate decide with no known blocks.
1021
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
1022
1154
  if (from !== undefined && to !== undefined) {
1023
1155
  const resolved = this.#gate.resolvedLoss(
1024
1156
  stream.packetId,
@@ -1027,7 +1159,8 @@ class FecReleaseSequencer {
1027
1159
  Math.max(from, to),
1028
1160
  from,
1029
1161
  )
1030
- if (resolved === null) {
1162
+ unresolved = resolved === null
1163
+ if (unresolved && !this.#cursorPassed(stream, asUnanchored)) {
1031
1164
  const heldMs = performance.now() - head.arrivedAtMs
1032
1165
  if (heldMs <= this.#gate.holdLimitMs(stream.packetId, stream.catalogTrackName)) return
1033
1166
  // Past the point where these blocks must have resolved: nothing
@@ -1037,7 +1170,7 @@ class FecReleaseSequencer {
1037
1170
  this.#release(stream, head, this.#gate.decide(last, head, [], true))
1038
1171
  continue
1039
1172
  }
1040
- lost = resolved
1173
+ lost = resolved ?? []
1041
1174
  }
1042
1175
  stream.buf.shift()
1043
1176
  // Every gap in the frame sequence gets a decision, not only one whose
@@ -1048,19 +1181,67 @@ class FecReleaseSequencer {
1048
1181
  this.#release(
1049
1182
  stream,
1050
1183
  head,
1051
- lost.length > 0 || unanchored || authoredGap(last.meta, head.meta)
1184
+ lost.length > 0 || unanchored || unresolved || authoredGap(last.meta, head.meta)
1052
1185
  ? this.#gate.decide(last, head, lost, false)
1053
1186
  : undefined,
1054
1187
  )
1055
1188
  }
1056
1189
  }
1057
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
+
1058
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')
1059
1231
  if (decision) frame.meta.gapBefore = decision
1060
1232
  stream.last = frame
1061
- 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).
1062
1243
  this.#gate.onReleased?.(frame.data, frame.meta)
1063
- this.#emit(frame.data, frame.meta)
1244
+ if (!stale) this.#emit(frame.data, frame.meta)
1064
1245
  }
1065
1246
  }
1066
1247
 
@@ -1089,9 +1270,30 @@ interface AwaitingTimingHead {
1089
1270
  mpuSequenceNumber: number
1090
1271
  movieFragmentSequenceNumber: number
1091
1272
  sampleNumber: number
1273
+ /** MMTP packet sequence number; a recovered copy carries its source's. */
1274
+ sequenceNumber: number
1092
1275
  arrivedAt: number
1093
1276
  }
1094
1277
 
1278
+ /** FEC-recovered heads that went through the awaiting-timing queue. Every
1279
+ * count but `pending` is cumulative. A held head is pending or has left the
1280
+ * queue exactly once, so `held - expired - discarded - superseded - pending`
1281
+ * is the number fed again once their FT=1 landed. That feed can still refuse
1282
+ * one (size, offset, dispose from a frame callback); the refusal is counted in
1283
+ * `feedStats.deferredOutcomes`, not here. */
1284
+ export interface MmtpRecoveredHeadTimingStats {
1285
+ /** Held because their sample's FT=1 had not arrived yet. */
1286
+ held: number
1287
+ /** Left without timing: the reassembler-timeout deadline, or pushed out by the queue's capacity. */
1288
+ expired: number
1289
+ /** Dropped with their stream (track reconfigure, reset or retire) or by dispose(). */
1290
+ discarded: number
1291
+ /** Gave way to their arriving source copy, which takes their place in the queue. */
1292
+ superseded: number
1293
+ /** In the queue now; current occupancy, not cumulative. */
1294
+ pending: number
1295
+ }
1296
+
1095
1297
  const MAX_AWAITING_TIMING_HEADS = 8
1096
1298
 
1097
1299
  // ── MmtpContainerParser ─────────────────────────────────────────────
@@ -1129,6 +1331,9 @@ export class MmtpContainerParser {
1129
1331
  #reclaimedTimingSamples = 0
1130
1332
  #evictedTimingSamples = 0
1131
1333
  #rejectedMovieFragments = 0
1334
+ #recoveredHeadsAwaitingTiming: Omit<MmtpRecoveredHeadTimingStats, 'pending'> = {
1335
+ held: 0, expired: 0, discarded: 0, superseded: 0,
1336
+ }
1132
1337
  #sequencers = new Map<string, { packetId: number; sequencer: FrameSequencer }>()
1133
1338
  #fecGate: FecReleaseGate | null = null
1134
1339
  #fecSequencer: FecReleaseSequencer | null = null
@@ -1330,11 +1535,12 @@ export class MmtpContainerParser {
1330
1535
  /**
1331
1536
  * Typed view of the ISO-BMFF timing-sample budget.
1332
1537
  *
1333
- * This is the acceptance signal for the budget ratchet: `pending` walking
1334
- * to `capacity` while `rejectedMovieFragments` climbs is the stream wedging,
1335
- * and it is observable here rather than only as media silently stopping.
1336
- * Per repo policy this is a producer-owned counter -- never infer budget
1337
- * 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.
1338
1544
  */
1339
1545
  get timingBudgetStats(): Readonly<MmtpTimingBudgetStats> {
1340
1546
  return {
@@ -1346,6 +1552,15 @@ export class MmtpContainerParser {
1346
1552
  }
1347
1553
  }
1348
1554
 
1555
+ /** Recovered heads parked for FT=1 timing (BLO-53369). Survives dispose(). */
1556
+ get recoveredHeadsAwaitingTiming(): Readonly<MmtpRecoveredHeadTimingStats> {
1557
+ let pending = 0
1558
+ for (const { awaitingTiming } of this.#timingStreams.values()) {
1559
+ for (const head of awaitingTiming) if (head.options?.recovered) pending++
1560
+ }
1561
+ return { ...this.#recoveredHeadsAwaitingTiming, pending }
1562
+ }
1563
+
1349
1564
  get reorderStats(): Readonly<MmtpReorderStats> {
1350
1565
  let bufferedFrames = this.#fecSequencer?.buffered ?? 0
1351
1566
  for (const { sequencer } of this.#sequencers.values()) bufferedFrames += sequencer.buffered
@@ -1394,6 +1609,16 @@ export class MmtpContainerParser {
1394
1609
  return this.#fecSequencer?.rapAbove(packetId, catalogTrackName, ssId) ?? false
1395
1610
  }
1396
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
+
1397
1622
  /** Re-evaluate held gaps after FEC block state changed. */
1398
1623
  pokeFecRelease(packetId?: number, catalogTrackName?: string): void {
1399
1624
  if (!this.#fecSequencer) return
@@ -1541,7 +1766,7 @@ export class MmtpContainerParser {
1541
1766
  const now = Date.now()
1542
1767
  for (const { awaitingTiming } of this.#timingStreams.values()) {
1543
1768
  while (awaitingTiming.length > 0 && now - awaitingTiming[0]!.arrivedAt > timeoutMs) {
1544
- awaitingTiming.shift()
1769
+ this.#leaveAwaitingTiming(awaitingTiming.shift()!, 'expired')
1545
1770
  }
1546
1771
  }
1547
1772
  }
@@ -1711,17 +1936,27 @@ export class MmtpContainerParser {
1711
1936
  if (mpu.fragmentationIndicator !== 0 || mpu.fragmentCounter !== 0)
1712
1937
  return this.#drop('fragment-unexpected-fragmentation')
1713
1938
  const resolver = this.#timingResolver(streamKey, info.packetId, false)
1714
- if (!resolver) return this.#drop('fragment-no-resolver')
1939
+ if (!resolver?.initialized) return this.#drop('fragment-no-resolver')
1715
1940
  const pendingBefore = resolver.pendingSampleCount
1716
1941
  const evictedBefore = resolver.evictedSampleCount
1717
- const remaining = this.#maxPendingTimingSamples - this.#pendingTimingSamples
1718
- const admitted = resolver.addMovieFragment(mpu.mpuSequenceNumber, data, remaining)
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
1947
+ const admitted = resolver.addMovieFragment(
1948
+ mpu.mpuSequenceNumber,
1949
+ data,
1950
+ remaining,
1951
+ options?.recovered === true,
1952
+ )
1719
1953
  // Sync on BOTH paths: the resolver can displace stale timing and still
1720
- // refuse the fragment in hand, and a global count that missed those
1721
- // 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.
1722
1955
  this.#pendingTimingSamples += resolver.pendingSampleCount - pendingBefore
1723
1956
  this.#evictedTimingSamples += resolver.evictedSampleCount - evictedBefore
1724
1957
  if (!admitted) {
1958
+ // Never 'budget' with a window per stream (see
1959
+ // `rejectedMovieFragments`); kept as a guard.
1725
1960
  if (resolver.lastMovieFragmentRejection === 'budget') {
1726
1961
  this.#rejectedMovieFragments++
1727
1962
  }
@@ -1739,7 +1974,11 @@ export class MmtpContainerParser {
1739
1974
  }
1740
1975
  if (fragmentType !== 2) return this.#drop('unknown-fragment-type')
1741
1976
 
1742
- const resolver = this.#timingResolver(streamKey, info.packetId, false)
1977
+ // An FT=2 before the stream's first FT=0 (a join, a track reset) is taken
1978
+ // like one whose FT=1 has not arrived: a head waits for its timing, a
1979
+ // continuation parks in the reassembler, and a later or FEC-recovered
1980
+ // FT=0/FT=1 can still time them.
1981
+ const resolver = this.#timingResolver(streamKey, info.packetId, true)
1743
1982
  if (!resolver) return this.#drop('mfu-no-resolver')
1744
1983
  if (!Number.isSafeInteger(data.byteLength) || data.byteLength <= 0)
1745
1984
  return this.#drop('mfu-empty-payload')
@@ -1761,6 +2000,7 @@ export class MmtpContainerParser {
1761
2000
  mpuSequenceNumber: mpu.mpuSequenceNumber,
1762
2001
  movieFragmentSequenceNumber: mfuDuHeader.movieFragmentSequenceNumber,
1763
2002
  sampleNumber: mfuDuHeader.sampleNumber,
2003
+ sequenceNumber: info.sequenceNumber,
1764
2004
  arrivedAt: Date.now(),
1765
2005
  })
1766
2006
  return this.#drop('mfu-sample-timing-missing')
@@ -1803,6 +2043,7 @@ export class MmtpContainerParser {
1803
2043
  resolver,
1804
2044
  options?.recovered === true,
1805
2045
  options?.fecSbn,
2046
+ options?.fecSbnFirst,
1806
2047
  )
1807
2048
  if (deferred.record === undefined) this.#countDeferredOutcome(deferred.reason)
1808
2049
  }
@@ -1914,6 +2155,7 @@ export class MmtpContainerParser {
1914
2155
  resolver,
1915
2156
  options?.recovered === true,
1916
2157
  options?.fecSbn,
2158
+ options?.fecSbnFirst,
1917
2159
  )
1918
2160
  if (outcome.record !== undefined) return outcome.record
1919
2161
  return this.#drop(outcome.reason)
@@ -1940,6 +2182,7 @@ export class MmtpContainerParser {
1940
2182
  resolver: IsoBmffTimingResolver,
1941
2183
  recovered: boolean,
1942
2184
  fecSbn: number | undefined,
2185
+ fecSbnFirst: number | undefined,
1943
2186
  ): CombinedSampleOutcome {
1944
2187
  const timing = resolver.sampleTiming(
1945
2188
  combined.mpuSequenceNumber,
@@ -1961,6 +2204,7 @@ export class MmtpContainerParser {
1961
2204
  )
1962
2205
  if (!meta) return { reason: 'combined-meta-build-failed' }
1963
2206
  if (fecSbn !== undefined) meta.fecSbn = fecSbn
2207
+ if (fecSbnFirst !== undefined) meta.fecSbnFirst = fecSbnFirst
1964
2208
  if (!resolver.consumeSample(
1965
2209
  combined.mpuSequenceNumber,
1966
2210
  combined.movieFragmentSequenceNumber,
@@ -2146,6 +2390,18 @@ export class MmtpContainerParser {
2146
2390
  this.#emitFrame(data, meta)
2147
2391
  }
2148
2392
 
2393
+ /** Whether FT=1 metadata already names this sample, without claiming it. */
2394
+ hasAuthoredTimingFor(
2395
+ packetId: number,
2396
+ mpuSequenceNumber: number,
2397
+ movieFragmentSequenceNumber: number,
2398
+ sampleNumber: number,
2399
+ catalogTrackName?: string,
2400
+ ): boolean {
2401
+ return this.#timingResolver(this.#streamKey(packetId, catalogTrackName), packetId, false)
2402
+ ?.sampleTiming(mpuSequenceNumber, movieFragmentSequenceNumber, sampleNumber) != null
2403
+ }
2404
+
2149
2405
  /**
2150
2406
  * Claim and return the authored timing for a recovered sample. Recovery does
2151
2407
  * not pass through the ordinary `feed()` completion path, so it must retire
@@ -2216,6 +2472,9 @@ export class MmtpContainerParser {
2216
2472
  for (const { sequencer } of this.#sequencers.values()) sequencer.discard()
2217
2473
  this.#sequencers.clear()
2218
2474
  this.#fecSequencer?.discard()
2475
+ for (const { awaitingTiming } of this.#timingStreams.values()) {
2476
+ for (const head of awaitingTiming) this.#leaveAwaitingTiming(head, 'discarded')
2477
+ }
2219
2478
  this.#timingStreams.clear()
2220
2479
  this.#pendingTimingSamples = 0
2221
2480
  this.#reliableMoqStartupGate.clear()
@@ -2241,10 +2500,27 @@ export class MmtpContainerParser {
2241
2500
  }
2242
2501
 
2243
2502
  #awaitTiming(streamKey: string, head: AwaitingTimingHead): void {
2244
- const awaitingTiming = this.#timingStreams.get(streamKey)?.awaitingTiming
2245
- if (!awaitingTiming) return
2503
+ const state = this.#timingStreams.get(streamKey)
2504
+ if (!state) return
2505
+ // The source copy of a held recovered head takes its place, so the frame
2506
+ // is delivered with source provenance once FT=1 lands.
2507
+ if (!head.options?.recovered) {
2508
+ const kept = state.awaitingTiming.filter(queued =>
2509
+ !queued.options?.recovered || queued.sequenceNumber !== head.sequenceNumber)
2510
+ this.#recoveredHeadsAwaitingTiming.superseded += state.awaitingTiming.length - kept.length
2511
+ state.awaitingTiming = kept
2512
+ }
2513
+ const awaitingTiming = state.awaitingTiming
2246
2514
  awaitingTiming.push(head)
2247
- if (awaitingTiming.length > MAX_AWAITING_TIMING_HEADS) awaitingTiming.shift()
2515
+ if (head.options?.recovered) this.#recoveredHeadsAwaitingTiming.held++
2516
+ if (awaitingTiming.length > MAX_AWAITING_TIMING_HEADS) {
2517
+ this.#leaveAwaitingTiming(awaitingTiming.shift()!, 'expired')
2518
+ }
2519
+ }
2520
+
2521
+ /** Count a recovered head leaving the queue other than by replay or its source copy. */
2522
+ #leaveAwaitingTiming(head: AwaitingTimingHead, outcome: 'expired' | 'discarded'): void {
2523
+ if (head.options?.recovered) this.#recoveredHeadsAwaitingTiming[outcome]++
2248
2524
  }
2249
2525
 
2250
2526
  /**
@@ -2274,6 +2550,7 @@ export class MmtpContainerParser {
2274
2550
  const state = this.#timingStreams.get(streamKey)
2275
2551
  if (!state) return
2276
2552
  this.#timingStreams.delete(streamKey)
2553
+ for (const head of state.awaitingTiming) this.#leaveAwaitingTiming(head, 'discarded')
2277
2554
  this.#pendingTimingSamples -= state.resolver.pendingSampleCount
2278
2555
  if (this.#pendingTimingSamples < 0) {
2279
2556
  throw new Error('[MmtpContainerParser] pending timing sample accounting underflow')