@blockcast/mmt-container 2.2.0-main.73af0eedcbc8 → 2.2.0-main.a16efba1d85a

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 (89) hide show
  1. package/README.md +29 -17
  2. package/dist/browser-runtime.js +630 -191
  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 +60 -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 +3 -1
  18. package/dist/fec-manager.d.ts.map +1 -1
  19. package/dist/fec-manager.js +26 -4
  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 +5 -4
  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.js.map +1 -1
  31. package/dist/iso-bmff-timing.d.ts +10 -1
  32. package/dist/iso-bmff-timing.d.ts.map +1 -1
  33. package/dist/iso-bmff-timing.js +36 -2
  34. package/dist/iso-bmff-timing.js.map +2 -2
  35. package/dist/media-router.js.map +1 -1
  36. package/dist/mfu-reassembler.js +12 -19
  37. package/dist/mfu-reassembler.js.map +2 -2
  38. package/dist/mmtp-container-parser.d.ts +35 -2
  39. package/dist/mmtp-container-parser.d.ts.map +1 -1
  40. package/dist/mmtp-container-parser.js +97 -28
  41. package/dist/mmtp-container-parser.js.map +2 -2
  42. package/dist/mmtp-container-parser.no-modules.js +222 -45
  43. package/dist/mmtp-container-parser.no-modules.js.map +3 -3
  44. package/dist/mmtp-decode-pipeline.js +97 -28
  45. package/dist/mmtp-decode-pipeline.js.map +2 -2
  46. package/dist/mmtp-fec-session.d.ts +41 -3
  47. package/dist/mmtp-fec-session.d.ts.map +1 -1
  48. package/dist/mmtp-fec-session.js +118 -13
  49. package/dist/mmtp-fec-session.js.map +3 -3
  50. package/dist/mmtp-router.d.ts +6 -0
  51. package/dist/mmtp-router.d.ts.map +1 -1
  52. package/dist/mmtp-router.js +150 -44
  53. package/dist/mmtp-router.js.map +2 -2
  54. package/dist/multicast-auth-catalog.js.map +1 -1
  55. package/dist/multicast-input.js.map +1 -1
  56. package/dist/multicast-manager.js +176 -48
  57. package/dist/multicast-manager.js.map +2 -2
  58. package/dist/multicast-transport.js +26 -16
  59. package/dist/multicast-transport.js.map +2 -2
  60. package/dist/multicast-unwrap.js +12 -19
  61. package/dist/multicast-unwrap.js.map +2 -2
  62. package/dist/no-modules.js +215 -42
  63. package/dist/no-modules.js.map +3 -3
  64. package/dist/repair-fetch.js.map +1 -1
  65. package/dist/repair-layer-subscriptions.d.ts +82 -0
  66. package/dist/repair-layer-subscriptions.d.ts.map +1 -0
  67. package/dist/repair-layer-subscriptions.js +192 -0
  68. package/dist/repair-layer-subscriptions.js.map +7 -0
  69. package/dist/signaling.js.map +1 -1
  70. package/dist/timestamp-codec.js +12 -19
  71. package/dist/timestamp-codec.js.map +2 -2
  72. package/dist/track-session.d.ts +1 -1
  73. package/dist/track-session.js.map +1 -1
  74. package/dist/wasm-mfu-reassembler.js.map +1 -1
  75. package/dist/wasm-mmtp-container-parser.js +97 -28
  76. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  77. package/package.json +8 -5
  78. package/schemas/catalog.schema.json +4 -0
  79. package/src/catalog-fec-params.ts +36 -1
  80. package/src/cmcd-producer.ts +189 -179
  81. package/src/fec-manager.ts +26 -4
  82. package/src/fec-worker-setup.ts +2 -2
  83. package/src/index.ts +18 -2
  84. package/src/iso-bmff-timing.ts +40 -1
  85. package/src/mmtp-container-parser.ts +92 -7
  86. package/src/mmtp-fec-session.ts +115 -15
  87. package/src/mmtp-router.ts +46 -0
  88. package/src/repair-layer-subscriptions.ts +261 -0
  89. package/src/track-session.ts +1 -1
@@ -644,6 +644,10 @@ export class FECManager {
644
644
  // ── Keyframe-protected repair policy (block-policy.ts) ──
645
645
  // One classifier per physical stream, built from that stream's geometry.
646
646
  #classifiers = new Map<string, BlockClassifier>()
647
+ // A stream without catalog timing has no classifier, so the last RAP it
648
+ // released stands in for one: "an MPU boundary bounds its RAPs" holds until
649
+ // a second RAP is released inside one MPU (BLO-52085).
650
+ #noSpanRaps = new Map<string, { mpu: number; presentationTime: bigint; inside: boolean }>()
647
651
  // Geometry a stream-scoped configuration gave one stream (streamKey). ABR
648
652
  // rungs can share a packetId with a different framerate or GOP, so the
649
653
  // packet-level geometry is only the geometry of a packet whose streams
@@ -1706,6 +1710,7 @@ export class FECManager {
1706
1710
  // The classifier compares authored order with SBN order only within
1707
1711
  // one continuous SBN sequence.
1708
1712
  this.#classifiers.get(streamKey)?.noteSourceDiscontinuity()
1713
+ this.#noSpanRaps.delete(streamKey)
1709
1714
  this.#highestSbn.set(streamKey, { sbn, atMs: now })
1710
1715
  this.#sourceFrontiers.set(streamKey, { sbn, atMs: now })
1711
1716
  if (psn === undefined) this.#sourceAnchors.delete(streamKey)
@@ -2289,6 +2294,9 @@ export class FECManager {
2289
2294
  // sibling's change, but the first stream-scoped geometry on a packet
2290
2295
  // does change the siblings that have none (stream-geometry-absent).
2291
2296
  this.#bumpEpoch(packetId)
2297
+ for (const key of [...this.#noSpanRaps.keys()]) {
2298
+ if (this.#belongsToPacket(key, packetId)) this.#noSpanRaps.delete(key)
2299
+ }
2292
2300
  for (const [key, classifier] of [...this.#classifiers]) {
2293
2301
  if (!this.#belongsToPacket(key, packetId)) continue
2294
2302
  const stream = this.#parseStreamKey(key)
@@ -2565,6 +2573,7 @@ export class FECManager {
2565
2573
  resetMap(this.#isoLocators)
2566
2574
  resetMap(this.#sourceAnchors)
2567
2575
  this.#classifiers.delete(this.#streamKey(packetId, catalogTrackName))
2576
+ this.#noSpanRaps.delete(this.#streamKey(packetId, catalogTrackName))
2568
2577
  this.#bumpEpoch(packetId)
2569
2578
  for (const [sbn, keys] of this.#blockKeysBySbn) {
2570
2579
  for (const key of keys.keys()) {
@@ -2679,6 +2688,7 @@ export class FECManager {
2679
2688
  dropMap(this.#isoLocators)
2680
2689
  dropMap(this.#sourceAnchors)
2681
2690
  dropMap(this.#classifiers)
2691
+ dropMap(this.#noSpanRaps)
2682
2692
  dropMap(this.#streamGeometries)
2683
2693
  dropSet(this.#pendingWorkerCleanups)
2684
2694
  dropSet(this.#completedBlocks)
@@ -3470,11 +3480,14 @@ export class FECManager {
3470
3480
  /**
3471
3481
  * Whether an MPU boundary still bounds this stream's RAPs (see
3472
3482
  * `BlockClassifier.mpuBoundsRaps`). Gap decisions drop MPU evidence once
3473
- * it does not. A stream with no classifier yet has shown nothing against it.
3483
+ * it does not. A stream with no classifier yet has shown nothing against it,
3484
+ * and neither has a stream without catalog timing until it releases two
3485
+ * RAPs inside one MPU.
3474
3486
  */
3475
3487
  mpuBoundsRapsFor(packetId: number, catalogTrackName?: string): boolean {
3476
- if (!this.#hasCatalogTimingForPacket(packetId)) return false
3477
- return this.#classifiers.get(this.#streamKey(packetId, catalogTrackName))?.mpuBoundsRaps ?? true
3488
+ const streamKey = this.#streamKey(packetId, catalogTrackName)
3489
+ if (!this.#hasCatalogTimingForPacket(packetId)) return this.#noSpanRaps.get(streamKey)?.inside !== true
3490
+ return this.#classifiers.get(streamKey)?.mpuBoundsRaps ?? true
3478
3491
  }
3479
3492
 
3480
3493
  /** A block's class now, or frozen at its resolution. */
@@ -3510,7 +3523,15 @@ export class FECManager {
3510
3523
  presentationTime: bigint,
3511
3524
  mediaTimescale: number,
3512
3525
  ): void {
3513
- if (!this.#trackConfigs.has(packetId) || !this.#hasCatalogTimingForPacket(packetId)) return
3526
+ if (!this.#trackConfigs.has(packetId)) return
3527
+ if (!this.#hasCatalogTimingForPacket(packetId)) {
3528
+ const streamKey = this.#streamKey(packetId, catalogTrackName)
3529
+ const last = this.#noSpanRaps.get(streamKey)
3530
+ const inside = last?.inside === true ||
3531
+ (last?.mpu === mpu && last.presentationTime !== presentationTime)
3532
+ this.#noSpanRaps.set(streamKey, { mpu, presentationTime, inside })
3533
+ return
3534
+ }
3514
3535
  this.#applyClassifierChanges(
3515
3536
  packetId,
3516
3537
  catalogTrackName,
@@ -4883,6 +4904,7 @@ export class FECManager {
4883
4904
  this.#blockAnchorsMs.clear()
4884
4905
  this.#blockCatalogTrackNames.clear()
4885
4906
  this.#classifiers.clear()
4907
+ this.#noSpanRaps.clear()
4886
4908
  this.#streamGeometries.clear()
4887
4909
  this.#failedBlocks.clear()
4888
4910
  this.#resolvedBlockClasses.clear()
@@ -64,7 +64,7 @@ export interface FecWorkerSetupOptions {
64
64
  /**
65
65
  * Repair symbols per block this receiver actually joined: the sum of
66
66
  * `repairSymbols` over the joined BASE layers (draft-ramadan-moq-fec 6.3).
67
- * Reported as P by the worker snapshot and CMCD `x-bc-fec-p`, because the
67
+ * Reported as P by the worker snapshot and CMCD `com.blockcast-fp`, because the
68
68
  * catalog total is not what this receiver joined. Falls back to
69
69
  * `fecRepairCount`, which is the same value on a single-layer track.
70
70
  */
@@ -252,7 +252,7 @@ export async function setupFecWorkerForTrack(
252
252
  blockProfile,
253
253
  k: opts.fecK,
254
254
  // P as this receiver joined it, not the catalog total: the worker snapshot
255
- // and CMCD `x-bc-fec-p` are read from this. Identity on a single layer.
255
+ // and CMCD `com.blockcast-fp` are read from this. Identity on a single layer.
256
256
  repairCount: opts.joinedRepairCount ?? opts.fecRepairCount,
257
257
  symbolSize: opts.fecSymbolSize,
258
258
  interleaveDepth: interleaveDepthBlocks,
package/src/index.ts CHANGED
@@ -299,6 +299,7 @@ export {
299
299
  type MmtpFeedOutcomeKey,
300
300
  type TrackInfo,
301
301
  type MmtpReorderStats,
302
+ type MmtpRecoveredHeadTimingStats,
302
303
  type MmtpReorderFrameDiagnostics,
303
304
  type MmtpReorderStreamDiagnostics,
304
305
  type FrameMeta as ParserFrameMeta,
@@ -526,10 +527,13 @@ export {
526
527
  type FecStarvationDetectorConfig,
527
528
  } from './fec-starvation-detector.js'
528
529
 
529
- // CMCD Producer (x-bc-* vendor key telemetry, BLO-1044)
530
+ // CMCD v2 Event-mode producer (CTA-5004-B, com.blockcast-* custom keys)
530
531
  export {
531
532
  CmcdProducer,
532
- type CmcdParams,
533
+ encodeCmcdRecord,
534
+ type CmcdRecord,
535
+ type CmcdToken,
536
+ type CmcdValue,
533
537
  type CmcdProducerOptions,
534
538
  type SnapshotData,
535
539
  type TransportSnapshot,
@@ -546,6 +550,7 @@ export {
546
550
  type TrackFecParams,
547
551
  type TrackFecUnavailable,
548
552
  catalogTrackFecUnavailable,
553
+ catalogTrackDeliveryMode,
549
554
  catalogGroupDurationMs,
550
555
  catalogKeyframeCadenceMs,
551
556
  catalogFecMediaUnitsPerSecond,
@@ -602,11 +607,22 @@ export {
602
607
  type MmtpFecSessionConfig,
603
608
  type MmtpFecSessionDecoder,
604
609
  type MmtpFecSessionDecoderConstructor,
610
+ type MmtpFecSessionMessageConfig,
611
+ type MmtpFecSessionMessageUpdate,
605
612
  type MmtpFecSessionMixedPathStats,
606
613
  type MmtpFecSessionPath,
607
614
  type MmtpFecSessionPathCounts,
608
615
  type MmtpFecSessionStats,
609
616
  } from "./mmtp-fec-session.js";
617
+ // MoQ repair-layer subscriptions by measured loss (BLO-35456)
618
+ export {
619
+ RepairLayerSubscriptions,
620
+ type MoqRepairDelivery,
621
+ type RepairLayerSource,
622
+ type RepairLayerSubscriptionsOptions,
623
+ type RepairLayerSubscriptionsSnapshot,
624
+ type RepairTrackSubscription,
625
+ } from "./repair-layer-subscriptions.js";
610
626
 
611
627
  export {
612
628
  catalogAmtNetworkSource,
@@ -581,6 +581,14 @@ type PendingMovieFragment = Array<IsoBmffSampleTiming | null>
581
581
  export class IsoBmffTimingResolver {
582
582
  #track: IsoBmffTrackTiming | null = null
583
583
  #fragments = new Map<string, PendingMovieFragment>()
584
+ /** First sample of each pending fragment, kept in `#delivered` with its sample count. */
585
+ #firstSamples = new Map<string, IsoBmffSampleTiming>()
586
+ /**
587
+ * First sample and sample count of the newest fragments whose every slot was
588
+ * cleared. Abandonment counts: `releaseSample` clears a slot too, which
589
+ * matches the combiner, since it retires an abandoned sample as well.
590
+ */
591
+ #delivered = new Map<string, { first: IsoBmffSampleTiming, sampleCount: number }>()
584
592
  #pendingSamples = 0
585
593
  #evictedSamples = 0
586
594
  #lastMovieFragmentRejection: IsoBmffMovieFragmentRejection | null = null
@@ -591,6 +599,11 @@ export class IsoBmffTimingResolver {
591
599
  }
592
600
  }
593
601
 
602
+ /** True once an FT=0 has bound the track that FT=1 timing is read against. */
603
+ get initialized(): boolean {
604
+ return this.#track !== null
605
+ }
606
+
594
607
  get pendingSampleCount(): number {
595
608
  return this.#pendingSamples
596
609
  }
@@ -618,10 +631,18 @@ export class IsoBmffTimingResolver {
618
631
  return true
619
632
  }
620
633
 
634
+ /**
635
+ * `recovered` marks an FEC-recovered copy. A recovered block hands back
636
+ * every FT=1 it holds, the received ones included, so a recovered copy of a
637
+ * fragment whose samples are all done is a duplicate: admitting it again
638
+ * would charge stale timing and could displace the live MPU's. A source
639
+ * copy is still admitted again (a rendition switch re-arms timing that way).
640
+ */
621
641
  addMovieFragment(
622
642
  mpuSequenceNumber: number,
623
643
  data: Uint8Array,
624
644
  additionalSampleLimit = this.maxPendingSamples,
645
+ recovered = false,
625
646
  ): boolean {
626
647
  this.#lastMovieFragmentRejection = null
627
648
  if (!Number.isInteger(additionalSampleLimit) || additionalSampleLimit < 0) {
@@ -648,6 +669,9 @@ export class IsoBmffTimingResolver {
648
669
  (sample, index) => sample !== null && sameSample(sample, parsed.samples[index]!),
649
670
  )
650
671
  }
672
+ const delivered = recovered ? this.#delivered.get(key) : undefined
673
+ if (delivered && delivered.sampleCount === parsed.samples.length &&
674
+ sameSample(delivered.first, parsed.samples[0]!)) return true
651
675
  // Sliding window, not a one-way ratchet. `consumeSample` and
652
676
  // `releaseSample` between them cover every sample some component observed
653
677
  // -- but a sample whose data units never arrive at all is observed by
@@ -683,6 +707,7 @@ export class IsoBmffTimingResolver {
683
707
  return false
684
708
  }
685
709
  this.#fragments.set(key, [...parsed.samples])
710
+ this.#firstSamples.set(key, parsed.samples[0]!)
686
711
  this.#pendingSamples += parsed.samples.length
687
712
  return true
688
713
  }
@@ -758,6 +783,7 @@ export class IsoBmffTimingResolver {
758
783
  let live = 0
759
784
  for (const sample of samples) if (sample !== null) live++
760
785
  this.#fragments.delete(oldest)
786
+ this.#firstSamples.delete(oldest)
761
787
  this.#pendingSamples -= live
762
788
  this.#evictedSamples += live
763
789
  return live
@@ -779,13 +805,26 @@ export class IsoBmffTimingResolver {
779
805
  if (!samples || !samples[index]) return false
780
806
  samples[index] = null
781
807
  this.#pendingSamples--
782
- if (samples.every(value => value === null)) this.#fragments.delete(key)
808
+ if (samples.every(value => value === null)) {
809
+ this.#fragments.delete(key)
810
+ const first = this.#firstSamples.get(key)!
811
+ this.#firstSamples.delete(key)
812
+ this.#delivered.delete(key)
813
+ this.#delivered.set(key, { first, sampleCount: samples.length })
814
+ // ponytail: the dedupe memory is as wide as the timing budget, in fragments;
815
+ // a recovered copy older than that is admitted again, as before.
816
+ if (this.#delivered.size > this.maxPendingSamples) {
817
+ this.#delivered.delete(this.#delivered.keys().next().value!)
818
+ }
819
+ }
783
820
  return true
784
821
  }
785
822
 
786
823
  reset(): void {
787
824
  this.#track = null
788
825
  this.#fragments.clear()
826
+ this.#firstSamples.clear()
827
+ this.#delivered.clear()
789
828
  this.#pendingSamples = 0
790
829
  }
791
830
  }
@@ -365,10 +365,19 @@ export type MmtpFeedDropReason =
365
365
  /** No timing resolver, or it refused the initialization segment. */
366
366
  | 'init-timing-refused'
367
367
  | 'fragment-unexpected-fragmentation'
368
+ /** FT=1 before an FT=0 has bound the stream's timing track. */
368
369
  | 'fragment-no-resolver'
369
370
  /** addMovieFragment refused — includes the pending-timing-sample budget. */
370
371
  | 'fragment-timing-refused'
371
372
  | 'unknown-fragment-type'
373
+ /** Stable code, no longer produced: an FT=2 with no timing (before the
374
+ * stream's first FT=0, or after setTrack dropped it) now waits for its
375
+ * timing like one whose FT=1 has not arrived. A head counts as
376
+ * `drop:mfu-sample-timing-missing` (parked) and a FI=2/FI=3 continuation
377
+ * as the `pending` record. A continuation that completes a head the
378
+ * reassembler buffered before the timing was dropped counts as
379
+ * `drop:assembled-timing-missing`. A panel keyed on this code reads zero:
380
+ * a re-attribution, not lost coverage. Re-key on both drop codes. */
372
381
  | 'mfu-no-resolver'
373
382
  | 'mfu-empty-payload'
374
383
  | 'mfu-du-header-missing'
@@ -1089,9 +1098,30 @@ interface AwaitingTimingHead {
1089
1098
  mpuSequenceNumber: number
1090
1099
  movieFragmentSequenceNumber: number
1091
1100
  sampleNumber: number
1101
+ /** MMTP packet sequence number; a recovered copy carries its source's. */
1102
+ sequenceNumber: number
1092
1103
  arrivedAt: number
1093
1104
  }
1094
1105
 
1106
+ /** FEC-recovered heads that went through the awaiting-timing queue. Every
1107
+ * count but `pending` is cumulative. A held head is pending or has left the
1108
+ * queue exactly once, so `held - expired - discarded - superseded - pending`
1109
+ * is the number fed again once their FT=1 landed. That feed can still refuse
1110
+ * one (size, offset, dispose from a frame callback); the refusal is counted in
1111
+ * `feedStats.deferredOutcomes`, not here. */
1112
+ export interface MmtpRecoveredHeadTimingStats {
1113
+ /** Held because their sample's FT=1 had not arrived yet. */
1114
+ held: number
1115
+ /** Left without timing: the reassembler-timeout deadline, or pushed out by the queue's capacity. */
1116
+ expired: number
1117
+ /** Dropped with their stream (track reconfigure, reset or retire) or by dispose(). */
1118
+ discarded: number
1119
+ /** Gave way to their arriving source copy, which takes their place in the queue. */
1120
+ superseded: number
1121
+ /** In the queue now; current occupancy, not cumulative. */
1122
+ pending: number
1123
+ }
1124
+
1095
1125
  const MAX_AWAITING_TIMING_HEADS = 8
1096
1126
 
1097
1127
  // ── MmtpContainerParser ─────────────────────────────────────────────
@@ -1129,6 +1159,9 @@ export class MmtpContainerParser {
1129
1159
  #reclaimedTimingSamples = 0
1130
1160
  #evictedTimingSamples = 0
1131
1161
  #rejectedMovieFragments = 0
1162
+ #recoveredHeadsAwaitingTiming: Omit<MmtpRecoveredHeadTimingStats, 'pending'> = {
1163
+ held: 0, expired: 0, discarded: 0, superseded: 0,
1164
+ }
1132
1165
  #sequencers = new Map<string, { packetId: number; sequencer: FrameSequencer }>()
1133
1166
  #fecGate: FecReleaseGate | null = null
1134
1167
  #fecSequencer: FecReleaseSequencer | null = null
@@ -1346,6 +1379,15 @@ export class MmtpContainerParser {
1346
1379
  }
1347
1380
  }
1348
1381
 
1382
+ /** Recovered heads parked for FT=1 timing (BLO-53369). Survives dispose(). */
1383
+ get recoveredHeadsAwaitingTiming(): Readonly<MmtpRecoveredHeadTimingStats> {
1384
+ let pending = 0
1385
+ for (const { awaitingTiming } of this.#timingStreams.values()) {
1386
+ for (const head of awaitingTiming) if (head.options?.recovered) pending++
1387
+ }
1388
+ return { ...this.#recoveredHeadsAwaitingTiming, pending }
1389
+ }
1390
+
1349
1391
  get reorderStats(): Readonly<MmtpReorderStats> {
1350
1392
  let bufferedFrames = this.#fecSequencer?.buffered ?? 0
1351
1393
  for (const { sequencer } of this.#sequencers.values()) bufferedFrames += sequencer.buffered
@@ -1541,7 +1583,7 @@ export class MmtpContainerParser {
1541
1583
  const now = Date.now()
1542
1584
  for (const { awaitingTiming } of this.#timingStreams.values()) {
1543
1585
  while (awaitingTiming.length > 0 && now - awaitingTiming[0]!.arrivedAt > timeoutMs) {
1544
- awaitingTiming.shift()
1586
+ this.#leaveAwaitingTiming(awaitingTiming.shift()!, 'expired')
1545
1587
  }
1546
1588
  }
1547
1589
  }
@@ -1711,11 +1753,16 @@ export class MmtpContainerParser {
1711
1753
  if (mpu.fragmentationIndicator !== 0 || mpu.fragmentCounter !== 0)
1712
1754
  return this.#drop('fragment-unexpected-fragmentation')
1713
1755
  const resolver = this.#timingResolver(streamKey, info.packetId, false)
1714
- if (!resolver) return this.#drop('fragment-no-resolver')
1756
+ if (!resolver?.initialized) return this.#drop('fragment-no-resolver')
1715
1757
  const pendingBefore = resolver.pendingSampleCount
1716
1758
  const evictedBefore = resolver.evictedSampleCount
1717
1759
  const remaining = this.#maxPendingTimingSamples - this.#pendingTimingSamples
1718
- const admitted = resolver.addMovieFragment(mpu.mpuSequenceNumber, data, remaining)
1760
+ const admitted = resolver.addMovieFragment(
1761
+ mpu.mpuSequenceNumber,
1762
+ data,
1763
+ remaining,
1764
+ options?.recovered === true,
1765
+ )
1719
1766
  // Sync on BOTH paths: the resolver can displace stale timing and still
1720
1767
  // refuse the fragment in hand, and a global count that missed those
1721
1768
  // freed slots would keep refusing on a budget that is no longer full.
@@ -1739,7 +1786,11 @@ export class MmtpContainerParser {
1739
1786
  }
1740
1787
  if (fragmentType !== 2) return this.#drop('unknown-fragment-type')
1741
1788
 
1742
- const resolver = this.#timingResolver(streamKey, info.packetId, false)
1789
+ // An FT=2 before the stream's first FT=0 (a join, a track reset) is taken
1790
+ // like one whose FT=1 has not arrived: a head waits for its timing, a
1791
+ // continuation parks in the reassembler, and a later or FEC-recovered
1792
+ // FT=0/FT=1 can still time them.
1793
+ const resolver = this.#timingResolver(streamKey, info.packetId, true)
1743
1794
  if (!resolver) return this.#drop('mfu-no-resolver')
1744
1795
  if (!Number.isSafeInteger(data.byteLength) || data.byteLength <= 0)
1745
1796
  return this.#drop('mfu-empty-payload')
@@ -1761,6 +1812,7 @@ export class MmtpContainerParser {
1761
1812
  mpuSequenceNumber: mpu.mpuSequenceNumber,
1762
1813
  movieFragmentSequenceNumber: mfuDuHeader.movieFragmentSequenceNumber,
1763
1814
  sampleNumber: mfuDuHeader.sampleNumber,
1815
+ sequenceNumber: info.sequenceNumber,
1764
1816
  arrivedAt: Date.now(),
1765
1817
  })
1766
1818
  return this.#drop('mfu-sample-timing-missing')
@@ -2146,6 +2198,18 @@ export class MmtpContainerParser {
2146
2198
  this.#emitFrame(data, meta)
2147
2199
  }
2148
2200
 
2201
+ /** Whether FT=1 metadata already names this sample, without claiming it. */
2202
+ hasAuthoredTimingFor(
2203
+ packetId: number,
2204
+ mpuSequenceNumber: number,
2205
+ movieFragmentSequenceNumber: number,
2206
+ sampleNumber: number,
2207
+ catalogTrackName?: string,
2208
+ ): boolean {
2209
+ return this.#timingResolver(this.#streamKey(packetId, catalogTrackName), packetId, false)
2210
+ ?.sampleTiming(mpuSequenceNumber, movieFragmentSequenceNumber, sampleNumber) != null
2211
+ }
2212
+
2149
2213
  /**
2150
2214
  * Claim and return the authored timing for a recovered sample. Recovery does
2151
2215
  * not pass through the ordinary `feed()` completion path, so it must retire
@@ -2216,6 +2280,9 @@ export class MmtpContainerParser {
2216
2280
  for (const { sequencer } of this.#sequencers.values()) sequencer.discard()
2217
2281
  this.#sequencers.clear()
2218
2282
  this.#fecSequencer?.discard()
2283
+ for (const { awaitingTiming } of this.#timingStreams.values()) {
2284
+ for (const head of awaitingTiming) this.#leaveAwaitingTiming(head, 'discarded')
2285
+ }
2219
2286
  this.#timingStreams.clear()
2220
2287
  this.#pendingTimingSamples = 0
2221
2288
  this.#reliableMoqStartupGate.clear()
@@ -2241,10 +2308,27 @@ export class MmtpContainerParser {
2241
2308
  }
2242
2309
 
2243
2310
  #awaitTiming(streamKey: string, head: AwaitingTimingHead): void {
2244
- const awaitingTiming = this.#timingStreams.get(streamKey)?.awaitingTiming
2245
- if (!awaitingTiming) return
2311
+ const state = this.#timingStreams.get(streamKey)
2312
+ if (!state) return
2313
+ // The source copy of a held recovered head takes its place, so the frame
2314
+ // is delivered with source provenance once FT=1 lands.
2315
+ if (!head.options?.recovered) {
2316
+ const kept = state.awaitingTiming.filter(queued =>
2317
+ !queued.options?.recovered || queued.sequenceNumber !== head.sequenceNumber)
2318
+ this.#recoveredHeadsAwaitingTiming.superseded += state.awaitingTiming.length - kept.length
2319
+ state.awaitingTiming = kept
2320
+ }
2321
+ const awaitingTiming = state.awaitingTiming
2246
2322
  awaitingTiming.push(head)
2247
- if (awaitingTiming.length > MAX_AWAITING_TIMING_HEADS) awaitingTiming.shift()
2323
+ if (head.options?.recovered) this.#recoveredHeadsAwaitingTiming.held++
2324
+ if (awaitingTiming.length > MAX_AWAITING_TIMING_HEADS) {
2325
+ this.#leaveAwaitingTiming(awaitingTiming.shift()!, 'expired')
2326
+ }
2327
+ }
2328
+
2329
+ /** Count a recovered head leaving the queue other than by replay or its source copy. */
2330
+ #leaveAwaitingTiming(head: AwaitingTimingHead, outcome: 'expired' | 'discarded'): void {
2331
+ if (head.options?.recovered) this.#recoveredHeadsAwaitingTiming[outcome]++
2248
2332
  }
2249
2333
 
2250
2334
  /**
@@ -2274,6 +2358,7 @@ export class MmtpContainerParser {
2274
2358
  const state = this.#timingStreams.get(streamKey)
2275
2359
  if (!state) return
2276
2360
  this.#timingStreams.delete(streamKey)
2361
+ for (const head of state.awaitingTiming) this.#leaveAwaitingTiming(head, 'discarded')
2277
2362
  this.#pendingTimingSamples -= state.resolver.pendingSampleCount
2278
2363
  if (this.#pendingTimingSamples < 0) {
2279
2364
  throw new Error('[MmtpContainerParser] pending timing sample accounting underflow')
@@ -23,6 +23,15 @@
23
23
  * uses. A packet below B_0 at or after the anchor fails the session closed:
24
24
  * `stats.failClosed` reports why, and every later repair is refused.
25
25
  *
26
+ * Catalog-less (draft-ramadan-moq-mmt-02 8.3, 13.2): a receiver holding
27
+ * layer 0 under the AL-FEC message alone builds the session from the message's
28
+ * fec_flow entry (`alFecFlow`) instead. K, T, the code point and the repair
29
+ * packet_id come from that entry, and layer 0 is held at
30
+ * maximum_p_for_repair_flow, standing for O_0 + P_0, by the anchored clause of
31
+ * draft-ramadan-moq-fec-core-00 7.3.2 alone. A later version that raises the
32
+ * field raises the held total (updateAlFecFlow()); the message carries no
33
+ * millisecond budget, so the caller states the retention (fec-core-00 9.4).
34
+ *
26
35
  * Every repair packet that cannot be used is counted under the reason it was
27
36
  * refused, and every block ends under exactly one outcome, so a stream whose
28
37
  * FEC is doing nothing can be told apart from one with nothing to repair.
@@ -30,6 +39,8 @@
30
39
 
31
40
  import {
32
41
  admitMmtpRepairObject,
42
+ alFecRaptorQFlowConfig,
43
+ FEC_CODE_RAPTORQ,
33
44
  isoBlockContains,
34
45
  isoBlocksBehindLowerBound,
35
46
  parseFecBlockProfile,
@@ -39,6 +50,8 @@ import {
39
50
  serial32Precedes,
40
51
  ssbg1FitsSymbol,
41
52
  trimSsbg1SourceSymbol,
53
+ type AlFecFlow,
54
+ type AlFecRaptorQFlowConfig,
42
55
  type FecBlockProfile,
43
56
  } from '@blockcast/mmt-base'
44
57
  import { deriveFecRetentionBlocks, fecBlockBudgetMs } from '@blockcast/mmt-fec'
@@ -105,6 +118,25 @@ export interface MmtpFecSessionConfig {
105
118
  heldLayers?: readonly number[]
106
119
  }
107
120
 
121
+ /**
122
+ * A catalog-less session: one fec_flow entry of the AL-FEC message
123
+ * (`parseAlFecMessage(...).flows[i]`), draft-ramadan-moq-mmt-02 8.3.
124
+ */
125
+ export interface MmtpFecSessionMessageConfig {
126
+ /** The source track's entry; it must pass `alFecRaptorQFlowConfig`. */
127
+ alFecFlow: AlFecFlow
128
+ /**
129
+ * The decoder retention in blocks. The message carries no elapsed-time
130
+ * budget (mmt-02 7.5), so open-block state is bounded by local policy
131
+ * (fec-core-00 9.4); required, never defaulted.
132
+ */
133
+ retentionBlocks: number
134
+ Decoder: MmtpFecSessionDecoderConstructor
135
+ }
136
+
137
+ /** What updateAlFecFlow() did with a later version of the message. */
138
+ export type MmtpFecSessionMessageUpdate = 'raised' | 'unchanged' | 'refused'
139
+
108
140
  /**
109
141
  * The path a symbol arrived on: its MoQT subscription, or the multicast
110
142
  * group (native SSM or AMT) carrying the same asset. The caller states it;
@@ -197,12 +229,17 @@ export class MmtpFecSession {
197
229
  readonly algorithm: 'raptorq' | 'reed-solomon'
198
230
  readonly sourceSymbols: number
199
231
  /** P_0 = B_0, the held total every repair packet is checked against. */
200
- readonly repairSymbols: number
232
+ get repairSymbols(): number {
233
+ return this.#repairSymbols
234
+ }
201
235
  readonly symbolSize: number
202
236
  /** The decoder's retention, in blocks, derived from the catalog budget. */
203
237
  readonly retentionBlocks: number
204
238
 
205
239
  #decoder: MmtpFecSessionDecoder | null
240
+ #repairSymbols: number
241
+ /** The message entry a catalog-less session was built from; undefined under a catalog. */
242
+ #message: AlFecRaptorQFlowConfig | undefined
206
243
  /** Blocks a repair has defined and that are not settled yet, by SS_Start. */
207
244
  #blocks = new Map<number, BlockState>()
208
245
  /** SS_Starts of settled blocks, so a later repair for one is refused. */
@@ -240,7 +277,26 @@ export class MmtpFecSession {
240
277
  * the scheme, the block profile, K, P_0 (`repairSymbols`), T,
241
278
  * `interleaveDepthMs` or `reorderToleranceMs`. None is defaulted.
242
279
  */
243
- constructor(config: MmtpFecSessionConfig) {
280
+ constructor(config: MmtpFecSessionConfig | MmtpFecSessionMessageConfig) {
281
+ if (config && 'alFecFlow' in config) {
282
+ const message = alFecRaptorQFlowConfig(config.alFecFlow)
283
+ if (!message) {
284
+ throw new Error('MmtpFecSession: the AL-FEC entry is outside draft-ramadan-moq-mmt-02 8.3 (iso-ssbg1-v1)')
285
+ }
286
+ this.#message = message
287
+ // Coding byte 0x14 is the iso-ssbg1-v1 profile (mmt-02 8.3).
288
+ this.blockProfile = parseFecBlockProfile('iso-ssbg1-v1', 'alFecFlow')
289
+ this.algorithm = message.fecCodeId === FEC_CODE_RAPTORQ ? 'raptorq' : 'reed-solomon'
290
+ this.sourceSymbols = message.maximumK
291
+ this.#repairSymbols = message.maximumP
292
+ this.symbolSize = message.repairSymbolSize
293
+ this.retentionBlocks = requirePositiveInteger(config.retentionBlocks, 'retentionBlocks')
294
+ this.#repairPacketId = message.repairFlowId
295
+ this.#heldLayers = layerZero(message.maximumP)
296
+ this.#anchorHorizon = heldLayerAnchorHorizon(this.retentionBlocks, this.sourceSymbols)
297
+ this.#decoder = buildDecoder(config.Decoder, this)
298
+ return
299
+ }
244
300
  const fec = config?.fec
245
301
  if (!fec || typeof fec !== 'object') {
246
302
  throw new Error('MmtpFecSession: the catalog fec instance is required')
@@ -251,7 +307,7 @@ export class MmtpFecSession {
251
307
  }
252
308
  this.algorithm = fec.scheme
253
309
  this.sourceSymbols = requirePositiveInteger(fec.sourceSymbols, 'fec.sourceSymbols')
254
- this.repairSymbols = requirePositiveInteger(fec.repairSymbols, 'fec.repairSymbols')
310
+ this.#repairSymbols = requirePositiveInteger(fec.repairSymbols, 'fec.repairSymbols')
255
311
  this.symbolSize = requirePositiveInteger(fec.symbolSize, 'fec.symbolSize')
256
312
  const interleaveDepthMs = requireNumber(fec.interleaveDepthMs, 'fec.interleaveDepthMs')
257
313
  const reorderToleranceMs = requireNumber(fec.reorderToleranceMs, 'fec.reorderToleranceMs')
@@ -271,17 +327,38 @@ export class MmtpFecSession {
271
327
  config.heldLayers,
272
328
  )
273
329
  this.#anchorHorizon = heldLayerAnchorHorizon(this.retentionBlocks, this.sourceSymbols)
274
- if (typeof config.Decoder !== 'function') {
275
- throw new Error('MmtpFecSession: a decoder constructor (wasm_bindgen.MmtFecDecoder) is required')
276
- }
277
- const decoder = new config.Decoder(this.retentionBlocks, this.blockProfile, this.algorithm)
278
- try {
279
- decoder.configure_params(this.sourceSymbols, this.symbolSize)
280
- } catch (error) {
281
- decoder.free()
282
- throw error
330
+ this.#decoder = buildDecoder(config.Decoder, this)
331
+ }
332
+
333
+ /**
334
+ * A later version of the AL-FEC message, catalog-less sessions only. A
335
+ * maximum_p_for_repair_flow above the held total raises it (`raised`): the
336
+ * anchored check restarts at the new total, so a block generated under the
337
+ * previous one stays valid before the first block carrying the new total
338
+ * (fec-core-00 7.3.2, mmt-02 8.3). A field at or below it changes nothing
339
+ * (`unchanged`): no publisher lowers it, and the check catches a wire that
340
+ * does. An entry with another asset, repair flow, code point, K or T, or
341
+ * one outside 8.3, is `refused`: that is a new source encoding, which needs
342
+ * a new session. A latched fail-closed stays latched either way.
343
+ */
344
+ updateAlFecFlow(flow: AlFecFlow): MmtpFecSessionMessageUpdate {
345
+ const held = this.#message
346
+ if (!held) throw new Error('MmtpFecSession: updateAlFecFlow() needs a session built from an AL-FEC entry')
347
+ const next = alFecRaptorQFlowConfig(flow)
348
+ if (
349
+ !next ||
350
+ next.sourcePacketIds[0] !== held.sourcePacketIds[0] ||
351
+ next.repairFlowId !== held.repairFlowId ||
352
+ next.fecCodeId !== held.fecCodeId ||
353
+ next.maximumK !== held.maximumK ||
354
+ next.repairSymbolSize !== held.repairSymbolSize
355
+ ) {
356
+ return 'refused'
283
357
  }
284
- this.#decoder = decoder
358
+ if (next.maximumP <= this.#repairSymbols) return 'unchanged'
359
+ this.#repairSymbols = next.maximumP
360
+ this.#heldLayers = layerZero(next.maximumP)
361
+ return 'raised'
285
362
  }
286
363
 
287
364
  /** True once release() ran. */
@@ -386,7 +463,7 @@ export class MmtpFecSession {
386
463
  // An RSB_length below a held layer's total goes to the 7.3.2 stop, not
387
464
  // to the discard; the block state is unchanged either way.
388
465
  const verdict = this.#heldLayers.failsClosed(
389
- repairTrackName, frame.ssStart, frame.rsbLength, this.#anchorHorizon, this.#newestSource,
466
+ this.#trackFor(repairTrackName), frame.ssStart, frame.rsbLength, this.#anchorHorizon, this.#newestSource,
390
467
  )
391
468
  if (verdict) {
392
469
  this.#stats.failClosed = verdict
@@ -399,7 +476,7 @@ export class MmtpFecSession {
399
476
  // fec-01 6.3.9, after the 4.5 discards and the size check, before a block
400
477
  // is placed. Without a track name only the anchored rule (b) applies.
401
478
  const verdict = this.#heldLayers.admit(
402
- repairTrackName,
479
+ this.#trackFor(repairTrackName),
403
480
  frame.ssStart,
404
481
  frame.rsbLength,
405
482
  this.#anchorHorizon,
@@ -464,6 +541,11 @@ export class MmtpFecSession {
464
541
  return true
465
542
  }
466
543
 
544
+ /** Catalog-less: layer 0 has no own-track clause (mmt-02 8.3), so no track name is judged. */
545
+ #trackFor(repairTrackName: string | undefined): string | undefined {
546
+ return this.#message ? undefined : repairTrackName
547
+ }
548
+
467
549
  #drop(reason: string): Uint8Array[] {
468
550
  this.#stats.dropped[reason] = (this.#stats.dropped[reason] ?? 0) + 1
469
551
  return []
@@ -600,6 +682,24 @@ export class MmtpFecSession {
600
682
  }
601
683
  }
602
684
 
685
+ function layerZero(total: number): HeldLayerAdmission {
686
+ return new HeldLayerAdmission([{ layer: 0, repairTrackName: '', rsIdOffset: 0, repairSymbols: total }])
687
+ }
688
+
689
+ function buildDecoder(Decoder: MmtpFecSessionDecoderConstructor, session: MmtpFecSession): MmtpFecSessionDecoder {
690
+ if (typeof Decoder !== 'function') {
691
+ throw new Error('MmtpFecSession: a decoder constructor (wasm_bindgen.MmtFecDecoder) is required')
692
+ }
693
+ const decoder = new Decoder(session.retentionBlocks, session.blockProfile, session.algorithm)
694
+ try {
695
+ decoder.configure_params(session.sourceSymbols, session.symbolSize)
696
+ } catch (error) {
697
+ decoder.free()
698
+ throw error
699
+ }
700
+ return decoder
701
+ }
702
+
603
703
  function requirePositiveInteger(value: unknown, path: string): number {
604
704
  if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
605
705
  throw new Error(`MmtpFecSession: ${path} must be a positive integer (got ${String(value)})`)