@blockcast/mmt-transport 0.2.2 → 0.3.0
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 +44 -58
- package/dist/abr-controller.d.ts +34 -4
- package/dist/abr-controller.d.ts.map +1 -1
- package/dist/abr-controller.js +66 -17
- package/dist/abr-controller.js.map +1 -1
- package/dist/amt-gateway.d.ts.map +1 -1
- package/dist/amt-gateway.js +3 -4
- package/dist/amt-gateway.js.map +1 -1
- package/dist/driad-discovery.d.ts.map +1 -1
- package/dist/driad-discovery.js +3 -6
- package/dist/driad-discovery.js.map +1 -1
- package/dist/endpoint-config.d.ts +68 -0
- package/dist/endpoint-config.d.ts.map +1 -1
- package/dist/endpoint-config.js +118 -3
- package/dist/endpoint-config.js.map +1 -1
- package/dist/index.d.ts +10 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -11
- package/dist/index.js.map +1 -1
- package/dist/mmt-transport.no-modules.js +881 -60
- package/dist/mmt-transport.no-modules.js.map +4 -4
- package/dist/moqt-priority.d.ts +39 -0
- package/dist/moqt-priority.d.ts.map +1 -0
- package/dist/moqt-priority.js +41 -0
- package/dist/moqt-priority.js.map +1 -0
- package/dist/multicast-catalog-policy.d.ts +96 -0
- package/dist/multicast-catalog-policy.d.ts.map +1 -0
- package/dist/multicast-catalog-policy.js +660 -0
- package/dist/multicast-catalog-policy.js.map +1 -0
- package/dist/multicast-orchestrator.d.ts +24 -0
- package/dist/multicast-orchestrator.d.ts.map +1 -1
- package/dist/multicast-orchestrator.js +102 -9
- package/dist/multicast-orchestrator.js.map +1 -1
- package/dist/multicast-signaling-reconciler.d.ts +37 -0
- package/dist/multicast-signaling-reconciler.d.ts.map +1 -0
- package/dist/multicast-signaling-reconciler.js +161 -0
- package/dist/multicast-signaling-reconciler.js.map +1 -0
- package/dist/multicast-subscription-pool.d.ts.map +1 -1
- package/dist/multicast-subscription-pool.js +37 -10
- package/dist/multicast-subscription-pool.js.map +1 -1
- package/dist/multicast-tier.d.ts +19 -0
- package/dist/multicast-tier.d.ts.map +1 -1
- package/dist/multicast-tier.js +21 -4
- package/dist/multicast-tier.js.map +1 -1
- package/dist/no-modules.d.ts +40 -3
- package/dist/no-modules.d.ts.map +1 -1
- package/dist/no-modules.js +30 -3
- package/dist/no-modules.js.map +1 -1
- package/dist/packet-batch.d.ts +13 -0
- package/dist/packet-batch.d.ts.map +1 -0
- package/dist/packet-batch.js +14 -0
- package/dist/packet-batch.js.map +1 -0
- package/dist/stall-detector.d.ts +10 -0
- package/dist/stall-detector.d.ts.map +1 -1
- package/dist/stall-detector.js +21 -0
- package/dist/stall-detector.js.map +1 -1
- package/dist/takeover.d.ts +3 -0
- package/dist/takeover.d.ts.map +1 -1
- package/dist/takeover.js +35 -2
- package/dist/takeover.js.map +1 -1
- package/dist/transport-manager.d.ts +21 -17
- package/dist/transport-manager.d.ts.map +1 -1
- package/dist/transport-manager.js +63 -29
- package/dist/transport-manager.js.map +1 -1
- package/dist/types.d.ts +98 -123
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +12 -50
- package/dist/types.js.map +1 -1
- package/node_modules/@blockcast/mmt-alta-parse/README.md +181 -0
- package/node_modules/@blockcast/mmt-alta-parse/dist/.build-stamp +0 -0
- package/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts +57 -0
- package/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-alta-parse/dist/index.js +115 -0
- package/node_modules/@blockcast/mmt-alta-parse/dist/index.js.map +1 -0
- package/node_modules/@blockcast/mmt-alta-parse/package.json +50 -0
- package/node_modules/@blockcast/mmt-alta-parse/src/index.ts +136 -0
- package/node_modules/@blockcast/mmt-base/README.md +134 -0
- package/node_modules/@blockcast/mmt-base/dist/.build-stamp +0 -0
- package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts +55 -0
- package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js +128 -0
- package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/index.d.ts +7 -0
- package/node_modules/@blockcast/mmt-base/dist/index.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/index.js +7 -0
- package/node_modules/@blockcast/mmt-base/dist/index.js.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts +292 -0
- package/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/mmtp.js +612 -0
- package/node_modules/@blockcast/mmt-base/dist/mmtp.js.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts +18 -0
- package/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/net-constants.js +18 -0
- package/node_modules/@blockcast/mmt-base/dist/net-constants.js.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts +47 -0
- package/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/ntp-short.js +54 -0
- package/node_modules/@blockcast/mmt-base/dist/ntp-short.js.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts +57 -0
- package/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/serial-number.js +98 -0
- package/node_modules/@blockcast/mmt-base/dist/serial-number.js.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/signalling.d.ts +312 -0
- package/node_modules/@blockcast/mmt-base/dist/signalling.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/signalling.js +637 -0
- package/node_modules/@blockcast/mmt-base/dist/signalling.js.map +1 -0
- package/node_modules/@blockcast/mmt-base/package.json +49 -0
- package/node_modules/@blockcast/mmt-base/src/fec-block-profile.ts +145 -0
- package/node_modules/@blockcast/mmt-base/src/index.ts +6 -0
- package/node_modules/@blockcast/mmt-base/src/mmtp.ts +892 -0
- package/node_modules/@blockcast/mmt-base/src/net-constants.ts +17 -0
- package/node_modules/@blockcast/mmt-base/src/ntp-short.ts +67 -0
- package/node_modules/@blockcast/mmt-base/src/serial-number.ts +107 -0
- package/node_modules/@blockcast/mmt-base/src/signalling.ts +862 -0
- package/package.json +13 -6
- package/src/abr-controller.ts +81 -17
- package/src/amt-gateway.ts +3 -5
- package/src/driad-discovery.ts +3 -6
- package/src/endpoint-config.ts +157 -3
- package/src/index.ts +54 -33
- package/src/moqt-priority.ts +51 -0
- package/src/multicast-catalog-policy.ts +753 -0
- package/src/multicast-orchestrator.ts +114 -10
- package/src/multicast-signaling-reconciler.ts +156 -0
- package/src/multicast-subscription-pool.ts +46 -11
- package/src/multicast-tier.ts +30 -3
- package/src/no-modules.ts +54 -2
- package/src/packet-batch.ts +17 -0
- package/src/stall-detector.ts +37 -0
- package/src/takeover.ts +41 -3
- package/src/transport-manager.ts +64 -34
- package/src/types.ts +103 -160
- package/dist/endpoint-config.test.d.ts +0 -2
- package/dist/endpoint-config.test.d.ts.map +0 -1
- package/dist/endpoint-config.test.js +0 -39
- package/dist/endpoint-config.test.js.map +0 -1
- package/dist/fec-client.d.ts +0 -442
- package/dist/fec-client.d.ts.map +0 -1
- package/dist/fec-client.js +0 -789
- package/dist/fec-client.js.map +0 -1
- package/dist/fec-client.test.d.ts +0 -8
- package/dist/fec-client.test.d.ts.map +0 -1
- package/dist/fec-client.test.js +0 -112
- package/dist/fec-client.test.js.map +0 -1
- package/dist/multicast-subscription-pool.test.d.ts +0 -2
- package/dist/multicast-subscription-pool.test.d.ts.map +0 -1
- package/dist/multicast-subscription-pool.test.js +0 -108
- package/dist/multicast-subscription-pool.test.js.map +0 -1
- package/dist/multicast-tier.test.d.ts +0 -2
- package/dist/multicast-tier.test.d.ts.map +0 -1
- package/dist/multicast-tier.test.js +0 -75
- package/dist/multicast-tier.test.js.map +0 -1
- package/dist/stall-detector.test.d.ts +0 -2
- package/dist/stall-detector.test.d.ts.map +0 -1
- package/dist/stall-detector.test.js +0 -33
- package/dist/stall-detector.test.js.map +0 -1
- package/dist/transport-manager.test.d.ts +0 -2
- package/dist/transport-manager.test.d.ts.map +0 -1
- package/dist/transport-manager.test.js +0 -155
- package/dist/transport-manager.test.js.map +0 -1
- package/src/endpoint-config.test.ts +0 -54
- package/src/fec-client.test.ts +0 -140
- package/src/fec-client.ts +0 -1103
- package/src/multicast-subscription-pool.test.ts +0 -115
- package/src/multicast-tier.test.ts +0 -80
- package/src/stall-detector.test.ts +0 -34
- package/src/transport-manager.test.ts +0 -192
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { compareNtpShort, isNtpShort } from "@blockcast/mmt-base";
|
|
2
|
+
import { carriesMedia, endpointKey, selectMulticastTier, type MulticastTierEndpoint } from "./multicast-tier.js";
|
|
2
3
|
|
|
3
4
|
export type OrchestratorState = "stable" | "joining";
|
|
4
5
|
export type OrchestratorCommand =
|
|
@@ -9,6 +10,16 @@ export type OrchestratorCommand =
|
|
|
9
10
|
export interface MulticastTierPacket {
|
|
10
11
|
endpointKey: string;
|
|
11
12
|
keyframe: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* The packet's MMTP header `timestamp`: its UTC sending instant in NTP short
|
|
15
|
+
* format (ISO/IEC 23008-1 §9.2.2), which wraps every 2^16 s (~18.2 h). The
|
|
16
|
+
* orchestrator orders these only with RFC 1982 serial arithmetic
|
|
17
|
+
* (`compareNtpShort`), never with `<` or `Math.max`.
|
|
18
|
+
*
|
|
19
|
+
* A non-finite value means the host could not read a header; that packet
|
|
20
|
+
* neither raises the high-water mark nor commits. Any other value that is
|
|
21
|
+
* not NTP short is a host bug and throws a RangeError.
|
|
22
|
+
*/
|
|
12
23
|
timestamp: number;
|
|
13
24
|
hasValidCodecConfig: boolean;
|
|
14
25
|
}
|
|
@@ -23,6 +34,7 @@ export interface OrchestratorOptions {
|
|
|
23
34
|
}
|
|
24
35
|
|
|
25
36
|
export interface GoodputSample { bitrate: number | undefined; }
|
|
37
|
+
export interface HeadroomSample { healthy: boolean; }
|
|
26
38
|
|
|
27
39
|
export const DEFAULT_DWELL_MS = 3_000;
|
|
28
40
|
export const DEFAULT_JOIN_DEADLINE_MS = 5_000;
|
|
@@ -44,9 +56,14 @@ export class MulticastOrchestrator {
|
|
|
44
56
|
#current: MulticastTierEndpoint | undefined;
|
|
45
57
|
#target: MulticastTierEndpoint | undefined;
|
|
46
58
|
#estimate: number | undefined;
|
|
47
|
-
#favored: { key: string; since: number } | undefined;
|
|
59
|
+
#favored: { key: string; since: number; source: "goodput" | "headroom" } | undefined;
|
|
48
60
|
#joinStartedAt = 0;
|
|
49
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Latest active-tier send time (NTP short), or undefined before the first.
|
|
63
|
+
* "Latest" is serial order, not the numerically largest value: after the
|
|
64
|
+
* field wraps, the largest value is the stale one.
|
|
65
|
+
*/
|
|
66
|
+
#highWater: number | undefined;
|
|
50
67
|
readonly #dwellMs: number;
|
|
51
68
|
readonly #joinDeadlineMs: number;
|
|
52
69
|
|
|
@@ -71,20 +88,70 @@ export class MulticastOrchestrator {
|
|
|
71
88
|
|
|
72
89
|
onGoodput(sample: GoodputSample): OrchestratorCommand | undefined {
|
|
73
90
|
const bitrate = sample.bitrate;
|
|
74
|
-
this.#estimate = bitrate;
|
|
75
91
|
if (!this.#current || this.options.adaptive === false) return undefined;
|
|
76
92
|
if (this.#state === "joining") return this.poll();
|
|
93
|
+
if (bitrate === undefined || !Number.isFinite(bitrate)) return undefined;
|
|
94
|
+
this.#estimate = bitrate;
|
|
77
95
|
const desired = selectMulticastTier(this.options.endpoints, bitrate);
|
|
78
96
|
if (!desired) return undefined;
|
|
79
97
|
const desiredKey = endpointKey(desired);
|
|
80
98
|
if (desiredKey === endpointKey(this.#current)) {
|
|
99
|
+
if (this.#favored?.source === "goodput") this.#favored = undefined;
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
if (typeof desired.bandwidth !== "number" || typeof this.#current.bandwidth !== "number" ||
|
|
103
|
+
desired.bandwidth >= this.#current.bandwidth) {
|
|
104
|
+
if (this.#favored?.source === "goodput") this.#favored = undefined;
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const now = this.#now();
|
|
109
|
+
if (!this.#favored || this.#favored.key !== desiredKey || this.#favored.source !== "goodput") {
|
|
110
|
+
this.#favored = { key: desiredKey, since: now, source: "goodput" };
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
if (now - this.#favored.since < this.#dwellMs) return undefined;
|
|
114
|
+
|
|
115
|
+
this.#state = "joining";
|
|
116
|
+
this.#target = desired;
|
|
117
|
+
this.#joinStartedAt = now;
|
|
118
|
+
this.#favored = undefined;
|
|
119
|
+
return { kind: "join-target", current: this.#current, target: desired };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
onHeadroom(sample: HeadroomSample): OrchestratorCommand | undefined {
|
|
123
|
+
if (!this.#current || this.options.adaptive === false || this.#state === "joining") return undefined;
|
|
124
|
+
if (!sample.healthy) {
|
|
125
|
+
if (this.#favored?.source === "headroom") this.#favored = undefined;
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
if (this.#favored?.source === "goodput") return undefined;
|
|
129
|
+
|
|
130
|
+
const currentBandwidth = this.#current.bandwidth;
|
|
131
|
+
if (typeof currentBandwidth !== "number") return undefined;
|
|
132
|
+
// Ranked here rather than through selectMulticastTier: up-selection
|
|
133
|
+
// advances exactly one adjacent rung, where the selector fits a budget to
|
|
134
|
+
// an estimate. The media filter is shared so a repair-only endpoint above
|
|
135
|
+
// the current rung is not joined as an upgrade, which would swap the
|
|
136
|
+
// receiver onto repair symbols and no media.
|
|
137
|
+
const desired = this.options.endpoints
|
|
138
|
+
.filter((endpoint) =>
|
|
139
|
+
carriesMedia(endpoint) && typeof endpoint.bandwidth === "number" && endpoint.bandwidth > currentBandwidth,
|
|
140
|
+
)
|
|
141
|
+
.sort((a, b) => a.bandwidth! - b.bandwidth!)[0];
|
|
142
|
+
if (!desired) {
|
|
81
143
|
this.#favored = undefined;
|
|
82
144
|
return undefined;
|
|
83
145
|
}
|
|
84
146
|
|
|
147
|
+
const desiredKey = endpointKey(desired);
|
|
148
|
+
if (desiredKey === endpointKey(this.#current)) {
|
|
149
|
+
this.#favored = undefined;
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
85
152
|
const now = this.#now();
|
|
86
153
|
if (!this.#favored || this.#favored.key !== desiredKey) {
|
|
87
|
-
this.#favored = { key: desiredKey, since: now };
|
|
154
|
+
this.#favored = { key: desiredKey, since: now, source: "headroom" };
|
|
88
155
|
return undefined;
|
|
89
156
|
}
|
|
90
157
|
if (now - this.#favored.since < this.#dwellMs) return undefined;
|
|
@@ -96,10 +163,25 @@ export class MulticastOrchestrator {
|
|
|
96
163
|
return { kind: "join-target", current: this.#current, target: desired };
|
|
97
164
|
}
|
|
98
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Feed a packet from the active tier (to advance the high-water mark) or
|
|
168
|
+
* from the in-flight target (to find a committable RAP).
|
|
169
|
+
*
|
|
170
|
+
* Serial order is only the true order while the compared instants are less
|
|
171
|
+
* than `NTP_SHORT_HALF_RANGE` (2^15 s, ~9.1 h) apart. Feeding every
|
|
172
|
+
* active-tier packet keeps the mark that close to the live stream. A mark
|
|
173
|
+
* refreshed only by commits can fall further behind than that, and a
|
|
174
|
+
* target RAP may then compare as older, so the join is abandoned.
|
|
175
|
+
*/
|
|
99
176
|
onTierPacket(packet: MulticastTierPacket): OrchestratorCommand | undefined {
|
|
100
177
|
if (!this.#current) return undefined;
|
|
101
|
-
|
|
102
|
-
|
|
178
|
+
const sentAt = tierPacketSendTime(packet);
|
|
179
|
+
// An instant exactly half the space from the mark (RFC 1982's undefined
|
|
180
|
+
// case) is neither later nor earlier, so it leaves the mark alone, as a
|
|
181
|
+
// reordered earlier packet does.
|
|
182
|
+
if (packet.endpointKey === endpointKey(this.#current) && sentAt !== undefined &&
|
|
183
|
+
(this.#highWater === undefined || compareNtpShort(sentAt, this.#highWater) === "after")) {
|
|
184
|
+
this.#highWater = sentAt;
|
|
103
185
|
}
|
|
104
186
|
if (this.#state !== "joining" || !this.#target) return undefined;
|
|
105
187
|
const expired = this.poll();
|
|
@@ -108,8 +190,8 @@ export class MulticastOrchestrator {
|
|
|
108
190
|
packet.endpointKey !== endpointKey(this.#target) ||
|
|
109
191
|
!packet.keyframe ||
|
|
110
192
|
!packet.hasValidCodecConfig ||
|
|
111
|
-
|
|
112
|
-
|
|
193
|
+
sentAt === undefined ||
|
|
194
|
+
!this.#reachesHighWater(sentAt)
|
|
113
195
|
) return undefined;
|
|
114
196
|
|
|
115
197
|
const command: OrchestratorCommand = {
|
|
@@ -117,7 +199,7 @@ export class MulticastOrchestrator {
|
|
|
117
199
|
target: this.#target,
|
|
118
200
|
old: this.#current,
|
|
119
201
|
};
|
|
120
|
-
this.#
|
|
202
|
+
this.#highWater = sentAt;
|
|
121
203
|
this.#current = this.#target;
|
|
122
204
|
this.#target = undefined;
|
|
123
205
|
this.#state = "stable";
|
|
@@ -142,9 +224,31 @@ export class MulticastOrchestrator {
|
|
|
142
224
|
return command;
|
|
143
225
|
}
|
|
144
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Whether a target RAP sent at `sentAt` is at or beyond the active tier's
|
|
229
|
+
* high-water mark. A pair exactly half the NTP-short space apart has no
|
|
230
|
+
* serial order, so it cannot show that and does not commit.
|
|
231
|
+
*/
|
|
232
|
+
#reachesHighWater(sentAt: number): boolean {
|
|
233
|
+
if (this.#highWater === undefined) return true;
|
|
234
|
+
const order = compareNtpShort(sentAt, this.#highWater);
|
|
235
|
+
return order === "after" || order === "equal";
|
|
236
|
+
}
|
|
237
|
+
|
|
145
238
|
#now(): number {
|
|
146
239
|
const now = this.options.now();
|
|
147
240
|
if (!Number.isFinite(now)) throw new Error("Multicast orchestrator clock must be finite");
|
|
148
241
|
return now;
|
|
149
242
|
}
|
|
150
243
|
}
|
|
244
|
+
|
|
245
|
+
/** A tier packet's NTP-short send time, or undefined when the host had no header to read. */
|
|
246
|
+
function tierPacketSendTime(packet: MulticastTierPacket): number | undefined {
|
|
247
|
+
if (!Number.isFinite(packet.timestamp)) return undefined;
|
|
248
|
+
if (!isNtpShort(packet.timestamp)) {
|
|
249
|
+
throw new RangeError(
|
|
250
|
+
`Multicast tier packet timestamp must be an MMTP NTP-short send time, got ${packet.timestamp}`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
return packet.timestamp;
|
|
254
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { isMulticastTrackPacketId, isValidMulticastGroupAddress } from "./endpoint-config.js";
|
|
2
|
+
|
|
3
|
+
export interface MulticastSignalingEndpoint {
|
|
4
|
+
endpointId: string;
|
|
5
|
+
endpointVersion: number;
|
|
6
|
+
protocol: "ssm" | "asm" | "amt";
|
|
7
|
+
sourceAddress?: string;
|
|
8
|
+
groupAddress: string;
|
|
9
|
+
port: number;
|
|
10
|
+
validUntil?: number;
|
|
11
|
+
tracks: Array<{ name: string; packetId?: number; [key: string]: unknown }>;
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Catalog = Record<string, any>;
|
|
16
|
+
type Signal = Record<string, any>;
|
|
17
|
+
|
|
18
|
+
export function createMulticastSignalingReconciler(catalog: Catalog, options: { catalog?: Catalog } = {}) {
|
|
19
|
+
const initial = normalizeCatalogEndpoints(catalog);
|
|
20
|
+
const endpoints = new Map(initial.map(endpoint => [endpoint.endpointId, copyEndpoint(endpoint)]));
|
|
21
|
+
const versions = new Map(initial.map(endpoint => [endpoint.endpointId, endpoint.endpointVersion]));
|
|
22
|
+
const seen = new Set<string>();
|
|
23
|
+
const dormant = new Set<string>();
|
|
24
|
+
let catalogVersion = Number(catalog?.multicast?.version ?? catalog?.version ?? 0) || 0;
|
|
25
|
+
const snapshot = (now = Date.now()) => ({
|
|
26
|
+
catalogVersion,
|
|
27
|
+
endpoints: [...endpoints.values()].filter(endpoint => !dormant.has(endpoint.endpointId) && !isExpired(endpoint, now)).map(copyEndpoint),
|
|
28
|
+
dormantEndpointIds: [...dormant].sort(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
apply(signal: Signal, now = Date.now()) {
|
|
33
|
+
if (!isObject(signal) || typeof signal.type !== "string" || typeof signal.msgId !== "string") return { applied: false, reason: "invalid-message" };
|
|
34
|
+
if (seen.has(signal.msgId)) return { applied: false, reason: "duplicate-message" };
|
|
35
|
+
if (signal.expiresAt != null && Number(signal.expiresAt) < now) return { applied: false, reason: "expired" };
|
|
36
|
+
const signalCatalogVersion = Number(signal.catalogVersion ?? 0);
|
|
37
|
+
if (!Number.isFinite(signalCatalogVersion)) return { applied: false, reason: "invalid-message" };
|
|
38
|
+
if (signalCatalogVersion < catalogVersion) return { applied: false, reason: "stale-catalog" };
|
|
39
|
+
const result = applyFresh(signal, now);
|
|
40
|
+
if (result.applied) {
|
|
41
|
+
seen.add(signal.msgId);
|
|
42
|
+
// A heartbeat proves liveness but carries no catalog state. Ratcheting
|
|
43
|
+
// here would incorrectly reject the next real snapshot as stale.
|
|
44
|
+
if (signal.type !== "heartbeat") {
|
|
45
|
+
catalogVersion = Math.max(catalogVersion, signalCatalogVersion);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
},
|
|
50
|
+
snapshot,
|
|
51
|
+
toCatalog(now = Date.now()) {
|
|
52
|
+
const current = snapshot(now);
|
|
53
|
+
return { ...catalog, multicast: { ...catalog.multicast, version: current.catalogVersion, endpoints: current.endpoints } };
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function applyFresh(signal: Signal, now: number): { applied: boolean; reason?: string; action?: string } {
|
|
58
|
+
switch (signal.type) {
|
|
59
|
+
case "snapshot": {
|
|
60
|
+
const next = signal.payload?.endpoints;
|
|
61
|
+
if (!Array.isArray(next)) return { applied: false, reason: "invalid-message" };
|
|
62
|
+
const normalized = next.map((endpoint, index) => normalizeEndpoint(endpoint, index));
|
|
63
|
+
if (normalized.some(endpoint => !endpoint || !isValidEndpoint(endpoint, options.catalog))) return { applied: false, reason: "invalid-endpoint" };
|
|
64
|
+
const ids = new Set(normalized.map(endpoint => endpoint!.endpointId));
|
|
65
|
+
if (ids.size !== normalized.length) return { applied: false, reason: "duplicate-endpoint" };
|
|
66
|
+
endpoints.clear(); versions.clear(); dormant.clear();
|
|
67
|
+
for (const endpoint of normalized as MulticastSignalingEndpoint[]) {
|
|
68
|
+
versions.set(endpoint.endpointId, endpoint.endpointVersion);
|
|
69
|
+
if (!isExpired(endpoint, now)) endpoints.set(endpoint.endpointId, copyEndpoint(endpoint));
|
|
70
|
+
}
|
|
71
|
+
return { applied: true, action: "snapshot" };
|
|
72
|
+
}
|
|
73
|
+
case "add": return upsert(signal.payload?.endpoint, now);
|
|
74
|
+
case "update": {
|
|
75
|
+
const id = signal.payload?.endpointId;
|
|
76
|
+
const fields = signal.payload?.fields;
|
|
77
|
+
if (!endpoints.has(id)) return { applied: false, reason: "unknown-endpoint" };
|
|
78
|
+
if (isStale(id, signal.payload?.endpointVersion)) return { applied: false, reason: "stale-endpoint" };
|
|
79
|
+
if (!isObject(fields) || ["protocol", "sourceAddress", "groupAddress", "port"].some(key => Object.hasOwn(fields, key))) return { applied: false, reason: "invalid-update" };
|
|
80
|
+
return upsert({ ...endpoints.get(id), ...fields, endpointVersion: signal.payload.endpointVersion }, now);
|
|
81
|
+
}
|
|
82
|
+
case "remove": {
|
|
83
|
+
const id = signal.payload?.endpointId;
|
|
84
|
+
if (!endpoints.has(id)) return { applied: false, reason: "unknown-endpoint" };
|
|
85
|
+
const endpointVersion = Number(signal.payload?.endpointVersion);
|
|
86
|
+
if (!Number.isFinite(endpointVersion)) return { applied: false, reason: "invalid-message" };
|
|
87
|
+
if (isStale(id, endpointVersion)) return { applied: false, reason: "stale-endpoint" };
|
|
88
|
+
endpoints.delete(id); dormant.delete(id); versions.set(id, endpointVersion);
|
|
89
|
+
return { applied: true, action: "drop" };
|
|
90
|
+
}
|
|
91
|
+
case "invalidate": {
|
|
92
|
+
const id = signal.payload?.endpointId;
|
|
93
|
+
if (!endpoints.has(id)) return { applied: false, reason: "unknown-endpoint" };
|
|
94
|
+
dormant.add(id);
|
|
95
|
+
return { applied: true, action: "drop" };
|
|
96
|
+
}
|
|
97
|
+
case "heartbeat": return { applied: true, action: "heartbeat" };
|
|
98
|
+
default: return { applied: false, reason: "unknown-message-type" };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function upsert(value: unknown, now: number) {
|
|
103
|
+
const endpoint = normalizeEndpoint(value, endpoints.size);
|
|
104
|
+
if (!endpoint || !isValidEndpoint(endpoint, options.catalog)) return { applied: false, reason: "invalid-endpoint" };
|
|
105
|
+
if (isStale(endpoint.endpointId, endpoint.endpointVersion)) return { applied: false, reason: "stale-endpoint" };
|
|
106
|
+
versions.set(endpoint.endpointId, endpoint.endpointVersion);
|
|
107
|
+
dormant.delete(endpoint.endpointId);
|
|
108
|
+
if (isExpired(endpoint, now)) endpoints.delete(endpoint.endpointId);
|
|
109
|
+
else endpoints.set(endpoint.endpointId, copyEndpoint(endpoint));
|
|
110
|
+
return { applied: true, action: isExpired(endpoint, now) ? "drop" : "candidate" };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isStale(id: string, version: unknown) {
|
|
114
|
+
const current = versions.get(id);
|
|
115
|
+
return current !== undefined && Number(version) <= current;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function normalizeCatalogEndpoints(catalog: Catalog): MulticastSignalingEndpoint[] {
|
|
120
|
+
const endpoints = catalog?.multicast?.endpoints;
|
|
121
|
+
if (!Array.isArray(endpoints)) return [];
|
|
122
|
+
return endpoints
|
|
123
|
+
.map(normalizeEndpoint)
|
|
124
|
+
.filter((endpoint): endpoint is MulticastSignalingEndpoint => !!endpoint && isValidEndpoint(endpoint, catalog));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeEndpoint(value: unknown, index: number): MulticastSignalingEndpoint | undefined {
|
|
128
|
+
if (!isObject(value)) return undefined;
|
|
129
|
+
const endpoint = value as Record<string, any>;
|
|
130
|
+
const tracks = Array.isArray(endpoint.tracks) ? endpoint.tracks.map(track => typeof track === "string" ? { name: track } : { ...track }).filter(track => track.name) : [];
|
|
131
|
+
return { ...endpoint, endpointId: endpoint.endpointId || `${endpoint.sourceAddress || ""}|${endpoint.groupAddress || ""}|${endpoint.port || ""}|${index}`, endpointVersion: Number(endpoint.endpointVersion ?? 0), tracks } as MulticastSignalingEndpoint;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isValidEndpoint(endpoint: MulticastSignalingEndpoint, catalog?: Catalog): boolean {
|
|
135
|
+
if (!endpoint.endpointId || !Number.isFinite(endpoint.endpointVersion)) return false;
|
|
136
|
+
if (!["ssm", "asm", "amt"].includes(endpoint.protocol)) return false;
|
|
137
|
+
if (endpoint.protocol === "ssm" && (typeof endpoint.sourceAddress !== "string" || endpoint.sourceAddress.trim().length === 0)) return false;
|
|
138
|
+
if (!isValidMulticastGroupAddress(endpoint.groupAddress) || !Number.isInteger(Number(endpoint.port)) || endpoint.port <= 0 || endpoint.port > 65535 || !endpoint.tracks.length) return false;
|
|
139
|
+
const catalogTracks = new Set([...Object.keys(catalog?.video?.renditions ?? {}), ...Object.keys(catalog?.audio?.renditions ?? {})]);
|
|
140
|
+
// Every track must carry a valid packet_id (isMulticastTrackPacketId); one
|
|
141
|
+
// that does not -- including packet_id 0, the MMTP signaling flow --
|
|
142
|
+
// rejects the whole endpoint rather than silently dropping that track.
|
|
143
|
+
return endpoint.tracks.every(track => !!track.name && isMulticastTrackPacketId(track.packetId) && (catalogTracks.size === 0 || catalogTracks.has(track.name) || catalogTracks.has(track.name.replace(/\/repair$/, ""))));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function copyEndpoint(endpoint: MulticastSignalingEndpoint): MulticastSignalingEndpoint {
|
|
147
|
+
return { ...endpoint, tracks: endpoint.tracks.map(track => ({ ...track })) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isExpired(endpoint: MulticastSignalingEndpoint, now: number): boolean {
|
|
151
|
+
return endpoint.validUntil != null && Number(endpoint.validUntil) < now;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
155
|
+
return value !== null && typeof value === "object";
|
|
156
|
+
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
multicastEndpointKey,
|
|
3
|
+
normalizeMulticastEndpoint,
|
|
4
|
+
toWindowMulticastSubscribeConfig,
|
|
5
|
+
} from "./endpoint-config.js";
|
|
6
|
+
import { subscriptionDeliversPacketBatches } from "./packet-batch.js";
|
|
2
7
|
import type {
|
|
3
8
|
TransportConfig,
|
|
4
9
|
WindowMulticastGateway,
|
|
@@ -15,7 +20,13 @@ export interface MulticastSubscriptionConsumer {
|
|
|
15
20
|
interface PoolEntry {
|
|
16
21
|
subscription: WindowMulticastSubscription;
|
|
17
22
|
consumers: Map<symbol, MulticastSubscriptionConsumer>;
|
|
18
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Exactly one packet surface is listened to: `packets` when the provider
|
|
25
|
+
* advertises it, `bytes` otherwise (BLO-36599).
|
|
26
|
+
*/
|
|
27
|
+
packetListener:
|
|
28
|
+
| { type: "bytes"; listener: (event: CustomEvent<ArrayBuffer>) => void }
|
|
29
|
+
| { type: "packets"; listener: (event: CustomEvent<readonly ArrayBuffer[]>) => void };
|
|
19
30
|
stateListener: (event: CustomEvent<WindowMulticastSubscriptionState>) => void;
|
|
20
31
|
errorListener: (event: CustomEvent<unknown>) => void;
|
|
21
32
|
closed: boolean;
|
|
@@ -103,20 +114,35 @@ export class MulticastSubscriptionPool {
|
|
|
103
114
|
endpoint: NonNullable<ReturnType<typeof normalizeMulticastEndpoint>>,
|
|
104
115
|
onClosed: (entry: PoolEntry) => void,
|
|
105
116
|
): Promise<PoolEntry> {
|
|
106
|
-
const subscription = await gateway.subscribe!({
|
|
117
|
+
const subscription = await gateway.subscribe!(toWindowMulticastSubscribeConfig({
|
|
107
118
|
group: endpoint.groupAddress,
|
|
108
119
|
...(endpoint.sourceAddress ? { source: endpoint.sourceAddress } : {}),
|
|
109
120
|
port: endpoint.port,
|
|
110
|
-
consume: "bytes",
|
|
111
121
|
...(endpoint.mode ? { mode: endpoint.mode } : {}),
|
|
112
122
|
...(endpoint.amtRelay ? { amtRelay: endpoint.amtRelay } : {}),
|
|
113
123
|
...(endpoint.amtRelayPort ? { amtRelayPort: endpoint.amtRelayPort } : {}),
|
|
114
|
-
});
|
|
124
|
+
}));
|
|
115
125
|
const consumers = new Map<symbol, MulticastSubscriptionConsumer>();
|
|
116
126
|
let entry: PoolEntry;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
127
|
+
// A batch is delivered as the same per-packet onBytes calls the bytes
|
|
128
|
+
// surface makes, in arrival order and with each call isolated, so a
|
|
129
|
+
// consumer cannot tell the two apart; only the DOM event per packet is
|
|
130
|
+
// gone. The batch array and its elements are the provider's own, uncopied.
|
|
131
|
+
const packetListener: PoolEntry["packetListener"] = subscriptionDeliversPacketBatches(subscription)
|
|
132
|
+
? {
|
|
133
|
+
type: "packets",
|
|
134
|
+
listener: (event) => {
|
|
135
|
+
for (const data of event.detail) {
|
|
136
|
+
this.#dispatch(consumers, (consumer) => consumer.onBytes(data));
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
: {
|
|
141
|
+
type: "bytes",
|
|
142
|
+
listener: (event) => {
|
|
143
|
+
this.#dispatch(consumers, (consumer) => consumer.onBytes(event.detail));
|
|
144
|
+
},
|
|
145
|
+
};
|
|
120
146
|
const stateListener = (event: CustomEvent<WindowMulticastSubscriptionState>) => {
|
|
121
147
|
this.#dispatch(consumers, (consumer) => consumer.onState?.(event.detail));
|
|
122
148
|
if (event.detail === "closed" && !entry.closed) {
|
|
@@ -128,8 +154,12 @@ export class MulticastSubscriptionPool {
|
|
|
128
154
|
const error = event.detail instanceof Error ? event.detail : new Error(String(event.detail));
|
|
129
155
|
this.#dispatch(consumers, (consumer) => consumer.onError?.(error));
|
|
130
156
|
};
|
|
131
|
-
entry = { subscription, consumers,
|
|
132
|
-
|
|
157
|
+
entry = { subscription, consumers, packetListener, stateListener, errorListener, closed: false };
|
|
158
|
+
if (packetListener.type === "packets") {
|
|
159
|
+
subscription.addEventListener("packets", packetListener.listener);
|
|
160
|
+
} else {
|
|
161
|
+
subscription.addEventListener("bytes", packetListener.listener);
|
|
162
|
+
}
|
|
133
163
|
subscription.addEventListener("state", stateListener);
|
|
134
164
|
subscription.addEventListener("error", errorListener);
|
|
135
165
|
return entry;
|
|
@@ -146,7 +176,12 @@ export class MulticastSubscriptionPool {
|
|
|
146
176
|
}
|
|
147
177
|
|
|
148
178
|
#detach(entry: PoolEntry): void {
|
|
149
|
-
|
|
179
|
+
const { packetListener } = entry;
|
|
180
|
+
if (packetListener.type === "packets") {
|
|
181
|
+
entry.subscription.removeEventListener("packets", packetListener.listener);
|
|
182
|
+
} else {
|
|
183
|
+
entry.subscription.removeEventListener("bytes", packetListener.listener);
|
|
184
|
+
}
|
|
150
185
|
entry.subscription.removeEventListener("state", entry.stateListener);
|
|
151
186
|
entry.subscription.removeEventListener("error", entry.errorListener);
|
|
152
187
|
}
|
package/src/multicast-tier.ts
CHANGED
|
@@ -5,22 +5,49 @@ export interface MulticastTierEndpoint {
|
|
|
5
5
|
tracks: { name: string; packetId?: number }[];
|
|
6
6
|
bandwidth?: number;
|
|
7
7
|
protocol?: string;
|
|
8
|
+
/** False when every track on this endpoint is a `fec-repair` track, so the
|
|
9
|
+
* endpoint carries repair symbols only and no media. A repair-layer
|
|
10
|
+
* endpoint is joined by the loss-driven layer policy, never by the
|
|
11
|
+
* bandwidth ladder: selecting one as a tier would leave the receiver with
|
|
12
|
+
* no media at all. Set by the catalog policy, which resolves repair-track
|
|
13
|
+
* membership from `depends`/`fec.repairTrack` rather than from names.
|
|
14
|
+
* Undefined means "not classified" and is treated as carrying media, so a
|
|
15
|
+
* caller that builds endpoints without the policy is unaffected. */
|
|
16
|
+
carriesMedia?: boolean;
|
|
8
17
|
}
|
|
9
18
|
|
|
10
19
|
export function endpointKey(endpoint: MulticastTierEndpoint): string {
|
|
11
20
|
return `${endpoint.sourceAddress ?? ""}|${endpoint.groupAddress}|${endpoint.port}`;
|
|
12
21
|
}
|
|
13
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Whether this endpoint may be selected as a media tier.
|
|
25
|
+
*
|
|
26
|
+
* Every path that picks a tier must apply this, not only `selectMulticastTier`:
|
|
27
|
+
* the orchestrator's headroom up-selection ranks endpoints itself, because it
|
|
28
|
+
* advances one adjacent rung rather than fitting a bandwidth budget. Exported
|
|
29
|
+
* so both rankings share one definition of "carries media" instead of
|
|
30
|
+
* repeating the comparison and drifting apart.
|
|
31
|
+
*/
|
|
32
|
+
export function carriesMedia(endpoint: MulticastTierEndpoint): boolean {
|
|
33
|
+
return endpoint.carriesMedia !== false;
|
|
34
|
+
}
|
|
35
|
+
|
|
14
36
|
export function selectMulticastTier(
|
|
15
37
|
endpoints: readonly MulticastTierEndpoint[],
|
|
16
38
|
estimate: number | undefined,
|
|
17
39
|
): MulticastTierEndpoint | undefined {
|
|
18
|
-
|
|
40
|
+
// Repair-only endpoints are never a media tier. Filtering once here covers
|
|
41
|
+
// every return path below, including the two early returns and the fallback:
|
|
42
|
+
// a ladder that has only repair-layer endpoints returns undefined rather
|
|
43
|
+
// than joining one and receiving no media.
|
|
44
|
+
const media = endpoints.filter(carriesMedia);
|
|
45
|
+
if (media.length <= 1) return media[0];
|
|
19
46
|
|
|
20
|
-
const ranked =
|
|
47
|
+
const ranked = media
|
|
21
48
|
.filter((endpoint) => typeof endpoint.bandwidth === "number" && endpoint.bandwidth > 0)
|
|
22
49
|
.sort((a, b) => a.bandwidth! - b.bandwidth!);
|
|
23
|
-
if (ranked.length < 2) return
|
|
50
|
+
if (ranked.length < 2) return media[0];
|
|
24
51
|
if (estimate === undefined) return ranked[0];
|
|
25
52
|
|
|
26
53
|
const budget = estimate * 0.8;
|
package/src/no-modules.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AdaptiveBitrateController } from "./abr-controller.js";
|
|
1
|
+
import { AdaptiveBitrateController, validateQualityLadder } from "./abr-controller.js";
|
|
2
2
|
import {
|
|
3
3
|
isMulticastExplicitlyDisabled,
|
|
4
4
|
isUsableMulticastEndpoint,
|
|
@@ -6,19 +6,60 @@ import {
|
|
|
6
6
|
multicastEndpointKey,
|
|
7
7
|
normalizeMulticastEndpoint,
|
|
8
8
|
readUrlMulticastConfig,
|
|
9
|
+
toWindowMulticastSubscribeConfig,
|
|
9
10
|
} from "./endpoint-config.js";
|
|
10
11
|
import { MulticastOrchestrator } from "./multicast-orchestrator.js";
|
|
12
|
+
import {
|
|
13
|
+
endpointToMulticastConfig,
|
|
14
|
+
hasMulticastCatalogBaseline,
|
|
15
|
+
hasUnicastMediaRenditions,
|
|
16
|
+
isMulticastOnlyCatalog,
|
|
17
|
+
isRecoverableMulticastSignalReadError,
|
|
18
|
+
mergeUrlMulticastPolicy,
|
|
19
|
+
normalizeMulticastConfig,
|
|
20
|
+
readPlayerMulticastConfig,
|
|
21
|
+
requiresMulticastCapability,
|
|
22
|
+
sameMulticastConfig,
|
|
23
|
+
selectEndpointPacketIds,
|
|
24
|
+
selectMulticastCandidate,
|
|
25
|
+
catalogQualityLadder,
|
|
26
|
+
urlOverrideBlocksMulticastCandidate,
|
|
27
|
+
withMulticastEndpoints,
|
|
28
|
+
} from "./multicast-catalog-policy.js";
|
|
29
|
+
import {
|
|
30
|
+
createMulticastSignalingReconciler,
|
|
31
|
+
normalizeCatalogEndpoints,
|
|
32
|
+
} from "./multicast-signaling-reconciler.js";
|
|
11
33
|
import { MulticastSubscriptionPool } from "./multicast-subscription-pool.js";
|
|
12
34
|
import { endpointKey, selectMulticastTier } from "./multicast-tier.js";
|
|
13
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
MOQT_PUBLISHER_PRIORITY,
|
|
37
|
+
MOQT_SUBSCRIBER_PRIORITY,
|
|
38
|
+
moqtPublisherPriority,
|
|
39
|
+
moqtSubscriberPriority,
|
|
40
|
+
} from "./moqt-priority.js";
|
|
41
|
+
import { classifyStall, TransportStallDetector } from "./stall-detector.js";
|
|
14
42
|
import { makeSourceSwitchHandler, wireStarvationDetector } from "./takeover.js";
|
|
15
43
|
import { TransportManager } from "./transport-manager.js";
|
|
16
44
|
import { runUnicastFallback } from "./unicast-fallback.js";
|
|
17
45
|
|
|
18
46
|
const api = {
|
|
19
47
|
AdaptiveBitrateController,
|
|
48
|
+
validateQualityLadder,
|
|
49
|
+
catalogQualityLadder,
|
|
50
|
+
classifyStall,
|
|
51
|
+
createMulticastSignalingReconciler,
|
|
52
|
+
endpointToMulticastConfig,
|
|
53
|
+
hasMulticastCatalogBaseline,
|
|
54
|
+
hasUnicastMediaRenditions,
|
|
55
|
+
isRecoverableMulticastSignalReadError,
|
|
56
|
+
isMulticastOnlyCatalog,
|
|
20
57
|
MulticastOrchestrator,
|
|
21
58
|
MulticastSubscriptionPool,
|
|
59
|
+
MOQT_PUBLISHER_PRIORITY,
|
|
60
|
+
MOQT_SUBSCRIBER_PRIORITY,
|
|
61
|
+
moqtPublisherPriority,
|
|
62
|
+
moqtSubscriberPriority,
|
|
22
63
|
TransportManager,
|
|
23
64
|
TransportStallDetector,
|
|
24
65
|
endpointKey,
|
|
@@ -26,11 +67,22 @@ const api = {
|
|
|
26
67
|
isUsableMulticastEndpoint,
|
|
27
68
|
isValidMulticastGroupAddress,
|
|
28
69
|
makeSourceSwitchHandler,
|
|
70
|
+
mergeUrlMulticastPolicy,
|
|
29
71
|
multicastEndpointKey,
|
|
72
|
+
normalizeCatalogEndpoints,
|
|
73
|
+
normalizeMulticastConfig,
|
|
30
74
|
normalizeMulticastEndpoint,
|
|
75
|
+
readPlayerMulticastConfig,
|
|
31
76
|
readUrlMulticastConfig,
|
|
77
|
+
toWindowMulticastSubscribeConfig,
|
|
78
|
+
requiresMulticastCapability,
|
|
32
79
|
runUnicastFallback,
|
|
80
|
+
sameMulticastConfig,
|
|
81
|
+
selectEndpointPacketIds,
|
|
82
|
+
selectMulticastCandidate,
|
|
33
83
|
selectMulticastTier,
|
|
84
|
+
urlOverrideBlocksMulticastCandidate,
|
|
85
|
+
withMulticastEndpoints,
|
|
34
86
|
wireStarvationDetector,
|
|
35
87
|
};
|
|
36
88
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { WindowMulticastSubscription } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* True when `subscription` delivers packets in batches through the `packets`
|
|
5
|
+
* provider extension (BLO-36599).
|
|
6
|
+
*
|
|
7
|
+
* The pim window-multicast-bridge (0.2.0 and later) advertises the extension
|
|
8
|
+
* in `eventTypes`. It builds only the events some listener asked for, so a
|
|
9
|
+
* consumer that listens to `packets` alone gets one event per reader batch
|
|
10
|
+
* instead of one DOM event per packet. A provider without the field, or one
|
|
11
|
+
* that does not list `packets`, is a v1 provider: listen to `bytes`.
|
|
12
|
+
*/
|
|
13
|
+
export function subscriptionDeliversPacketBatches(
|
|
14
|
+
subscription: Pick<WindowMulticastSubscription, "eventTypes">,
|
|
15
|
+
): boolean {
|
|
16
|
+
return subscription.eventTypes?.includes("packets") === true;
|
|
17
|
+
}
|
package/src/stall-detector.ts
CHANGED
|
@@ -13,6 +13,43 @@ export interface StallDetectorConfig {
|
|
|
13
13
|
now?: () => number;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export interface PlayerStallSnapshot {
|
|
17
|
+
liveQ?: number | null;
|
|
18
|
+
decodeCallsDelta?: number | null;
|
|
19
|
+
fps?: number | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PlayerStallClassification {
|
|
23
|
+
verdict: "DECODE-BOUND" | "PIPELINE-STARVED" | "STARVED-OR-SLOW" | "INDETERMINATE";
|
|
24
|
+
reason: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function classifyStall({
|
|
28
|
+
liveQ = null,
|
|
29
|
+
decodeCallsDelta = null,
|
|
30
|
+
fps = null,
|
|
31
|
+
}: PlayerStallSnapshot = {}): PlayerStallClassification {
|
|
32
|
+
if (liveQ != null && liveQ >= 20) {
|
|
33
|
+
return {
|
|
34
|
+
verdict: "DECODE-BOUND",
|
|
35
|
+
reason: `decode queue backlogged now (liveQ=${liveQ})`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (liveQ != null && liveQ <= 2 && decodeCallsDelta != null && decodeCallsDelta <= 1) {
|
|
39
|
+
return {
|
|
40
|
+
verdict: "PIPELINE-STARVED",
|
|
41
|
+
reason: `queue empty (liveQ=${liveQ}) and decode flatlined (Δcalls=${decodeCallsDelta}) — nothing reaching the decoder; suspect delivery/FEC/failback, not decode perf`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (fps != null && fps < 45) {
|
|
45
|
+
return {
|
|
46
|
+
verdict: "STARVED-OR-SLOW",
|
|
47
|
+
reason: `low decode rate (fps=${fps}) without a queue/flatline signal to disambiguate`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return { verdict: "INDETERMINATE", reason: "no signal supports a verdict" };
|
|
51
|
+
}
|
|
52
|
+
|
|
16
53
|
export class TransportStallDetector {
|
|
17
54
|
#last: FecProgressSnapshot | null = null;
|
|
18
55
|
#lastSampleAt = 0;
|