@blockcast/mmt-container 2.2.0-main.73af0eedcbc8 → 2.2.0-main.75b39a2cdddf
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/README.md +29 -17
- package/dist/browser-runtime.js +1001 -215
- package/dist/browser-runtime.js.map +4 -4
- package/dist/carp-catalog.js.map +1 -1
- package/dist/catalog-fec-params.d.ts +17 -1
- package/dist/catalog-fec-params.d.ts.map +1 -1
- package/dist/catalog-fec-params.js +15 -0
- package/dist/catalog-fec-params.js.map +2 -2
- package/dist/catalog-network-source.js.map +1 -1
- package/dist/cmcd-producer.d.ts +60 -47
- package/dist/cmcd-producer.d.ts.map +1 -1
- package/dist/cmcd-producer.js +138 -109
- package/dist/cmcd-producer.js.map +2 -2
- package/dist/delivery-catalog.js +26 -16
- package/dist/delivery-catalog.js.map +2 -2
- package/dist/delta-fallback-config.js.map +1 -1
- package/dist/fec-manager.d.ts +33 -1
- package/dist/fec-manager.d.ts.map +1 -1
- package/dist/fec-manager.js +222 -6
- package/dist/fec-manager.js.map +2 -2
- package/dist/fec-state.js.map +1 -1
- package/dist/fec-worker-setup.d.ts +1 -1
- package/dist/fec-worker-setup.js +1 -1
- package/dist/fec-worker-setup.js.map +1 -1
- package/dist/held-layer-admission.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -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/media-router.js.map +1 -1
- package/dist/mfu-reassembler.js +12 -19
- package/dist/mfu-reassembler.js.map +2 -2
- package/dist/mmtp-container-parser.d.ts +58 -10
- package/dist/mmtp-container-parser.d.ts.map +1 -1
- package/dist/mmtp-container-parser.js +105 -34
- package/dist/mmtp-container-parser.js.map +2 -2
- package/dist/mmtp-container-parser.no-modules.js +229 -51
- package/dist/mmtp-container-parser.no-modules.js.map +3 -3
- package/dist/mmtp-decode-pipeline.js +104 -34
- package/dist/mmtp-decode-pipeline.js.map +2 -2
- package/dist/mmtp-fec-session.d.ts +41 -3
- package/dist/mmtp-fec-session.d.ts.map +1 -1
- package/dist/mmtp-fec-session.js +118 -13
- package/dist/mmtp-fec-session.js.map +3 -3
- package/dist/mmtp-router.d.ts +18 -1
- package/dist/mmtp-router.d.ts.map +1 -1
- package/dist/mmtp-router.js +323 -64
- package/dist/mmtp-router.js.map +2 -2
- package/dist/multicast-auth-catalog.js.map +1 -1
- package/dist/multicast-input.js.map +1 -1
- package/dist/multicast-manager.js +547 -72
- package/dist/multicast-manager.js.map +2 -2
- package/dist/multicast-transport.js +28 -18
- package/dist/multicast-transport.js.map +2 -2
- package/dist/multicast-unwrap.js +12 -19
- package/dist/multicast-unwrap.js.map +2 -2
- package/dist/no-modules.js +222 -48
- package/dist/no-modules.js.map +3 -3
- package/dist/repair-fetch.js.map +1 -1
- package/dist/repair-layer-subscriptions.d.ts +82 -0
- package/dist/repair-layer-subscriptions.d.ts.map +1 -0
- package/dist/repair-layer-subscriptions.js +192 -0
- package/dist/repair-layer-subscriptions.js.map +7 -0
- package/dist/signaling.js.map +1 -1
- package/dist/timestamp-codec.js +12 -19
- package/dist/timestamp-codec.js.map +2 -2
- package/dist/track-session.d.ts +1 -1
- package/dist/track-session.js.map +1 -1
- package/dist/wasm-mfu-reassembler.js.map +1 -1
- package/dist/wasm-mmtp-container-parser.js +104 -34
- package/dist/wasm-mmtp-container-parser.js.map +2 -2
- package/package.json +8 -5
- package/schemas/catalog.schema.json +4 -0
- package/src/catalog-fec-params.ts +36 -1
- package/src/cmcd-producer.ts +189 -179
- package/src/fec-manager.ts +277 -6
- package/src/fec-worker-setup.ts +2 -2
- package/src/index.ts +18 -2
- package/src/iso-block-locator.ts +19 -0
- package/src/iso-bmff-timing.ts +40 -1
- package/src/mmtp-container-parser.ts +125 -19
- package/src/mmtp-fec-session.ts +115 -15
- package/src/mmtp-router.ts +350 -13
- package/src/repair-layer-subscriptions.ts +261 -0
- package/src/track-session.ts +1 -1
|
@@ -248,7 +248,10 @@ export interface FecReleaseGate {
|
|
|
248
248
|
export interface MmtpTimingBudgetStats {
|
|
249
249
|
/** Slots currently charged to declared-but-undelivered samples. */
|
|
250
250
|
pending: number
|
|
251
|
-
/**
|
|
251
|
+
/**
|
|
252
|
+
* Slots each stream's window holds (`maxBufferedMfus`). A stream at
|
|
253
|
+
* capacity displaces its own oldest timing; `pending` sums every stream.
|
|
254
|
+
*/
|
|
252
255
|
capacity: number
|
|
253
256
|
/** Slots returned by samples the combiner abandoned (evicted or timed out). */
|
|
254
257
|
reclaimed: number
|
|
@@ -259,7 +262,13 @@ export interface MmtpTimingBudgetStats {
|
|
|
259
262
|
* up, `evicted` is loss that was never observable at all.
|
|
260
263
|
*/
|
|
261
264
|
evicted: number
|
|
262
|
-
/**
|
|
265
|
+
/**
|
|
266
|
+
* Movie fragments refused because the budget had no room for their samples.
|
|
267
|
+
* Stays 0 with a window per stream (BLO-52085): a stream at capacity
|
|
268
|
+
* displaces its own oldest timing to make room, and a moof declaring more
|
|
269
|
+
* samples than a whole window holds is refused as invalid before any budget
|
|
270
|
+
* is consulted. Kept as a guard, and because it is public.
|
|
271
|
+
*/
|
|
263
272
|
rejectedMovieFragments: number
|
|
264
273
|
}
|
|
265
274
|
|
|
@@ -365,10 +374,22 @@ export type MmtpFeedDropReason =
|
|
|
365
374
|
/** No timing resolver, or it refused the initialization segment. */
|
|
366
375
|
| 'init-timing-refused'
|
|
367
376
|
| 'fragment-unexpected-fragmentation'
|
|
377
|
+
/** FT=1 before an FT=0 has bound the stream's timing track. */
|
|
368
378
|
| 'fragment-no-resolver'
|
|
369
|
-
/** addMovieFragment refused
|
|
379
|
+
/** addMovieFragment refused: the moof does not parse against the stream's
|
|
380
|
+
* track, conflicts with a pending copy of itself, or repeats a pending
|
|
381
|
+
* copy whose samples are partly consumed. Never the timing budget: a full
|
|
382
|
+
* window displaces the stream's own oldest timing. */
|
|
370
383
|
| 'fragment-timing-refused'
|
|
371
384
|
| 'unknown-fragment-type'
|
|
385
|
+
/** Stable code, no longer produced: an FT=2 with no timing (before the
|
|
386
|
+
* stream's first FT=0, or after setTrack dropped it) now waits for its
|
|
387
|
+
* timing like one whose FT=1 has not arrived. A head counts as
|
|
388
|
+
* `drop:mfu-sample-timing-missing` (parked) and a FI=2/FI=3 continuation
|
|
389
|
+
* as the `pending` record. A continuation that completes a head the
|
|
390
|
+
* reassembler buffered before the timing was dropped counts as
|
|
391
|
+
* `drop:assembled-timing-missing`. A panel keyed on this code reads zero:
|
|
392
|
+
* a re-attribution, not lost coverage. Re-key on both drop codes. */
|
|
372
393
|
| 'mfu-no-resolver'
|
|
373
394
|
| 'mfu-empty-payload'
|
|
374
395
|
| 'mfu-du-header-missing'
|
|
@@ -679,7 +700,10 @@ class FrameSequencer {
|
|
|
679
700
|
// ── FecReleaseSequencer ─────────────────────────────────────────────
|
|
680
701
|
|
|
681
702
|
/** Authored decode-time adjacency: the next frame starts where this one ends. */
|
|
682
|
-
function authoredAdjacent(
|
|
703
|
+
export function authoredAdjacent(
|
|
704
|
+
before: Pick<FrameMeta, 'authoredTiming'>,
|
|
705
|
+
after: Pick<FrameMeta, 'authoredTiming'>,
|
|
706
|
+
): boolean {
|
|
683
707
|
const a = before.authoredTiming
|
|
684
708
|
const b = after.authoredTiming
|
|
685
709
|
if (!a || !b || a.mediaTimescale !== b.mediaTimescale) return false
|
|
@@ -1089,9 +1113,30 @@ interface AwaitingTimingHead {
|
|
|
1089
1113
|
mpuSequenceNumber: number
|
|
1090
1114
|
movieFragmentSequenceNumber: number
|
|
1091
1115
|
sampleNumber: number
|
|
1116
|
+
/** MMTP packet sequence number; a recovered copy carries its source's. */
|
|
1117
|
+
sequenceNumber: number
|
|
1092
1118
|
arrivedAt: number
|
|
1093
1119
|
}
|
|
1094
1120
|
|
|
1121
|
+
/** FEC-recovered heads that went through the awaiting-timing queue. Every
|
|
1122
|
+
* count but `pending` is cumulative. A held head is pending or has left the
|
|
1123
|
+
* queue exactly once, so `held - expired - discarded - superseded - pending`
|
|
1124
|
+
* is the number fed again once their FT=1 landed. That feed can still refuse
|
|
1125
|
+
* one (size, offset, dispose from a frame callback); the refusal is counted in
|
|
1126
|
+
* `feedStats.deferredOutcomes`, not here. */
|
|
1127
|
+
export interface MmtpRecoveredHeadTimingStats {
|
|
1128
|
+
/** Held because their sample's FT=1 had not arrived yet. */
|
|
1129
|
+
held: number
|
|
1130
|
+
/** Left without timing: the reassembler-timeout deadline, or pushed out by the queue's capacity. */
|
|
1131
|
+
expired: number
|
|
1132
|
+
/** Dropped with their stream (track reconfigure, reset or retire) or by dispose(). */
|
|
1133
|
+
discarded: number
|
|
1134
|
+
/** Gave way to their arriving source copy, which takes their place in the queue. */
|
|
1135
|
+
superseded: number
|
|
1136
|
+
/** In the queue now; current occupancy, not cumulative. */
|
|
1137
|
+
pending: number
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1095
1140
|
const MAX_AWAITING_TIMING_HEADS = 8
|
|
1096
1141
|
|
|
1097
1142
|
// ── MmtpContainerParser ─────────────────────────────────────────────
|
|
@@ -1129,6 +1174,9 @@ export class MmtpContainerParser {
|
|
|
1129
1174
|
#reclaimedTimingSamples = 0
|
|
1130
1175
|
#evictedTimingSamples = 0
|
|
1131
1176
|
#rejectedMovieFragments = 0
|
|
1177
|
+
#recoveredHeadsAwaitingTiming: Omit<MmtpRecoveredHeadTimingStats, 'pending'> = {
|
|
1178
|
+
held: 0, expired: 0, discarded: 0, superseded: 0,
|
|
1179
|
+
}
|
|
1132
1180
|
#sequencers = new Map<string, { packetId: number; sequencer: FrameSequencer }>()
|
|
1133
1181
|
#fecGate: FecReleaseGate | null = null
|
|
1134
1182
|
#fecSequencer: FecReleaseSequencer | null = null
|
|
@@ -1330,11 +1378,12 @@ export class MmtpContainerParser {
|
|
|
1330
1378
|
/**
|
|
1331
1379
|
* Typed view of the ISO-BMFF timing-sample budget.
|
|
1332
1380
|
*
|
|
1333
|
-
*
|
|
1334
|
-
*
|
|
1335
|
-
*
|
|
1336
|
-
*
|
|
1337
|
-
*
|
|
1381
|
+
* Each stream admits timing against its own window of `capacity` slots
|
|
1382
|
+
* (BLO-52085). A stream at capacity displaces its own oldest timing to
|
|
1383
|
+
* admit the moof in hand, and `evicted` counts the slots so displaced:
|
|
1384
|
+
* timing a stream declared and never delivered shows up there. `pending`
|
|
1385
|
+
* sums every stream, so it can exceed `capacity`. Per repo policy this is
|
|
1386
|
+
* a producer-owned counter -- never infer budget state by parsing logs.
|
|
1338
1387
|
*/
|
|
1339
1388
|
get timingBudgetStats(): Readonly<MmtpTimingBudgetStats> {
|
|
1340
1389
|
return {
|
|
@@ -1346,6 +1395,15 @@ export class MmtpContainerParser {
|
|
|
1346
1395
|
}
|
|
1347
1396
|
}
|
|
1348
1397
|
|
|
1398
|
+
/** Recovered heads parked for FT=1 timing (BLO-53369). Survives dispose(). */
|
|
1399
|
+
get recoveredHeadsAwaitingTiming(): Readonly<MmtpRecoveredHeadTimingStats> {
|
|
1400
|
+
let pending = 0
|
|
1401
|
+
for (const { awaitingTiming } of this.#timingStreams.values()) {
|
|
1402
|
+
for (const head of awaitingTiming) if (head.options?.recovered) pending++
|
|
1403
|
+
}
|
|
1404
|
+
return { ...this.#recoveredHeadsAwaitingTiming, pending }
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1349
1407
|
get reorderStats(): Readonly<MmtpReorderStats> {
|
|
1350
1408
|
let bufferedFrames = this.#fecSequencer?.buffered ?? 0
|
|
1351
1409
|
for (const { sequencer } of this.#sequencers.values()) bufferedFrames += sequencer.buffered
|
|
@@ -1541,7 +1599,7 @@ export class MmtpContainerParser {
|
|
|
1541
1599
|
const now = Date.now()
|
|
1542
1600
|
for (const { awaitingTiming } of this.#timingStreams.values()) {
|
|
1543
1601
|
while (awaitingTiming.length > 0 && now - awaitingTiming[0]!.arrivedAt > timeoutMs) {
|
|
1544
|
-
awaitingTiming.shift()
|
|
1602
|
+
this.#leaveAwaitingTiming(awaitingTiming.shift()!, 'expired')
|
|
1545
1603
|
}
|
|
1546
1604
|
}
|
|
1547
1605
|
}
|
|
@@ -1711,17 +1769,27 @@ export class MmtpContainerParser {
|
|
|
1711
1769
|
if (mpu.fragmentationIndicator !== 0 || mpu.fragmentCounter !== 0)
|
|
1712
1770
|
return this.#drop('fragment-unexpected-fragmentation')
|
|
1713
1771
|
const resolver = this.#timingResolver(streamKey, info.packetId, false)
|
|
1714
|
-
if (!resolver) return this.#drop('fragment-no-resolver')
|
|
1772
|
+
if (!resolver?.initialized) return this.#drop('fragment-no-resolver')
|
|
1715
1773
|
const pendingBefore = resolver.pendingSampleCount
|
|
1716
1774
|
const evictedBefore = resolver.evictedSampleCount
|
|
1717
|
-
|
|
1718
|
-
|
|
1775
|
+
// Each stream's window is the whole budget. A resolver can displace only
|
|
1776
|
+
// its own timing, so a pool shared across streams let one stream's
|
|
1777
|
+
// unconsumed slots refuse another stream's moofs with nothing to
|
|
1778
|
+
// displace: BLO-52085 G7b, overlay-rebuilt video keyframes refusing audio.
|
|
1779
|
+
const remaining = this.#maxPendingTimingSamples - resolver.pendingSampleCount
|
|
1780
|
+
const admitted = resolver.addMovieFragment(
|
|
1781
|
+
mpu.mpuSequenceNumber,
|
|
1782
|
+
data,
|
|
1783
|
+
remaining,
|
|
1784
|
+
options?.recovered === true,
|
|
1785
|
+
)
|
|
1719
1786
|
// Sync on BOTH paths: the resolver can displace stale timing and still
|
|
1720
|
-
// refuse the fragment in hand, and
|
|
1721
|
-
// freed slots would keep refusing on a budget that is no longer full.
|
|
1787
|
+
// refuse the fragment in hand, and the total must count those freed slots.
|
|
1722
1788
|
this.#pendingTimingSamples += resolver.pendingSampleCount - pendingBefore
|
|
1723
1789
|
this.#evictedTimingSamples += resolver.evictedSampleCount - evictedBefore
|
|
1724
1790
|
if (!admitted) {
|
|
1791
|
+
// Never 'budget' with a window per stream (see
|
|
1792
|
+
// `rejectedMovieFragments`); kept as a guard.
|
|
1725
1793
|
if (resolver.lastMovieFragmentRejection === 'budget') {
|
|
1726
1794
|
this.#rejectedMovieFragments++
|
|
1727
1795
|
}
|
|
@@ -1739,7 +1807,11 @@ export class MmtpContainerParser {
|
|
|
1739
1807
|
}
|
|
1740
1808
|
if (fragmentType !== 2) return this.#drop('unknown-fragment-type')
|
|
1741
1809
|
|
|
1742
|
-
|
|
1810
|
+
// An FT=2 before the stream's first FT=0 (a join, a track reset) is taken
|
|
1811
|
+
// like one whose FT=1 has not arrived: a head waits for its timing, a
|
|
1812
|
+
// continuation parks in the reassembler, and a later or FEC-recovered
|
|
1813
|
+
// FT=0/FT=1 can still time them.
|
|
1814
|
+
const resolver = this.#timingResolver(streamKey, info.packetId, true)
|
|
1743
1815
|
if (!resolver) return this.#drop('mfu-no-resolver')
|
|
1744
1816
|
if (!Number.isSafeInteger(data.byteLength) || data.byteLength <= 0)
|
|
1745
1817
|
return this.#drop('mfu-empty-payload')
|
|
@@ -1761,6 +1833,7 @@ export class MmtpContainerParser {
|
|
|
1761
1833
|
mpuSequenceNumber: mpu.mpuSequenceNumber,
|
|
1762
1834
|
movieFragmentSequenceNumber: mfuDuHeader.movieFragmentSequenceNumber,
|
|
1763
1835
|
sampleNumber: mfuDuHeader.sampleNumber,
|
|
1836
|
+
sequenceNumber: info.sequenceNumber,
|
|
1764
1837
|
arrivedAt: Date.now(),
|
|
1765
1838
|
})
|
|
1766
1839
|
return this.#drop('mfu-sample-timing-missing')
|
|
@@ -2146,6 +2219,18 @@ export class MmtpContainerParser {
|
|
|
2146
2219
|
this.#emitFrame(data, meta)
|
|
2147
2220
|
}
|
|
2148
2221
|
|
|
2222
|
+
/** Whether FT=1 metadata already names this sample, without claiming it. */
|
|
2223
|
+
hasAuthoredTimingFor(
|
|
2224
|
+
packetId: number,
|
|
2225
|
+
mpuSequenceNumber: number,
|
|
2226
|
+
movieFragmentSequenceNumber: number,
|
|
2227
|
+
sampleNumber: number,
|
|
2228
|
+
catalogTrackName?: string,
|
|
2229
|
+
): boolean {
|
|
2230
|
+
return this.#timingResolver(this.#streamKey(packetId, catalogTrackName), packetId, false)
|
|
2231
|
+
?.sampleTiming(mpuSequenceNumber, movieFragmentSequenceNumber, sampleNumber) != null
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2149
2234
|
/**
|
|
2150
2235
|
* Claim and return the authored timing for a recovered sample. Recovery does
|
|
2151
2236
|
* not pass through the ordinary `feed()` completion path, so it must retire
|
|
@@ -2216,6 +2301,9 @@ export class MmtpContainerParser {
|
|
|
2216
2301
|
for (const { sequencer } of this.#sequencers.values()) sequencer.discard()
|
|
2217
2302
|
this.#sequencers.clear()
|
|
2218
2303
|
this.#fecSequencer?.discard()
|
|
2304
|
+
for (const { awaitingTiming } of this.#timingStreams.values()) {
|
|
2305
|
+
for (const head of awaitingTiming) this.#leaveAwaitingTiming(head, 'discarded')
|
|
2306
|
+
}
|
|
2219
2307
|
this.#timingStreams.clear()
|
|
2220
2308
|
this.#pendingTimingSamples = 0
|
|
2221
2309
|
this.#reliableMoqStartupGate.clear()
|
|
@@ -2241,10 +2329,27 @@ export class MmtpContainerParser {
|
|
|
2241
2329
|
}
|
|
2242
2330
|
|
|
2243
2331
|
#awaitTiming(streamKey: string, head: AwaitingTimingHead): void {
|
|
2244
|
-
const
|
|
2245
|
-
if (!
|
|
2332
|
+
const state = this.#timingStreams.get(streamKey)
|
|
2333
|
+
if (!state) return
|
|
2334
|
+
// The source copy of a held recovered head takes its place, so the frame
|
|
2335
|
+
// is delivered with source provenance once FT=1 lands.
|
|
2336
|
+
if (!head.options?.recovered) {
|
|
2337
|
+
const kept = state.awaitingTiming.filter(queued =>
|
|
2338
|
+
!queued.options?.recovered || queued.sequenceNumber !== head.sequenceNumber)
|
|
2339
|
+
this.#recoveredHeadsAwaitingTiming.superseded += state.awaitingTiming.length - kept.length
|
|
2340
|
+
state.awaitingTiming = kept
|
|
2341
|
+
}
|
|
2342
|
+
const awaitingTiming = state.awaitingTiming
|
|
2246
2343
|
awaitingTiming.push(head)
|
|
2247
|
-
if (
|
|
2344
|
+
if (head.options?.recovered) this.#recoveredHeadsAwaitingTiming.held++
|
|
2345
|
+
if (awaitingTiming.length > MAX_AWAITING_TIMING_HEADS) {
|
|
2346
|
+
this.#leaveAwaitingTiming(awaitingTiming.shift()!, 'expired')
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
/** Count a recovered head leaving the queue other than by replay or its source copy. */
|
|
2351
|
+
#leaveAwaitingTiming(head: AwaitingTimingHead, outcome: 'expired' | 'discarded'): void {
|
|
2352
|
+
if (head.options?.recovered) this.#recoveredHeadsAwaitingTiming[outcome]++
|
|
2248
2353
|
}
|
|
2249
2354
|
|
|
2250
2355
|
/**
|
|
@@ -2274,6 +2379,7 @@ export class MmtpContainerParser {
|
|
|
2274
2379
|
const state = this.#timingStreams.get(streamKey)
|
|
2275
2380
|
if (!state) return
|
|
2276
2381
|
this.#timingStreams.delete(streamKey)
|
|
2382
|
+
for (const head of state.awaitingTiming) this.#leaveAwaitingTiming(head, 'discarded')
|
|
2277
2383
|
this.#pendingTimingSamples -= state.resolver.pendingSampleCount
|
|
2278
2384
|
if (this.#pendingTimingSamples < 0) {
|
|
2279
2385
|
throw new Error('[MmtpContainerParser] pending timing sample accounting underflow')
|
package/src/mmtp-fec-session.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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.#
|
|
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)})`)
|