@blockcast/mmt-container 2.2.0-main.da2e7cd1dd5d → 2.2.0-main.f480a7ede8b1
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.
- package/dist/browser-runtime.js +215 -23
- package/dist/browser-runtime.js.map +2 -2
- package/dist/delivery-catalog.js +2 -1
- package/dist/delivery-catalog.js.map +2 -2
- package/dist/fec-manager.d.ts.map +1 -1
- package/dist/fec-manager.js +95 -1
- package/dist/fec-manager.js.map +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +2 -2
- package/dist/iso-block-locator.d.ts +9 -0
- package/dist/iso-block-locator.d.ts.map +1 -1
- package/dist/iso-block-locator.js +17 -0
- package/dist/iso-block-locator.js.map +2 -2
- package/dist/iso-bmff-timing.d.ts +10 -1
- package/dist/iso-bmff-timing.d.ts.map +1 -1
- package/dist/iso-bmff-timing.js +36 -2
- package/dist/iso-bmff-timing.js.map +2 -2
- package/dist/mmtp-container-parser.d.ts +35 -2
- package/dist/mmtp-container-parser.d.ts.map +1 -1
- package/dist/mmtp-container-parser.js +82 -9
- package/dist/mmtp-container-parser.js.map +2 -2
- package/dist/mmtp-container-parser.no-modules.js +85 -11
- package/dist/mmtp-container-parser.no-modules.js.map +2 -2
- package/dist/mmtp-decode-pipeline.js +81 -9
- package/dist/mmtp-decode-pipeline.js.map +2 -2
- package/dist/mmtp-router.d.ts +6 -0
- package/dist/mmtp-router.d.ts.map +1 -1
- package/dist/mmtp-router.js +120 -22
- package/dist/mmtp-router.js.map +2 -2
- package/dist/multicast-manager.js +215 -23
- package/dist/multicast-manager.js.map +2 -2
- package/dist/multicast-transport.js +2 -1
- package/dist/multicast-transport.js.map +2 -2
- package/dist/no-modules.js +81 -9
- package/dist/no-modules.js.map +2 -2
- package/dist/wasm-mmtp-container-parser.js +81 -9
- package/dist/wasm-mmtp-container-parser.js.map +2 -2
- package/package.json +5 -5
- package/src/fec-manager.ts +104 -1
- package/src/index.ts +1 -0
- package/src/iso-block-locator.ts +19 -0
- package/src/iso-bmff-timing.ts +40 -1
- package/src/mmtp-container-parser.ts +84 -8
- package/src/mmtp-router.ts +64 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blockcast/mmt-container",
|
|
3
|
-
"version": "2.2.0-main.
|
|
3
|
+
"version": "2.2.0-main.f480a7ede8b1",
|
|
4
4
|
"description": "MMTP/LOC container parsing for MMT over MoQ and SSM",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -169,10 +169,10 @@
|
|
|
169
169
|
"test:watch": "vitest"
|
|
170
170
|
},
|
|
171
171
|
"dependencies": {
|
|
172
|
-
"@blockcast/fec-worker": "2.2.0-main.
|
|
173
|
-
"@blockcast/mmt-base": "2.2.0-main.
|
|
174
|
-
"@blockcast/mmt-fec": "2.2.0-main.
|
|
175
|
-
"@blockcast/mmt-manifest-verify": "2.2.0-main.
|
|
172
|
+
"@blockcast/fec-worker": "2.2.0-main.f480a7ede8b1",
|
|
173
|
+
"@blockcast/mmt-base": "2.2.0-main.f480a7ede8b1",
|
|
174
|
+
"@blockcast/mmt-fec": "2.2.0-main.f480a7ede8b1",
|
|
175
|
+
"@blockcast/mmt-manifest-verify": "2.2.0-main.f480a7ede8b1",
|
|
176
176
|
"@noble/curves": "^1.9.7",
|
|
177
177
|
"@noble/hashes": "^1.8.0",
|
|
178
178
|
"ajv": "^8.17.1",
|
package/src/fec-manager.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
isoBlockContains,
|
|
23
23
|
isoBlocksBehindLowerBound,
|
|
24
24
|
parseFecBlockProfile,
|
|
25
|
+
serial32Add,
|
|
25
26
|
serial32Distance,
|
|
26
27
|
serial32Precedes,
|
|
27
28
|
ssbg1FitsSymbol,
|
|
@@ -2891,6 +2892,10 @@ export class FECManager {
|
|
|
2891
2892
|
}
|
|
2892
2893
|
}
|
|
2893
2894
|
|
|
2895
|
+
if (isSourceSymbol && source === 'multicast') {
|
|
2896
|
+
this.#noteMulticastSource(this.#isoLocatorFor(packetId, catalogTrackName), (sbn + esi) >>> 0, packetId, catalogTrackName)
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2894
2899
|
return true
|
|
2895
2900
|
}
|
|
2896
2901
|
|
|
@@ -3263,6 +3268,7 @@ export class FECManager {
|
|
|
3263
3268
|
const locator = this.#growingIsoLocatorFor(packetId, catalogTrackName)
|
|
3264
3269
|
const evictedBefore = locator.heldEvicted
|
|
3265
3270
|
const held = locator.hold(ssId, source)
|
|
3271
|
+
if (held && source.source === 'multicast') this.#noteMulticastSource(locator, ssId, packetId, catalogTrackName)
|
|
3266
3272
|
const evicted = locator.heldEvicted - evictedBefore
|
|
3267
3273
|
if (evicted > 0) {
|
|
3268
3274
|
const before = this.#isoHeldEvictedCount
|
|
@@ -4364,9 +4370,95 @@ export class FECManager {
|
|
|
4364
4370
|
this.#completeCleanBlock(blockKey, sbn, packetId, catalogTrackName)
|
|
4365
4371
|
return null
|
|
4366
4372
|
}
|
|
4373
|
+
// Its K' is known now: a block this repair shows short of it waits less.
|
|
4374
|
+
if (recovered === null) this.#shortenUnrecoverableWait(blockKey, sbn, packetId, catalogTrackName)
|
|
4367
4375
|
return recovered
|
|
4368
4376
|
}
|
|
4369
4377
|
|
|
4378
|
+
/**
|
|
4379
|
+
* A first-arrival multicast source, held or placed. A datagram flow keeps
|
|
4380
|
+
* its sources in send order, so one that moves the stream's newest source
|
|
4381
|
+
* position past a block leaves the sources that block still misses
|
|
4382
|
+
* reordered behind it at best: it shortens the wait of every block it
|
|
4383
|
+
* passed that only a late source can complete. MoQ is left out: its streams
|
|
4384
|
+
* can overtake one another, so a MoQ source shows nothing about the sources
|
|
4385
|
+
* sent before it.
|
|
4386
|
+
*/
|
|
4387
|
+
#noteMulticastSource(
|
|
4388
|
+
locator: IsoBlockLocator<IsoHeldSource>,
|
|
4389
|
+
ssId: number,
|
|
4390
|
+
packetId: number,
|
|
4391
|
+
catalogTrackName: string | undefined,
|
|
4392
|
+
): void {
|
|
4393
|
+
const previous = locator.newestSource
|
|
4394
|
+
if (!locator.noteSource(ssId) || previous === undefined) return
|
|
4395
|
+
// The blocks whose last SS_ID lies in [previous, ssId): this source
|
|
4396
|
+
// passed them and the previous newest one had not.
|
|
4397
|
+
for (const block of locator.blocksBetween((previous - 1) >>> 0, ssId)) {
|
|
4398
|
+
if (block.span || !serial32Precedes(serial32Add(block.start, block.length - 1), ssId)) continue
|
|
4399
|
+
this.#shortenUnrecoverableWait(
|
|
4400
|
+
this.#blockKey(block.start, packetId, catalogTrackName),
|
|
4401
|
+
block.start,
|
|
4402
|
+
packetId,
|
|
4403
|
+
catalogTrackName,
|
|
4404
|
+
)
|
|
4405
|
+
}
|
|
4406
|
+
}
|
|
4407
|
+
|
|
4408
|
+
/**
|
|
4409
|
+
* The wait of a block that only a late source can complete (BLO-52085): a
|
|
4410
|
+
* repair stated it, its received sources plus every symbol its repair group
|
|
4411
|
+
* carries (RSB_length) are short of K' (the repair scheduler's
|
|
4412
|
+
* group-cannot-complete), and a later source of the stream has arrived, so
|
|
4413
|
+
* a source it misses is lost or reordered behind that one. The span, which
|
|
4414
|
+
* covers repair symbols paced after its sources, buys it nothing: it waits
|
|
4415
|
+
* the late-symbol allowance alone, the multicast wait less the span (the
|
|
4416
|
+
* catalog tolerance or the measured delivery gap). Undefined for any other
|
|
4417
|
+
* block.
|
|
4418
|
+
*/
|
|
4419
|
+
#lateSourceWaitMs(
|
|
4420
|
+
key: string,
|
|
4421
|
+
sbn: number,
|
|
4422
|
+
packetId: number,
|
|
4423
|
+
catalogTrackName: string | undefined,
|
|
4424
|
+
config: ResolvedFECTrackConfig,
|
|
4425
|
+
): number | undefined {
|
|
4426
|
+
const authoredLength = this.#blockAuthoredLengths.get(key)
|
|
4427
|
+
const rsbLength = this.#blockRsbLengths.get(key)
|
|
4428
|
+
if (authoredLength === undefined || rsbLength === undefined) return undefined
|
|
4429
|
+
if ((this.#blockSourceCounts.get(key) ?? 0) + rsbLength >= authoredLength) return undefined
|
|
4430
|
+
const newestSource = this.#isoLocators.get(this.#streamKey(packetId, catalogTrackName))?.newestSource
|
|
4431
|
+
if (newestSource === undefined || !serial32Precedes(serial32Add(sbn, authoredLength - 1), newestSource)) {
|
|
4432
|
+
return undefined
|
|
4433
|
+
}
|
|
4434
|
+
return this.#multicastWaitMs(config) - config.interleaveDepthMs
|
|
4435
|
+
}
|
|
4436
|
+
|
|
4437
|
+
/**
|
|
4438
|
+
* Bring an open block's wait in to its late-source wait, when it has one.
|
|
4439
|
+
* Never later than it is armed: only a source can complete such a block,
|
|
4440
|
+
* so a repair of its group (paced across the span) must not keep it open.
|
|
4441
|
+
*/
|
|
4442
|
+
#shortenUnrecoverableWait(
|
|
4443
|
+
key: string,
|
|
4444
|
+
sbn: number,
|
|
4445
|
+
packetId: number,
|
|
4446
|
+
catalogTrackName: string | undefined,
|
|
4447
|
+
): void {
|
|
4448
|
+
if (!this.#activeBlocks.has(key)) return
|
|
4449
|
+
const config = this.#trackConfigs.get(packetId)
|
|
4450
|
+
if (config === undefined) return
|
|
4451
|
+
const waitMs = this.#lateSourceWaitMs(key, sbn, packetId, catalogTrackName, config)
|
|
4452
|
+
if (waitMs === undefined) return
|
|
4453
|
+
const armedAtMs = this.#blockWaitDeadlinesMs.get(key)
|
|
4454
|
+
this.#armBlockTimeout(
|
|
4455
|
+
sbn,
|
|
4456
|
+
packetId,
|
|
4457
|
+
catalogTrackName,
|
|
4458
|
+
armedAtMs === undefined ? waitMs : Math.min(waitMs, armedAtMs - this.#receiptNow()),
|
|
4459
|
+
)
|
|
4460
|
+
}
|
|
4461
|
+
|
|
4370
4462
|
/**
|
|
4371
4463
|
* Record a block's authored SSB_length from a repair symbol. Returns false,
|
|
4372
4464
|
* leaving the block unchanged, when the symbol contradicts the block: a
|
|
@@ -4714,7 +4806,18 @@ export class FECManager {
|
|
|
4714
4806
|
)
|
|
4715
4807
|
}
|
|
4716
4808
|
|
|
4717
|
-
|
|
4809
|
+
// A block only a late source can complete waits the late-symbol
|
|
4810
|
+
// allowance from its own sources' activity; nothing else moves it later.
|
|
4811
|
+
if (!sourceProgress && this.#lateSourceWaitMs(key, sbn, packetId, catalogTrackName, trackConfig) !== undefined) {
|
|
4812
|
+
this.#shortenUnrecoverableWait(key, sbn, packetId, catalogTrackName)
|
|
4813
|
+
return
|
|
4814
|
+
}
|
|
4815
|
+
this.#armBlockTimeout(
|
|
4816
|
+
sbn,
|
|
4817
|
+
packetId,
|
|
4818
|
+
catalogTrackName,
|
|
4819
|
+
this.#lateSourceWaitMs(key, sbn, packetId, catalogTrackName, trackConfig) ?? timeoutMs,
|
|
4820
|
+
)
|
|
4718
4821
|
}
|
|
4719
4822
|
|
|
4720
4823
|
#failBlock(
|
package/src/index.ts
CHANGED
package/src/iso-block-locator.ts
CHANGED
|
@@ -85,6 +85,7 @@ export class IsoBlockLocator<T> {
|
|
|
85
85
|
#retentionBlocks: number
|
|
86
86
|
#heldEvicted = 0
|
|
87
87
|
#newest: number | undefined
|
|
88
|
+
#newestSource: number | undefined
|
|
88
89
|
/** Pending holes (see `deferIncompleteGaps`): unstated, sources still held. */
|
|
89
90
|
readonly #holes: IsoBlockRange[] = []
|
|
90
91
|
/**
|
|
@@ -152,6 +153,23 @@ export class IsoBlockLocator<T> {
|
|
|
152
153
|
if (this.#newest === undefined || serial32Precedes(this.#newest, position)) this.#newest = position
|
|
153
154
|
}
|
|
154
155
|
|
|
156
|
+
/**
|
|
157
|
+
* The newest source SS_ID noted with `noteSource` (RFC 1982 order). Unlike
|
|
158
|
+
* `newest`, a repair never moves it: a repair can run ahead of its sources,
|
|
159
|
+
* a source cannot run ahead of the sources sent before it except by
|
|
160
|
+
* reordering.
|
|
161
|
+
*/
|
|
162
|
+
get newestSource(): number | undefined {
|
|
163
|
+
return this.#newestSource
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Note a received source's SS_ID; true when it is the newest one yet. */
|
|
167
|
+
noteSource(ssId: number): boolean {
|
|
168
|
+
if (this.#newestSource !== undefined && !serial32Precedes(this.#newestSource, ssId)) return false
|
|
169
|
+
this.#newestSource = ssId
|
|
170
|
+
return true
|
|
171
|
+
}
|
|
172
|
+
|
|
155
173
|
/** Held sources evicted by `heldCapacity` (never by the horizon itself). */
|
|
156
174
|
get heldEvicted(): number {
|
|
157
175
|
return this.#heldEvicted
|
|
@@ -412,6 +430,7 @@ export class IsoBlockLocator<T> {
|
|
|
412
430
|
this.#held.clear()
|
|
413
431
|
this.#coverageEnd = undefined
|
|
414
432
|
this.#newest = undefined
|
|
433
|
+
this.#newestSource = undefined
|
|
415
434
|
}
|
|
416
435
|
|
|
417
436
|
#claim(block: IsoBlockRange): IsoPlacedSource<T>[] {
|
package/src/iso-bmff-timing.ts
CHANGED
|
@@ -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))
|
|
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'
|
|
@@ -679,7 +688,10 @@ class FrameSequencer {
|
|
|
679
688
|
// ── FecReleaseSequencer ─────────────────────────────────────────────
|
|
680
689
|
|
|
681
690
|
/** Authored decode-time adjacency: the next frame starts where this one ends. */
|
|
682
|
-
function authoredAdjacent(
|
|
691
|
+
export function authoredAdjacent(
|
|
692
|
+
before: Pick<FrameMeta, 'authoredTiming'>,
|
|
693
|
+
after: Pick<FrameMeta, 'authoredTiming'>,
|
|
694
|
+
): boolean {
|
|
683
695
|
const a = before.authoredTiming
|
|
684
696
|
const b = after.authoredTiming
|
|
685
697
|
if (!a || !b || a.mediaTimescale !== b.mediaTimescale) return false
|
|
@@ -1089,9 +1101,30 @@ interface AwaitingTimingHead {
|
|
|
1089
1101
|
mpuSequenceNumber: number
|
|
1090
1102
|
movieFragmentSequenceNumber: number
|
|
1091
1103
|
sampleNumber: number
|
|
1104
|
+
/** MMTP packet sequence number; a recovered copy carries its source's. */
|
|
1105
|
+
sequenceNumber: number
|
|
1092
1106
|
arrivedAt: number
|
|
1093
1107
|
}
|
|
1094
1108
|
|
|
1109
|
+
/** FEC-recovered heads that went through the awaiting-timing queue. Every
|
|
1110
|
+
* count but `pending` is cumulative. A held head is pending or has left the
|
|
1111
|
+
* queue exactly once, so `held - expired - discarded - superseded - pending`
|
|
1112
|
+
* is the number fed again once their FT=1 landed. That feed can still refuse
|
|
1113
|
+
* one (size, offset, dispose from a frame callback); the refusal is counted in
|
|
1114
|
+
* `feedStats.deferredOutcomes`, not here. */
|
|
1115
|
+
export interface MmtpRecoveredHeadTimingStats {
|
|
1116
|
+
/** Held because their sample's FT=1 had not arrived yet. */
|
|
1117
|
+
held: number
|
|
1118
|
+
/** Left without timing: the reassembler-timeout deadline, or pushed out by the queue's capacity. */
|
|
1119
|
+
expired: number
|
|
1120
|
+
/** Dropped with their stream (track reconfigure, reset or retire) or by dispose(). */
|
|
1121
|
+
discarded: number
|
|
1122
|
+
/** Gave way to their arriving source copy, which takes their place in the queue. */
|
|
1123
|
+
superseded: number
|
|
1124
|
+
/** In the queue now; current occupancy, not cumulative. */
|
|
1125
|
+
pending: number
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1095
1128
|
const MAX_AWAITING_TIMING_HEADS = 8
|
|
1096
1129
|
|
|
1097
1130
|
// ── MmtpContainerParser ─────────────────────────────────────────────
|
|
@@ -1129,6 +1162,9 @@ export class MmtpContainerParser {
|
|
|
1129
1162
|
#reclaimedTimingSamples = 0
|
|
1130
1163
|
#evictedTimingSamples = 0
|
|
1131
1164
|
#rejectedMovieFragments = 0
|
|
1165
|
+
#recoveredHeadsAwaitingTiming: Omit<MmtpRecoveredHeadTimingStats, 'pending'> = {
|
|
1166
|
+
held: 0, expired: 0, discarded: 0, superseded: 0,
|
|
1167
|
+
}
|
|
1132
1168
|
#sequencers = new Map<string, { packetId: number; sequencer: FrameSequencer }>()
|
|
1133
1169
|
#fecGate: FecReleaseGate | null = null
|
|
1134
1170
|
#fecSequencer: FecReleaseSequencer | null = null
|
|
@@ -1346,6 +1382,15 @@ export class MmtpContainerParser {
|
|
|
1346
1382
|
}
|
|
1347
1383
|
}
|
|
1348
1384
|
|
|
1385
|
+
/** Recovered heads parked for FT=1 timing (BLO-53369). Survives dispose(). */
|
|
1386
|
+
get recoveredHeadsAwaitingTiming(): Readonly<MmtpRecoveredHeadTimingStats> {
|
|
1387
|
+
let pending = 0
|
|
1388
|
+
for (const { awaitingTiming } of this.#timingStreams.values()) {
|
|
1389
|
+
for (const head of awaitingTiming) if (head.options?.recovered) pending++
|
|
1390
|
+
}
|
|
1391
|
+
return { ...this.#recoveredHeadsAwaitingTiming, pending }
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1349
1394
|
get reorderStats(): Readonly<MmtpReorderStats> {
|
|
1350
1395
|
let bufferedFrames = this.#fecSequencer?.buffered ?? 0
|
|
1351
1396
|
for (const { sequencer } of this.#sequencers.values()) bufferedFrames += sequencer.buffered
|
|
@@ -1541,7 +1586,7 @@ export class MmtpContainerParser {
|
|
|
1541
1586
|
const now = Date.now()
|
|
1542
1587
|
for (const { awaitingTiming } of this.#timingStreams.values()) {
|
|
1543
1588
|
while (awaitingTiming.length > 0 && now - awaitingTiming[0]!.arrivedAt > timeoutMs) {
|
|
1544
|
-
awaitingTiming.shift()
|
|
1589
|
+
this.#leaveAwaitingTiming(awaitingTiming.shift()!, 'expired')
|
|
1545
1590
|
}
|
|
1546
1591
|
}
|
|
1547
1592
|
}
|
|
@@ -1711,11 +1756,16 @@ export class MmtpContainerParser {
|
|
|
1711
1756
|
if (mpu.fragmentationIndicator !== 0 || mpu.fragmentCounter !== 0)
|
|
1712
1757
|
return this.#drop('fragment-unexpected-fragmentation')
|
|
1713
1758
|
const resolver = this.#timingResolver(streamKey, info.packetId, false)
|
|
1714
|
-
if (!resolver) return this.#drop('fragment-no-resolver')
|
|
1759
|
+
if (!resolver?.initialized) return this.#drop('fragment-no-resolver')
|
|
1715
1760
|
const pendingBefore = resolver.pendingSampleCount
|
|
1716
1761
|
const evictedBefore = resolver.evictedSampleCount
|
|
1717
1762
|
const remaining = this.#maxPendingTimingSamples - this.#pendingTimingSamples
|
|
1718
|
-
const admitted = resolver.addMovieFragment(
|
|
1763
|
+
const admitted = resolver.addMovieFragment(
|
|
1764
|
+
mpu.mpuSequenceNumber,
|
|
1765
|
+
data,
|
|
1766
|
+
remaining,
|
|
1767
|
+
options?.recovered === true,
|
|
1768
|
+
)
|
|
1719
1769
|
// Sync on BOTH paths: the resolver can displace stale timing and still
|
|
1720
1770
|
// refuse the fragment in hand, and a global count that missed those
|
|
1721
1771
|
// freed slots would keep refusing on a budget that is no longer full.
|
|
@@ -1739,7 +1789,11 @@ export class MmtpContainerParser {
|
|
|
1739
1789
|
}
|
|
1740
1790
|
if (fragmentType !== 2) return this.#drop('unknown-fragment-type')
|
|
1741
1791
|
|
|
1742
|
-
|
|
1792
|
+
// An FT=2 before the stream's first FT=0 (a join, a track reset) is taken
|
|
1793
|
+
// like one whose FT=1 has not arrived: a head waits for its timing, a
|
|
1794
|
+
// continuation parks in the reassembler, and a later or FEC-recovered
|
|
1795
|
+
// FT=0/FT=1 can still time them.
|
|
1796
|
+
const resolver = this.#timingResolver(streamKey, info.packetId, true)
|
|
1743
1797
|
if (!resolver) return this.#drop('mfu-no-resolver')
|
|
1744
1798
|
if (!Number.isSafeInteger(data.byteLength) || data.byteLength <= 0)
|
|
1745
1799
|
return this.#drop('mfu-empty-payload')
|
|
@@ -1761,6 +1815,7 @@ export class MmtpContainerParser {
|
|
|
1761
1815
|
mpuSequenceNumber: mpu.mpuSequenceNumber,
|
|
1762
1816
|
movieFragmentSequenceNumber: mfuDuHeader.movieFragmentSequenceNumber,
|
|
1763
1817
|
sampleNumber: mfuDuHeader.sampleNumber,
|
|
1818
|
+
sequenceNumber: info.sequenceNumber,
|
|
1764
1819
|
arrivedAt: Date.now(),
|
|
1765
1820
|
})
|
|
1766
1821
|
return this.#drop('mfu-sample-timing-missing')
|
|
@@ -2228,6 +2283,9 @@ export class MmtpContainerParser {
|
|
|
2228
2283
|
for (const { sequencer } of this.#sequencers.values()) sequencer.discard()
|
|
2229
2284
|
this.#sequencers.clear()
|
|
2230
2285
|
this.#fecSequencer?.discard()
|
|
2286
|
+
for (const { awaitingTiming } of this.#timingStreams.values()) {
|
|
2287
|
+
for (const head of awaitingTiming) this.#leaveAwaitingTiming(head, 'discarded')
|
|
2288
|
+
}
|
|
2231
2289
|
this.#timingStreams.clear()
|
|
2232
2290
|
this.#pendingTimingSamples = 0
|
|
2233
2291
|
this.#reliableMoqStartupGate.clear()
|
|
@@ -2253,10 +2311,27 @@ export class MmtpContainerParser {
|
|
|
2253
2311
|
}
|
|
2254
2312
|
|
|
2255
2313
|
#awaitTiming(streamKey: string, head: AwaitingTimingHead): void {
|
|
2256
|
-
const
|
|
2257
|
-
if (!
|
|
2314
|
+
const state = this.#timingStreams.get(streamKey)
|
|
2315
|
+
if (!state) return
|
|
2316
|
+
// The source copy of a held recovered head takes its place, so the frame
|
|
2317
|
+
// is delivered with source provenance once FT=1 lands.
|
|
2318
|
+
if (!head.options?.recovered) {
|
|
2319
|
+
const kept = state.awaitingTiming.filter(queued =>
|
|
2320
|
+
!queued.options?.recovered || queued.sequenceNumber !== head.sequenceNumber)
|
|
2321
|
+
this.#recoveredHeadsAwaitingTiming.superseded += state.awaitingTiming.length - kept.length
|
|
2322
|
+
state.awaitingTiming = kept
|
|
2323
|
+
}
|
|
2324
|
+
const awaitingTiming = state.awaitingTiming
|
|
2258
2325
|
awaitingTiming.push(head)
|
|
2259
|
-
if (
|
|
2326
|
+
if (head.options?.recovered) this.#recoveredHeadsAwaitingTiming.held++
|
|
2327
|
+
if (awaitingTiming.length > MAX_AWAITING_TIMING_HEADS) {
|
|
2328
|
+
this.#leaveAwaitingTiming(awaitingTiming.shift()!, 'expired')
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
/** Count a recovered head leaving the queue other than by replay or its source copy. */
|
|
2333
|
+
#leaveAwaitingTiming(head: AwaitingTimingHead, outcome: 'expired' | 'discarded'): void {
|
|
2334
|
+
if (head.options?.recovered) this.#recoveredHeadsAwaitingTiming[outcome]++
|
|
2260
2335
|
}
|
|
2261
2336
|
|
|
2262
2337
|
/**
|
|
@@ -2286,6 +2361,7 @@ export class MmtpContainerParser {
|
|
|
2286
2361
|
const state = this.#timingStreams.get(streamKey)
|
|
2287
2362
|
if (!state) return
|
|
2288
2363
|
this.#timingStreams.delete(streamKey)
|
|
2364
|
+
for (const head of state.awaitingTiming) this.#leaveAwaitingTiming(head, 'discarded')
|
|
2289
2365
|
this.#pendingTimingSamples -= state.resolver.pendingSampleCount
|
|
2290
2366
|
if (this.#pendingTimingSamples < 0) {
|
|
2291
2367
|
throw new Error('[MmtpContainerParser] pending timing sample accounting underflow')
|
package/src/mmtp-router.ts
CHANGED
|
@@ -48,10 +48,12 @@ import {
|
|
|
48
48
|
} from "./cmaf-block-assembler.js";
|
|
49
49
|
import {
|
|
50
50
|
MmtpContainerParser,
|
|
51
|
+
authoredAdjacent,
|
|
51
52
|
isMfuKeyframeMetadata,
|
|
52
53
|
type FecReleaseGate,
|
|
53
54
|
type FrameMeta as ParserFrameMeta,
|
|
54
55
|
type ParsedMmtpPacket,
|
|
56
|
+
type MmtpRecoveredHeadTimingStats,
|
|
55
57
|
} from "./mmtp-container-parser.js";
|
|
56
58
|
import { type TrackRole } from "./carp-catalog.js";
|
|
57
59
|
import {
|
|
@@ -511,6 +513,9 @@ export type RoutedPacketMeta = {
|
|
|
511
513
|
mmtpTimestamp: bigint;
|
|
512
514
|
/**
|
|
513
515
|
* The MMTP header RAP flag: the packet starts a random access point. On a
|
|
516
|
+
* track under a keyframe overlay it is also set on the FT=0 and FT=1 data
|
|
517
|
+
* units that govern a covered keyframe (draft-ramadan-moq-mmt-02 7.4.2), so
|
|
518
|
+
* it can precede the keyframe's first MFU fragment. On a
|
|
514
519
|
* `recovered` packet it is read from the FEC-reconstructed header, which is
|
|
515
520
|
* byte-exact outside packet_sequence_number, so it is the flag the
|
|
516
521
|
* publisher sent. The router's own gap decisions deliberately ignore it for
|
|
@@ -1400,7 +1405,7 @@ export class MmtpRouter {
|
|
|
1400
1405
|
const cb = this.#getFrameCallback(packetId);
|
|
1401
1406
|
if (isVideo && !assembler) {
|
|
1402
1407
|
if (frameMeta.recovered) {
|
|
1403
|
-
this.#routeDirectRecoveredVideo(packetId, data, frameMeta, cb);
|
|
1408
|
+
this.#routeDirectRecoveredVideo(packetId, data, frameMeta, cb, meta.fecSbn);
|
|
1404
1409
|
} else {
|
|
1405
1410
|
this.#routeDirectSourceVideo(packetId, data, frameMeta, cb);
|
|
1406
1411
|
}
|
|
@@ -1915,6 +1920,11 @@ export class MmtpRouter {
|
|
|
1915
1920
|
return { ...this.#overlayStats };
|
|
1916
1921
|
}
|
|
1917
1922
|
|
|
1923
|
+
/** Recovered FT=2 heads held for their FT=1, and how each left the wait (BLO-53369). */
|
|
1924
|
+
get recoveredHeadsAwaitingTiming(): Readonly<MmtpRecoveredHeadTimingStats> {
|
|
1925
|
+
return this.#parser.recoveredHeadsAwaitingTiming;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1918
1928
|
#isOverlayRepair(repairPacketId: number, repairTrackName?: string): boolean {
|
|
1919
1929
|
return this.#overlayLegs.has(repairPacketId) ||
|
|
1920
1930
|
(repairTrackName !== undefined && this.#overlayRepairNames.has(repairTrackName));
|
|
@@ -4817,8 +4827,28 @@ export class MmtpRouter {
|
|
|
4817
4827
|
data: Uint8Array,
|
|
4818
4828
|
meta: RoutedFrameMeta,
|
|
4819
4829
|
cb: FrameCallback | null,
|
|
4830
|
+
/** The base SS_ID the FEC release gate ordered the frame by, if any. */
|
|
4831
|
+
fecSbn: number | undefined,
|
|
4820
4832
|
): void {
|
|
4821
4833
|
const streamKey = this.#mediaStreamKey(packetId, meta.catalogTrackName);
|
|
4834
|
+
// The FEC release gate let this frame out in order: only once every
|
|
4835
|
+
// block between it and the frame before it resolved, or because it
|
|
4836
|
+
// starts where that frame ended. Nothing still in flight can precede it
|
|
4837
|
+
// then, so it plays now, as a source frame does; holding it for the next
|
|
4838
|
+
// intact frame cost every repaired burst its length in decoder input
|
|
4839
|
+
// (BLO-52085). An overlay recovery names no base block for the gate to
|
|
4840
|
+
// wait on, so past a gap it may be ahead of frames still in flight (an
|
|
4841
|
+
// overlay that outran the wire, BLO-53342): it keeps waiting for the
|
|
4842
|
+
// source cursor below, unless it starts where the last emitted frame
|
|
4843
|
+
// ended (on this direct path, the delivered high water).
|
|
4844
|
+
if (this.#isFecTrack(packetId)) {
|
|
4845
|
+
const high = this.#deliveredVideoHighWaterByStream.get(streamKey);
|
|
4846
|
+
if (fecSbn !== undefined || (high !== undefined && authoredAdjacent(high, meta))) {
|
|
4847
|
+
this.#drainDirectRecoveredVideo(streamKey, packetId, frame => frame.timestamp <= meta.timestamp, cb);
|
|
4848
|
+
this.#emitDirectVideo(packetId, data, meta, cb);
|
|
4849
|
+
return;
|
|
4850
|
+
}
|
|
4851
|
+
}
|
|
4822
4852
|
this.#enqueueDirectRecoveredVideo(packetId, data, meta);
|
|
4823
4853
|
const lastSourceTs = this.#lastDirectSourceVideoTsByStream.get(streamKey);
|
|
4824
4854
|
this.#drainDirectRecoveredVideo(
|
|
@@ -5209,23 +5239,39 @@ export class MmtpRouter {
|
|
|
5209
5239
|
// A recovered head whose sample FT=1 has not named yet (an overlay that
|
|
5210
5240
|
// outran the source path) would complete its data unit untimed: the unit
|
|
5211
5241
|
// is dropped, marked completed, and the timed source copy is then refused
|
|
5212
|
-
// (BLO-53342).
|
|
5213
|
-
//
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
|
|
5221
|
-
|
|
5222
|
-
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5242
|
+
// (BLO-53342). Hand it to the parser instead, which holds it in its
|
|
5243
|
+
// awaiting-timing queue and feeds it again when FT=1 lands; its recovered
|
|
5244
|
+
// continuations park in the reassembler meanwhile. If the source path
|
|
5245
|
+
// lost the head, this held copy is the keyframe (BLO-53369).
|
|
5246
|
+
const untimedHead = (sym: (typeof result.symbols)[number]): boolean =>
|
|
5247
|
+
sym.fragmentType === 2 &&
|
|
5248
|
+
sym.fragment.fragmentationIndicator === 1 &&
|
|
5249
|
+
sym.fragment.movieFragmentSequenceNumber !== undefined &&
|
|
5250
|
+
sym.fragment.sampleNumber !== undefined &&
|
|
5251
|
+
!this.#parser.hasAuthoredTimingFor(
|
|
5252
|
+
ownerPacketId,
|
|
5253
|
+
sym.fragment.mpuSequenceNumber,
|
|
5254
|
+
sym.fragment.movieFragmentSequenceNumber,
|
|
5255
|
+
sym.fragment.sampleNumber,
|
|
5256
|
+
catalogTrackName,
|
|
5257
|
+
);
|
|
5258
|
+
for (const sym of result.symbols) {
|
|
5259
|
+
if (!untimedHead(sym) ||
|
|
5260
|
+
this.#sourceSeqReceived(ownerPacketId, sym.packetSequenceNumber, catalogTrackName)) continue;
|
|
5261
|
+
this.#feedViaParser(
|
|
5262
|
+
sym.rawSymbol,
|
|
5263
|
+
{ packetId: ownerPacketId, sequenceNumber: sym.packetSequenceNumber },
|
|
5264
|
+
undefined,
|
|
5265
|
+
{
|
|
5266
|
+
skipFecPayloadId: true,
|
|
5267
|
+
recovered: true,
|
|
5268
|
+
deliveryProvenance,
|
|
5269
|
+
...fecSbnOf(sym.index),
|
|
5270
|
+
...(catalogTrackName === undefined ? {} : { catalogTrackName }),
|
|
5271
|
+
},
|
|
5272
|
+
);
|
|
5273
|
+
}
|
|
5274
|
+
result = { ...result, symbols: result.symbols.filter((sym) => !untimedHead(sym)) };
|
|
5229
5275
|
|
|
5230
5276
|
// MSE assembler path: route recovered block as pre-assembled frames (skip FT=0)
|
|
5231
5277
|
// BLO-3035 layer 3: recovered symbols are RAW MFU fragments, not complete
|