@blockcast/mmt-container 2.1.0 → 2.2.0-main.050b52fb9b00
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -17
- package/dist/browser-runtime.js +1949 -374
- 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 +65 -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 +70 -12
- package/dist/fec-manager.d.ts.map +1 -1
- package/dist/fec-manager.js +403 -58
- 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 +6 -5
- 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 +48 -13
- package/dist/iso-block-locator.d.ts.map +1 -1
- package/dist/iso-block-locator.js +97 -25
- 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 +79 -10
- package/dist/mmtp-container-parser.d.ts.map +1 -1
- package/dist/mmtp-container-parser.js +219 -45
- package/dist/mmtp-container-parser.js.map +2 -2
- package/dist/mmtp-container-parser.no-modules.js +342 -63
- package/dist/mmtp-container-parser.no-modules.js.map +3 -3
- package/dist/mmtp-decode-pipeline.js +218 -45
- 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 +43 -9
- package/dist/mmtp-router.d.ts.map +1 -1
- package/dist/mmtp-router.js +2006 -95
- package/dist/mmtp-router.js.map +4 -4
- package/dist/multicast-auth-catalog.js.map +1 -1
- package/dist/multicast-input.js.map +1 -1
- package/dist/multicast-manager.d.ts +28 -5
- package/dist/multicast-manager.d.ts.map +1 -1
- package/dist/multicast-manager.js +16116 -15005
- package/dist/multicast-manager.js.map +4 -4
- package/dist/multicast-transport.d.ts +11 -3
- package/dist/multicast-transport.d.ts.map +1 -1
- package/dist/multicast-transport.js +281 -35
- package/dist/multicast-transport.js.map +4 -4
- package/dist/multicast-unwrap.js +12 -19
- package/dist/multicast-unwrap.js.map +2 -2
- package/dist/no-modules.js +336 -59
- package/dist/no-modules.js.map +3 -3
- package/dist/repair-fetch.d.ts +36 -8
- package/dist/repair-fetch.d.ts.map +1 -1
- package/dist/repair-fetch.js +285 -26
- package/dist/repair-fetch.js.map +3 -3
- 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 +2 -2
- 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.d.ts.map +1 -1
- package/dist/track-session.js +1 -0
- package/dist/track-session.js.map +2 -2
- package/dist/wasm-mfu-reassembler.js.map +1 -1
- package/dist/wasm-mmtp-container-parser.js +218 -45
- 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 +194 -179
- package/src/fec-manager.ts +433 -32
- package/src/fec-worker-setup.ts +2 -2
- package/src/index.ts +20 -2
- package/src/iso-block-locator.ts +133 -31
- package/src/iso-bmff-timing.ts +40 -1
- package/src/mmtp-container-parser.ts +306 -29
- package/src/mmtp-fec-session.ts +115 -15
- package/src/mmtp-router.ts +629 -54
- package/src/multicast-manager.ts +70 -11
- package/src/multicast-transport.ts +44 -20
- package/src/repair-fetch.ts +363 -49
- package/src/repair-layer-subscriptions.ts +261 -0
- package/src/track-session.ts +4 -1
|
@@ -173,6 +173,15 @@ export interface FrameMeta {
|
|
|
173
173
|
* on FEC-gated streams.
|
|
174
174
|
*/
|
|
175
175
|
fecSbn?: number
|
|
176
|
+
/**
|
|
177
|
+
* Set on a frame the keyframe overlay rebuilt (draft-ramadan-moq-mmt-02
|
|
178
|
+
* 7.4) whose base SS_IDs are determined: the lowest base SS_ID of its
|
|
179
|
+
* MPU's packets in that decode, its FT=0 when the decode holds it. The
|
|
180
|
+
* release gate waits only for the blocks before it, and stops waiting once
|
|
181
|
+
* a base frame after this one arrived. `fecSbn` stays the SS_ID the frame
|
|
182
|
+
* completed in.
|
|
183
|
+
*/
|
|
184
|
+
fecSbnFirst?: number
|
|
176
185
|
/**
|
|
177
186
|
* Set on the first frame released after a gap: the blocks between it and
|
|
178
187
|
* the previous release resolved with lost media, or authored decode time
|
|
@@ -242,13 +251,22 @@ export interface FecReleaseGate {
|
|
|
242
251
|
isRap?(data: Uint8Array, meta: FrameMeta): boolean
|
|
243
252
|
/** A complete RAP entered the gate at SS_ID `ssId` (see `isRap`). */
|
|
244
253
|
onRap?(packetId: number, catalogTrackName: string | undefined, ssId: number): void
|
|
254
|
+
/**
|
|
255
|
+
* Whether what reads this stream decides on when its frames arrive (a
|
|
256
|
+
* staged stream switch): the gate then releases a held overlay-rebuilt
|
|
257
|
+
* frame when it did unanchored (#cursorPassed).
|
|
258
|
+
*/
|
|
259
|
+
releaseUnanchored?(packetId: number, catalogTrackName: string | undefined): boolean
|
|
245
260
|
}
|
|
246
261
|
|
|
247
262
|
/** Typed ISO-BMFF timing-sample budget counters. See `timingBudgetStats`. */
|
|
248
263
|
export interface MmtpTimingBudgetStats {
|
|
249
264
|
/** Slots currently charged to declared-but-undelivered samples. */
|
|
250
265
|
pending: number
|
|
251
|
-
/**
|
|
266
|
+
/**
|
|
267
|
+
* Slots each stream's window holds (`maxBufferedMfus`). A stream at
|
|
268
|
+
* capacity displaces its own oldest timing; `pending` sums every stream.
|
|
269
|
+
*/
|
|
252
270
|
capacity: number
|
|
253
271
|
/** Slots returned by samples the combiner abandoned (evicted or timed out). */
|
|
254
272
|
reclaimed: number
|
|
@@ -259,7 +277,13 @@ export interface MmtpTimingBudgetStats {
|
|
|
259
277
|
* up, `evicted` is loss that was never observable at all.
|
|
260
278
|
*/
|
|
261
279
|
evicted: number
|
|
262
|
-
/**
|
|
280
|
+
/**
|
|
281
|
+
* Movie fragments refused because the budget had no room for their samples.
|
|
282
|
+
* Stays 0 with a window per stream (BLO-52085): a stream at capacity
|
|
283
|
+
* displaces its own oldest timing to make room, and a moof declaring more
|
|
284
|
+
* samples than a whole window holds is refused as invalid before any budget
|
|
285
|
+
* is consulted. Kept as a guard, and because it is public.
|
|
286
|
+
*/
|
|
263
287
|
rejectedMovieFragments: number
|
|
264
288
|
}
|
|
265
289
|
|
|
@@ -365,10 +389,22 @@ export type MmtpFeedDropReason =
|
|
|
365
389
|
/** No timing resolver, or it refused the initialization segment. */
|
|
366
390
|
| 'init-timing-refused'
|
|
367
391
|
| 'fragment-unexpected-fragmentation'
|
|
392
|
+
/** FT=1 before an FT=0 has bound the stream's timing track. */
|
|
368
393
|
| 'fragment-no-resolver'
|
|
369
|
-
/** addMovieFragment refused
|
|
394
|
+
/** addMovieFragment refused: the moof does not parse against the stream's
|
|
395
|
+
* track, conflicts with a pending copy of itself, or repeats a pending
|
|
396
|
+
* copy whose samples are partly consumed. Never the timing budget: a full
|
|
397
|
+
* window displaces the stream's own oldest timing. */
|
|
370
398
|
| 'fragment-timing-refused'
|
|
371
399
|
| 'unknown-fragment-type'
|
|
400
|
+
/** Stable code, no longer produced: an FT=2 with no timing (before the
|
|
401
|
+
* stream's first FT=0, or after setTrack dropped it) now waits for its
|
|
402
|
+
* timing like one whose FT=1 has not arrived. A head counts as
|
|
403
|
+
* `drop:mfu-sample-timing-missing` (parked) and a FI=2/FI=3 continuation
|
|
404
|
+
* as the `pending` record. A continuation that completes a head the
|
|
405
|
+
* reassembler buffered before the timing was dropped counts as
|
|
406
|
+
* `drop:assembled-timing-missing`. A panel keyed on this code reads zero:
|
|
407
|
+
* a re-attribution, not lost coverage. Re-key on both drop codes. */
|
|
372
408
|
| 'mfu-no-resolver'
|
|
373
409
|
| 'mfu-empty-payload'
|
|
374
410
|
| 'mfu-du-header-missing'
|
|
@@ -499,6 +535,8 @@ export interface ParseHeaderOptions {
|
|
|
499
535
|
deliveryProvenance?: MmtpDeliveryProvenance
|
|
500
536
|
/** FEC source block this packet belongs to (FEC-protected packets only). */
|
|
501
537
|
fecSbn?: number
|
|
538
|
+
/** See FrameMeta.fecSbnFirst. */
|
|
539
|
+
fecSbnFirst?: number
|
|
502
540
|
}
|
|
503
541
|
|
|
504
542
|
// ── FrameSequencer ──────────────────────────────────────────────────
|
|
@@ -679,7 +717,10 @@ class FrameSequencer {
|
|
|
679
717
|
// ── FecReleaseSequencer ─────────────────────────────────────────────
|
|
680
718
|
|
|
681
719
|
/** Authored decode-time adjacency: the next frame starts where this one ends. */
|
|
682
|
-
function authoredAdjacent(
|
|
720
|
+
export function authoredAdjacent(
|
|
721
|
+
before: Pick<FrameMeta, 'authoredTiming'>,
|
|
722
|
+
after: Pick<FrameMeta, 'authoredTiming'>,
|
|
723
|
+
): boolean {
|
|
683
724
|
const a = before.authoredTiming
|
|
684
725
|
const b = after.authoredTiming
|
|
685
726
|
if (!a || !b || a.mediaTimescale !== b.mediaTimescale) return false
|
|
@@ -709,9 +750,19 @@ function ssIdAfter(a: number, b: number): boolean {
|
|
|
709
750
|
return ((a - b) | 0) > 0
|
|
710
751
|
}
|
|
711
752
|
|
|
753
|
+
/** The stream holds a frame the keyframe overlay rebuilt (FrameMeta.fecSbnFirst). */
|
|
754
|
+
function holdsRebuilt(stream: GatedStream): boolean {
|
|
755
|
+
return stream.buf.some(({ meta }) => meta.fecSbnFirst !== undefined)
|
|
756
|
+
}
|
|
757
|
+
|
|
712
758
|
interface GatedFrame extends PendingFrame {
|
|
713
759
|
/** performance.now() at push; measures the hold against the gate's limit. */
|
|
714
760
|
arrivedAtMs: number
|
|
761
|
+
/**
|
|
762
|
+
* The unanchored path's gate is done with this frame (see #cursorPassed):
|
|
763
|
+
* it released it, or dropped it as stale.
|
|
764
|
+
*/
|
|
765
|
+
passed?: boolean
|
|
715
766
|
}
|
|
716
767
|
|
|
717
768
|
interface GatedStream {
|
|
@@ -723,6 +774,10 @@ interface GatedStream {
|
|
|
723
774
|
discontinuity: boolean
|
|
724
775
|
/** Latest (serial order) SS_ID of a complete RAP that entered the gate since the last reset. */
|
|
725
776
|
rapSsId: number | undefined
|
|
777
|
+
/** A frame that was not FEC-recovered was released since the last reset. */
|
|
778
|
+
sourced: boolean
|
|
779
|
+
/** Drains of this stream in progress: a release calls out, and back in (a stream switch's onCommit). */
|
|
780
|
+
draining: number
|
|
726
781
|
}
|
|
727
782
|
|
|
728
783
|
/**
|
|
@@ -731,9 +786,11 @@ interface GatedStream {
|
|
|
731
786
|
* to the last released frame is released immediately. A frame after a gap
|
|
732
787
|
* waits until every block between the last released frame's block and its
|
|
733
788
|
* own has resolved; a D-28 repair or a late in-band recovery can therefore
|
|
734
|
-
* still fill the gap.
|
|
735
|
-
*
|
|
736
|
-
*
|
|
789
|
+
* still fill the gap. A frame the keyframe overlay rebuilt waits only for the
|
|
790
|
+
* blocks before its MPU (FrameMeta.fecSbnFirst), and no longer than the
|
|
791
|
+
* source cursor would hold it unanchored (#cursorPassed). When the resolved
|
|
792
|
+
* blocks lost media, or authored decode time shows a missing sample, the
|
|
793
|
+
* release carries the gate's stall/conceal decision as `gapBefore`.
|
|
737
794
|
*
|
|
738
795
|
* The wait is bounded here, not by the blocks: a frame held longer than the
|
|
739
796
|
* gate's `holdLimitMs` since it arrived is released with the gate's
|
|
@@ -817,6 +874,7 @@ class FecReleaseSequencer {
|
|
|
817
874
|
stream.lastSbn = undefined
|
|
818
875
|
stream.discontinuity = false
|
|
819
876
|
stream.rapSsId = undefined
|
|
877
|
+
stream.sourced = false
|
|
820
878
|
}
|
|
821
879
|
if (stream.last !== null && compareFrameOrder(frame, stream.last) <= 0) {
|
|
822
880
|
this.#recordStat('droppedStale')
|
|
@@ -845,6 +903,8 @@ class FecReleaseSequencer {
|
|
|
845
903
|
if (compareFrameOrder(stream.buf[mid]!, frame) < 0) lo = mid + 1
|
|
846
904
|
else hi = mid
|
|
847
905
|
}
|
|
906
|
+
// In front of a frame the unanchored path released, it was stale there.
|
|
907
|
+
if (stream.buf[lo]?.passed === true) frame.passed = true
|
|
848
908
|
stream.buf.splice(lo, 0, frame)
|
|
849
909
|
this.#drainUnlessDeferred(stream)
|
|
850
910
|
}
|
|
@@ -883,6 +943,54 @@ class FecReleaseSequencer {
|
|
|
883
943
|
return false
|
|
884
944
|
}
|
|
885
945
|
|
|
946
|
+
/**
|
|
947
|
+
* These streams' sink is being replaced by one that has seen no source
|
|
948
|
+
* frame yet. First, from a stream that holds a frame the overlay rebuilt,
|
|
949
|
+
* release to the sink being replaced the frames the unanchored path had
|
|
950
|
+
* already released to it (#cursorPassed): there they waited in its queue,
|
|
951
|
+
* which its replacement flushes. A stream that holds none is not drained:
|
|
952
|
+
* it holds nothing the anchor kept back, and a drain would still force out
|
|
953
|
+
* a frame held past the hold limit, which before the anchor waited for the
|
|
954
|
+
* next push. Then treat the streams as unsourced until a source frame is
|
|
955
|
+
* released.
|
|
956
|
+
*/
|
|
957
|
+
unsource(packetId: number, catalogTrackName: string | undefined): void {
|
|
958
|
+
for (const stream of this.#streams.values()) {
|
|
959
|
+
if (stream.packetId === packetId && (catalogTrackName === undefined || stream.catalogTrackName === catalogTrackName)) {
|
|
960
|
+
// From inside this stream's own drain (a sink replaced from a frame
|
|
961
|
+
// callback, as a stream switch's onCommit can), the drain under way
|
|
962
|
+
// releases the rest, in order, once the frame it is delivering is
|
|
963
|
+
// done (a commit delivers its held GOP and RAP after onCommit):
|
|
964
|
+
// released here, they would go ahead of them. Inside a deferral
|
|
965
|
+
// scope (a protected flow's record admits frames of several streams
|
|
966
|
+
// in one), the scope's drain does, for the same reason, into the
|
|
967
|
+
// replacement. With the stream unsourced, that drain lets a rebuilt
|
|
968
|
+
// frame go as unanchored did until it releases a source frame; a
|
|
969
|
+
// rebuilt frame behind that one waits for the blocks before it, as an
|
|
970
|
+
// anchored frame does (the record may hold the base frames it needs).
|
|
971
|
+
if (stream.draining === 0 && holdsRebuilt(stream)) {
|
|
972
|
+
if (this.#deferDepth > 0) this.#deferredStreams.add(stream)
|
|
973
|
+
else this.#drain(stream, true)
|
|
974
|
+
}
|
|
975
|
+
stream.sourced = false
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Whether a stream of `packetId` holds a frame the keyframe overlay rebuilt
|
|
982
|
+
* (FrameMeta.fecSbnFirst). Without `catalogTrackName`, every stream of the
|
|
983
|
+
* packetId.
|
|
984
|
+
*/
|
|
985
|
+
holdsRebuilt(packetId: number, catalogTrackName: string | undefined): boolean {
|
|
986
|
+
for (const stream of this.#streams.values()) {
|
|
987
|
+
if (stream.packetId !== packetId) continue
|
|
988
|
+
if (catalogTrackName !== undefined && stream.catalogTrackName !== catalogTrackName) continue
|
|
989
|
+
if (holdsRebuilt(stream)) return true
|
|
990
|
+
}
|
|
991
|
+
return false
|
|
992
|
+
}
|
|
993
|
+
|
|
886
994
|
/** Re-evaluate held gaps after FEC state changed. */
|
|
887
995
|
poke(streamKey?: string): void {
|
|
888
996
|
if (streamKey !== undefined) {
|
|
@@ -989,16 +1097,34 @@ class FecReleaseSequencer {
|
|
|
989
1097
|
lastSbn: undefined,
|
|
990
1098
|
discontinuity: false,
|
|
991
1099
|
rapSsId: undefined,
|
|
1100
|
+
sourced: false,
|
|
1101
|
+
draining: 0,
|
|
992
1102
|
}
|
|
993
1103
|
this.#streams.set(streamKey, stream)
|
|
994
1104
|
}
|
|
995
1105
|
return stream
|
|
996
1106
|
}
|
|
997
1107
|
|
|
998
|
-
#
|
|
1108
|
+
/** `asUnanchored`: release overlay-rebuilt frames when unanchored did (#cursorPassed). */
|
|
1109
|
+
#drain(stream: GatedStream, asUnanchored = false): void {
|
|
1110
|
+
stream.draining++
|
|
1111
|
+
try {
|
|
1112
|
+
this.#drainLoop(stream, asUnanchored)
|
|
1113
|
+
} finally {
|
|
1114
|
+
stream.draining--
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
#drainLoop(stream: GatedStream, asUnanchored: boolean): void {
|
|
999
1119
|
while (stream.buf.length > 0 && stream.last !== null) {
|
|
1000
1120
|
const head = stream.buf[0]!
|
|
1001
1121
|
const last = stream.last
|
|
1122
|
+
// A second held copy of the frame just released waits on nothing.
|
|
1123
|
+
if (compareFrameOrder(head, last) <= 0) {
|
|
1124
|
+
stream.buf.shift()
|
|
1125
|
+
this.#release(stream, head, undefined)
|
|
1126
|
+
continue
|
|
1127
|
+
}
|
|
1002
1128
|
if (stream.discontinuity) {
|
|
1003
1129
|
stream.discontinuity = false
|
|
1004
1130
|
stream.buf.shift()
|
|
@@ -1012,13 +1138,19 @@ class FecReleaseSequencer {
|
|
|
1012
1138
|
}
|
|
1013
1139
|
const headSbn = head.meta.fecSbn
|
|
1014
1140
|
const from = stream.lastSbn ?? headSbn
|
|
1015
|
-
|
|
1141
|
+
// An overlay-rebuilt frame waits only for the blocks before its MPU's
|
|
1142
|
+
// first packet in that decode (fecSbnFirst): the overlay delivered the
|
|
1143
|
+
// packets from there on.
|
|
1144
|
+
const to = head.meta.fecSbnFirst ?? headSbn ?? stream.lastSbn
|
|
1016
1145
|
let lost: readonly number[] = []
|
|
1017
1146
|
// Neither side carries a Source FEC Payload ID: on a catalog-declared
|
|
1018
1147
|
// FEC track that is the trailer-stripped profile violation, and there
|
|
1019
1148
|
// are no blocks to wait on. The gap is still a gap: never release across
|
|
1020
1149
|
// it undecided (fail closed), let the gate decide with no known blocks.
|
|
1021
1150
|
const unanchored = from === undefined && to === undefined
|
|
1151
|
+
// The source cursor released it (#cursorPassed) across blocks that have
|
|
1152
|
+
// not resolved: nothing sized that gap either, so it is decided too.
|
|
1153
|
+
let unresolved = false
|
|
1022
1154
|
if (from !== undefined && to !== undefined) {
|
|
1023
1155
|
const resolved = this.#gate.resolvedLoss(
|
|
1024
1156
|
stream.packetId,
|
|
@@ -1027,7 +1159,8 @@ class FecReleaseSequencer {
|
|
|
1027
1159
|
Math.max(from, to),
|
|
1028
1160
|
from,
|
|
1029
1161
|
)
|
|
1030
|
-
|
|
1162
|
+
unresolved = resolved === null
|
|
1163
|
+
if (unresolved && !this.#cursorPassed(stream, asUnanchored)) {
|
|
1031
1164
|
const heldMs = performance.now() - head.arrivedAtMs
|
|
1032
1165
|
if (heldMs <= this.#gate.holdLimitMs(stream.packetId, stream.catalogTrackName)) return
|
|
1033
1166
|
// Past the point where these blocks must have resolved: nothing
|
|
@@ -1037,7 +1170,7 @@ class FecReleaseSequencer {
|
|
|
1037
1170
|
this.#release(stream, head, this.#gate.decide(last, head, [], true))
|
|
1038
1171
|
continue
|
|
1039
1172
|
}
|
|
1040
|
-
lost = resolved
|
|
1173
|
+
lost = resolved ?? []
|
|
1041
1174
|
}
|
|
1042
1175
|
stream.buf.shift()
|
|
1043
1176
|
// Every gap in the frame sequence gets a decision, not only one whose
|
|
@@ -1048,19 +1181,67 @@ class FecReleaseSequencer {
|
|
|
1048
1181
|
this.#release(
|
|
1049
1182
|
stream,
|
|
1050
1183
|
head,
|
|
1051
|
-
lost.length > 0 || unanchored || authoredGap(last.meta, head.meta)
|
|
1184
|
+
lost.length > 0 || unanchored || unresolved || authoredGap(last.meta, head.meta)
|
|
1052
1185
|
? this.#gate.decide(last, head, lost, false)
|
|
1053
1186
|
: undefined,
|
|
1054
1187
|
)
|
|
1055
1188
|
}
|
|
1056
1189
|
}
|
|
1057
1190
|
|
|
1191
|
+
/**
|
|
1192
|
+
* An overlay-rebuilt frame (fecSbnFirst) waits no longer than the BLO-53342
|
|
1193
|
+
* source cursor holds it unanchored, and neither does a later frame behind
|
|
1194
|
+
* it. Unanchored, a frame that waits on no block (fecSbnFirst set, or no
|
|
1195
|
+
* fecSbn) left the gate once every frame ahead of it had: mark it `passed`,
|
|
1196
|
+
* like each frame that arrived in front of it since (stale there). The head
|
|
1197
|
+
* then goes, decided as it would be unanchored, once a frame the base path
|
|
1198
|
+
* delivered (a source frame, or one base FEC recovered) is buffered after a
|
|
1199
|
+
* marked one: the base path has passed that one, so a frame still missing
|
|
1200
|
+
* before it can only come from a repair. It also goes while no source frame
|
|
1201
|
+
* has been released, when the direct path shows a recovered frame at once.
|
|
1202
|
+
* And it goes at once while a staged switch decides on when frames arrive
|
|
1203
|
+
* (FecReleaseGate.releaseUnanchored), and when unsource releases into a
|
|
1204
|
+
* sink being replaced: unanchored, it had already reached the sink.
|
|
1205
|
+
* The scan also writes: the `passed` marks it sets stay on the frames,
|
|
1206
|
+
* and push marks a frame it inserts in front of a marked one from them.
|
|
1207
|
+
*/
|
|
1208
|
+
#cursorPassed(stream: GatedStream, asUnanchored: boolean): boolean {
|
|
1209
|
+
let at = -1
|
|
1210
|
+
for (let i = 0; i < stream.buf.length; i++) {
|
|
1211
|
+
const frame = stream.buf[i]!
|
|
1212
|
+
if (frame.meta.fecSbnFirst !== undefined || frame.meta.fecSbn === undefined) {
|
|
1213
|
+
frame.passed = true
|
|
1214
|
+
if (at < 0) at = i
|
|
1215
|
+
} else if (frame.passed !== true) break
|
|
1216
|
+
}
|
|
1217
|
+
if (at < 0) return false
|
|
1218
|
+
return asUnanchored || !stream.sourced ||
|
|
1219
|
+
this.#gate.releaseUnanchored?.(stream.packetId, stream.catalogTrackName) === true ||
|
|
1220
|
+
stream.buf.some(({ meta }, i) =>
|
|
1221
|
+
i > at && meta.fecSbnFirst === undefined && (meta.recovered !== true || meta.fecSbn !== undefined))
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1058
1224
|
#release(stream: GatedStream, frame: PendingFrame, decision: FrameGapDecision | undefined): void {
|
|
1225
|
+
// Two copies of one frame were held (its base copy and the overlay's
|
|
1226
|
+
// rebuild) and the other went out: this one is stale, as a copy pushed
|
|
1227
|
+
// after its frame was released is. It still moves the state as a release
|
|
1228
|
+
// does: only the second emission is dropped.
|
|
1229
|
+
const stale = stream.last !== null && compareFrameOrder(frame, stream.last) <= 0
|
|
1230
|
+
if (stale) this.#recordStat('droppedStale')
|
|
1059
1231
|
if (decision) frame.meta.gapBefore = decision
|
|
1060
1232
|
stream.last = frame
|
|
1061
|
-
|
|
1233
|
+
// An overlay-rebuilt frame (fecSbnFirst) released before any base frame
|
|
1234
|
+
// since the last reset leaves lastSbn unset, as it did unanchored: the
|
|
1235
|
+
// base frame after it then waits for nothing, not for the frames between.
|
|
1236
|
+
if (frame.meta.fecSbn !== undefined && (frame.meta.fecSbnFirst === undefined || stream.lastSbn !== undefined)) {
|
|
1237
|
+
stream.lastSbn = frame.meta.fecSbn
|
|
1238
|
+
}
|
|
1239
|
+
if (frame.meta.recovered !== true) stream.sourced = true
|
|
1240
|
+
// A stale copy of the frame just released (equal order) repeats an
|
|
1241
|
+
// onReleased call already made: noteReleasedRap ignores a second RAP of
|
|
1242
|
+
// an MPU it has anchored (the same MPU and presentation time).
|
|
1062
1243
|
this.#gate.onReleased?.(frame.data, frame.meta)
|
|
1063
|
-
this.#emit(frame.data, frame.meta)
|
|
1244
|
+
if (!stale) this.#emit(frame.data, frame.meta)
|
|
1064
1245
|
}
|
|
1065
1246
|
}
|
|
1066
1247
|
|
|
@@ -1089,9 +1270,30 @@ interface AwaitingTimingHead {
|
|
|
1089
1270
|
mpuSequenceNumber: number
|
|
1090
1271
|
movieFragmentSequenceNumber: number
|
|
1091
1272
|
sampleNumber: number
|
|
1273
|
+
/** MMTP packet sequence number; a recovered copy carries its source's. */
|
|
1274
|
+
sequenceNumber: number
|
|
1092
1275
|
arrivedAt: number
|
|
1093
1276
|
}
|
|
1094
1277
|
|
|
1278
|
+
/** FEC-recovered heads that went through the awaiting-timing queue. Every
|
|
1279
|
+
* count but `pending` is cumulative. A held head is pending or has left the
|
|
1280
|
+
* queue exactly once, so `held - expired - discarded - superseded - pending`
|
|
1281
|
+
* is the number fed again once their FT=1 landed. That feed can still refuse
|
|
1282
|
+
* one (size, offset, dispose from a frame callback); the refusal is counted in
|
|
1283
|
+
* `feedStats.deferredOutcomes`, not here. */
|
|
1284
|
+
export interface MmtpRecoveredHeadTimingStats {
|
|
1285
|
+
/** Held because their sample's FT=1 had not arrived yet. */
|
|
1286
|
+
held: number
|
|
1287
|
+
/** Left without timing: the reassembler-timeout deadline, or pushed out by the queue's capacity. */
|
|
1288
|
+
expired: number
|
|
1289
|
+
/** Dropped with their stream (track reconfigure, reset or retire) or by dispose(). */
|
|
1290
|
+
discarded: number
|
|
1291
|
+
/** Gave way to their arriving source copy, which takes their place in the queue. */
|
|
1292
|
+
superseded: number
|
|
1293
|
+
/** In the queue now; current occupancy, not cumulative. */
|
|
1294
|
+
pending: number
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1095
1297
|
const MAX_AWAITING_TIMING_HEADS = 8
|
|
1096
1298
|
|
|
1097
1299
|
// ── MmtpContainerParser ─────────────────────────────────────────────
|
|
@@ -1129,6 +1331,9 @@ export class MmtpContainerParser {
|
|
|
1129
1331
|
#reclaimedTimingSamples = 0
|
|
1130
1332
|
#evictedTimingSamples = 0
|
|
1131
1333
|
#rejectedMovieFragments = 0
|
|
1334
|
+
#recoveredHeadsAwaitingTiming: Omit<MmtpRecoveredHeadTimingStats, 'pending'> = {
|
|
1335
|
+
held: 0, expired: 0, discarded: 0, superseded: 0,
|
|
1336
|
+
}
|
|
1132
1337
|
#sequencers = new Map<string, { packetId: number; sequencer: FrameSequencer }>()
|
|
1133
1338
|
#fecGate: FecReleaseGate | null = null
|
|
1134
1339
|
#fecSequencer: FecReleaseSequencer | null = null
|
|
@@ -1330,11 +1535,12 @@ export class MmtpContainerParser {
|
|
|
1330
1535
|
/**
|
|
1331
1536
|
* Typed view of the ISO-BMFF timing-sample budget.
|
|
1332
1537
|
*
|
|
1333
|
-
*
|
|
1334
|
-
*
|
|
1335
|
-
*
|
|
1336
|
-
*
|
|
1337
|
-
*
|
|
1538
|
+
* Each stream admits timing against its own window of `capacity` slots
|
|
1539
|
+
* (BLO-52085). A stream at capacity displaces its own oldest timing to
|
|
1540
|
+
* admit the moof in hand, and `evicted` counts the slots so displaced:
|
|
1541
|
+
* timing a stream declared and never delivered shows up there. `pending`
|
|
1542
|
+
* sums every stream, so it can exceed `capacity`. Per repo policy this is
|
|
1543
|
+
* a producer-owned counter -- never infer budget state by parsing logs.
|
|
1338
1544
|
*/
|
|
1339
1545
|
get timingBudgetStats(): Readonly<MmtpTimingBudgetStats> {
|
|
1340
1546
|
return {
|
|
@@ -1346,6 +1552,15 @@ export class MmtpContainerParser {
|
|
|
1346
1552
|
}
|
|
1347
1553
|
}
|
|
1348
1554
|
|
|
1555
|
+
/** Recovered heads parked for FT=1 timing (BLO-53369). Survives dispose(). */
|
|
1556
|
+
get recoveredHeadsAwaitingTiming(): Readonly<MmtpRecoveredHeadTimingStats> {
|
|
1557
|
+
let pending = 0
|
|
1558
|
+
for (const { awaitingTiming } of this.#timingStreams.values()) {
|
|
1559
|
+
for (const head of awaitingTiming) if (head.options?.recovered) pending++
|
|
1560
|
+
}
|
|
1561
|
+
return { ...this.#recoveredHeadsAwaitingTiming, pending }
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1349
1564
|
get reorderStats(): Readonly<MmtpReorderStats> {
|
|
1350
1565
|
let bufferedFrames = this.#fecSequencer?.buffered ?? 0
|
|
1351
1566
|
for (const { sequencer } of this.#sequencers.values()) bufferedFrames += sequencer.buffered
|
|
@@ -1394,6 +1609,16 @@ export class MmtpContainerParser {
|
|
|
1394
1609
|
return this.#fecSequencer?.rapAbove(packetId, catalogTrackName, ssId) ?? false
|
|
1395
1610
|
}
|
|
1396
1611
|
|
|
1612
|
+
/** See FecReleaseSequencer.unsource. */
|
|
1613
|
+
unsourceFecRelease(packetId: number, catalogTrackName?: string): void {
|
|
1614
|
+
this.#fecSequencer?.unsource(packetId, catalogTrackName)
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/** See FecReleaseSequencer.holdsRebuilt. */
|
|
1618
|
+
fecReleaseHoldsRebuilt(packetId: number, catalogTrackName?: string): boolean {
|
|
1619
|
+
return this.#fecSequencer?.holdsRebuilt(packetId, catalogTrackName) ?? false
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1397
1622
|
/** Re-evaluate held gaps after FEC block state changed. */
|
|
1398
1623
|
pokeFecRelease(packetId?: number, catalogTrackName?: string): void {
|
|
1399
1624
|
if (!this.#fecSequencer) return
|
|
@@ -1541,7 +1766,7 @@ export class MmtpContainerParser {
|
|
|
1541
1766
|
const now = Date.now()
|
|
1542
1767
|
for (const { awaitingTiming } of this.#timingStreams.values()) {
|
|
1543
1768
|
while (awaitingTiming.length > 0 && now - awaitingTiming[0]!.arrivedAt > timeoutMs) {
|
|
1544
|
-
awaitingTiming.shift()
|
|
1769
|
+
this.#leaveAwaitingTiming(awaitingTiming.shift()!, 'expired')
|
|
1545
1770
|
}
|
|
1546
1771
|
}
|
|
1547
1772
|
}
|
|
@@ -1711,17 +1936,27 @@ export class MmtpContainerParser {
|
|
|
1711
1936
|
if (mpu.fragmentationIndicator !== 0 || mpu.fragmentCounter !== 0)
|
|
1712
1937
|
return this.#drop('fragment-unexpected-fragmentation')
|
|
1713
1938
|
const resolver = this.#timingResolver(streamKey, info.packetId, false)
|
|
1714
|
-
if (!resolver) return this.#drop('fragment-no-resolver')
|
|
1939
|
+
if (!resolver?.initialized) return this.#drop('fragment-no-resolver')
|
|
1715
1940
|
const pendingBefore = resolver.pendingSampleCount
|
|
1716
1941
|
const evictedBefore = resolver.evictedSampleCount
|
|
1717
|
-
|
|
1718
|
-
|
|
1942
|
+
// Each stream's window is the whole budget. A resolver can displace only
|
|
1943
|
+
// its own timing, so a pool shared across streams let one stream's
|
|
1944
|
+
// unconsumed slots refuse another stream's moofs with nothing to
|
|
1945
|
+
// displace: BLO-52085 G7b, overlay-rebuilt video keyframes refusing audio.
|
|
1946
|
+
const remaining = this.#maxPendingTimingSamples - resolver.pendingSampleCount
|
|
1947
|
+
const admitted = resolver.addMovieFragment(
|
|
1948
|
+
mpu.mpuSequenceNumber,
|
|
1949
|
+
data,
|
|
1950
|
+
remaining,
|
|
1951
|
+
options?.recovered === true,
|
|
1952
|
+
)
|
|
1719
1953
|
// Sync on BOTH paths: the resolver can displace stale timing and still
|
|
1720
|
-
// refuse the fragment in hand, and
|
|
1721
|
-
// freed slots would keep refusing on a budget that is no longer full.
|
|
1954
|
+
// refuse the fragment in hand, and the total must count those freed slots.
|
|
1722
1955
|
this.#pendingTimingSamples += resolver.pendingSampleCount - pendingBefore
|
|
1723
1956
|
this.#evictedTimingSamples += resolver.evictedSampleCount - evictedBefore
|
|
1724
1957
|
if (!admitted) {
|
|
1958
|
+
// Never 'budget' with a window per stream (see
|
|
1959
|
+
// `rejectedMovieFragments`); kept as a guard.
|
|
1725
1960
|
if (resolver.lastMovieFragmentRejection === 'budget') {
|
|
1726
1961
|
this.#rejectedMovieFragments++
|
|
1727
1962
|
}
|
|
@@ -1739,7 +1974,11 @@ export class MmtpContainerParser {
|
|
|
1739
1974
|
}
|
|
1740
1975
|
if (fragmentType !== 2) return this.#drop('unknown-fragment-type')
|
|
1741
1976
|
|
|
1742
|
-
|
|
1977
|
+
// An FT=2 before the stream's first FT=0 (a join, a track reset) is taken
|
|
1978
|
+
// like one whose FT=1 has not arrived: a head waits for its timing, a
|
|
1979
|
+
// continuation parks in the reassembler, and a later or FEC-recovered
|
|
1980
|
+
// FT=0/FT=1 can still time them.
|
|
1981
|
+
const resolver = this.#timingResolver(streamKey, info.packetId, true)
|
|
1743
1982
|
if (!resolver) return this.#drop('mfu-no-resolver')
|
|
1744
1983
|
if (!Number.isSafeInteger(data.byteLength) || data.byteLength <= 0)
|
|
1745
1984
|
return this.#drop('mfu-empty-payload')
|
|
@@ -1761,6 +2000,7 @@ export class MmtpContainerParser {
|
|
|
1761
2000
|
mpuSequenceNumber: mpu.mpuSequenceNumber,
|
|
1762
2001
|
movieFragmentSequenceNumber: mfuDuHeader.movieFragmentSequenceNumber,
|
|
1763
2002
|
sampleNumber: mfuDuHeader.sampleNumber,
|
|
2003
|
+
sequenceNumber: info.sequenceNumber,
|
|
1764
2004
|
arrivedAt: Date.now(),
|
|
1765
2005
|
})
|
|
1766
2006
|
return this.#drop('mfu-sample-timing-missing')
|
|
@@ -1803,6 +2043,7 @@ export class MmtpContainerParser {
|
|
|
1803
2043
|
resolver,
|
|
1804
2044
|
options?.recovered === true,
|
|
1805
2045
|
options?.fecSbn,
|
|
2046
|
+
options?.fecSbnFirst,
|
|
1806
2047
|
)
|
|
1807
2048
|
if (deferred.record === undefined) this.#countDeferredOutcome(deferred.reason)
|
|
1808
2049
|
}
|
|
@@ -1914,6 +2155,7 @@ export class MmtpContainerParser {
|
|
|
1914
2155
|
resolver,
|
|
1915
2156
|
options?.recovered === true,
|
|
1916
2157
|
options?.fecSbn,
|
|
2158
|
+
options?.fecSbnFirst,
|
|
1917
2159
|
)
|
|
1918
2160
|
if (outcome.record !== undefined) return outcome.record
|
|
1919
2161
|
return this.#drop(outcome.reason)
|
|
@@ -1940,6 +2182,7 @@ export class MmtpContainerParser {
|
|
|
1940
2182
|
resolver: IsoBmffTimingResolver,
|
|
1941
2183
|
recovered: boolean,
|
|
1942
2184
|
fecSbn: number | undefined,
|
|
2185
|
+
fecSbnFirst: number | undefined,
|
|
1943
2186
|
): CombinedSampleOutcome {
|
|
1944
2187
|
const timing = resolver.sampleTiming(
|
|
1945
2188
|
combined.mpuSequenceNumber,
|
|
@@ -1961,6 +2204,7 @@ export class MmtpContainerParser {
|
|
|
1961
2204
|
)
|
|
1962
2205
|
if (!meta) return { reason: 'combined-meta-build-failed' }
|
|
1963
2206
|
if (fecSbn !== undefined) meta.fecSbn = fecSbn
|
|
2207
|
+
if (fecSbnFirst !== undefined) meta.fecSbnFirst = fecSbnFirst
|
|
1964
2208
|
if (!resolver.consumeSample(
|
|
1965
2209
|
combined.mpuSequenceNumber,
|
|
1966
2210
|
combined.movieFragmentSequenceNumber,
|
|
@@ -2146,6 +2390,18 @@ export class MmtpContainerParser {
|
|
|
2146
2390
|
this.#emitFrame(data, meta)
|
|
2147
2391
|
}
|
|
2148
2392
|
|
|
2393
|
+
/** Whether FT=1 metadata already names this sample, without claiming it. */
|
|
2394
|
+
hasAuthoredTimingFor(
|
|
2395
|
+
packetId: number,
|
|
2396
|
+
mpuSequenceNumber: number,
|
|
2397
|
+
movieFragmentSequenceNumber: number,
|
|
2398
|
+
sampleNumber: number,
|
|
2399
|
+
catalogTrackName?: string,
|
|
2400
|
+
): boolean {
|
|
2401
|
+
return this.#timingResolver(this.#streamKey(packetId, catalogTrackName), packetId, false)
|
|
2402
|
+
?.sampleTiming(mpuSequenceNumber, movieFragmentSequenceNumber, sampleNumber) != null
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2149
2405
|
/**
|
|
2150
2406
|
* Claim and return the authored timing for a recovered sample. Recovery does
|
|
2151
2407
|
* not pass through the ordinary `feed()` completion path, so it must retire
|
|
@@ -2216,6 +2472,9 @@ export class MmtpContainerParser {
|
|
|
2216
2472
|
for (const { sequencer } of this.#sequencers.values()) sequencer.discard()
|
|
2217
2473
|
this.#sequencers.clear()
|
|
2218
2474
|
this.#fecSequencer?.discard()
|
|
2475
|
+
for (const { awaitingTiming } of this.#timingStreams.values()) {
|
|
2476
|
+
for (const head of awaitingTiming) this.#leaveAwaitingTiming(head, 'discarded')
|
|
2477
|
+
}
|
|
2219
2478
|
this.#timingStreams.clear()
|
|
2220
2479
|
this.#pendingTimingSamples = 0
|
|
2221
2480
|
this.#reliableMoqStartupGate.clear()
|
|
@@ -2241,10 +2500,27 @@ export class MmtpContainerParser {
|
|
|
2241
2500
|
}
|
|
2242
2501
|
|
|
2243
2502
|
#awaitTiming(streamKey: string, head: AwaitingTimingHead): void {
|
|
2244
|
-
const
|
|
2245
|
-
if (!
|
|
2503
|
+
const state = this.#timingStreams.get(streamKey)
|
|
2504
|
+
if (!state) return
|
|
2505
|
+
// The source copy of a held recovered head takes its place, so the frame
|
|
2506
|
+
// is delivered with source provenance once FT=1 lands.
|
|
2507
|
+
if (!head.options?.recovered) {
|
|
2508
|
+
const kept = state.awaitingTiming.filter(queued =>
|
|
2509
|
+
!queued.options?.recovered || queued.sequenceNumber !== head.sequenceNumber)
|
|
2510
|
+
this.#recoveredHeadsAwaitingTiming.superseded += state.awaitingTiming.length - kept.length
|
|
2511
|
+
state.awaitingTiming = kept
|
|
2512
|
+
}
|
|
2513
|
+
const awaitingTiming = state.awaitingTiming
|
|
2246
2514
|
awaitingTiming.push(head)
|
|
2247
|
-
if (
|
|
2515
|
+
if (head.options?.recovered) this.#recoveredHeadsAwaitingTiming.held++
|
|
2516
|
+
if (awaitingTiming.length > MAX_AWAITING_TIMING_HEADS) {
|
|
2517
|
+
this.#leaveAwaitingTiming(awaitingTiming.shift()!, 'expired')
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
/** Count a recovered head leaving the queue other than by replay or its source copy. */
|
|
2522
|
+
#leaveAwaitingTiming(head: AwaitingTimingHead, outcome: 'expired' | 'discarded'): void {
|
|
2523
|
+
if (head.options?.recovered) this.#recoveredHeadsAwaitingTiming[outcome]++
|
|
2248
2524
|
}
|
|
2249
2525
|
|
|
2250
2526
|
/**
|
|
@@ -2274,6 +2550,7 @@ export class MmtpContainerParser {
|
|
|
2274
2550
|
const state = this.#timingStreams.get(streamKey)
|
|
2275
2551
|
if (!state) return
|
|
2276
2552
|
this.#timingStreams.delete(streamKey)
|
|
2553
|
+
for (const head of state.awaitingTiming) this.#leaveAwaitingTiming(head, 'discarded')
|
|
2277
2554
|
this.#pendingTimingSamples -= state.resolver.pendingSampleCount
|
|
2278
2555
|
if (this.#pendingTimingSamples < 0) {
|
|
2279
2556
|
throw new Error('[MmtpContainerParser] pending timing sample accounting underflow')
|